aboutsummaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
authorOgulcan Celik <ogulcancelik@gmail.com>2026-07-15 15:48:38 +0300
committerOgulcan Celik <ogulcancelik@gmail.com>2026-07-16 22:47:10 +0300
commit8dcb75a5c08b4dd1f225ea531a27824a9f41ae4a (patch)
tree48b4b74e857c93ffc03ff1b790b2f9e76e0113ac /src
parentf43a6d4a5591a0286e01de737be7b3f42416898b (diff)
refactor: extract active tab surface rendering
Diffstat (limited to 'src')
-rw-r--r--src/kitty_graphics.rs22
-rw-r--r--src/server/headless.rs64
-rw-r--r--src/server/headless/pane_graphics.rs1
-rw-r--r--src/server/render_stream.rs84
-rw-r--r--src/ui.rs57
-rw-r--r--src/ui/panes.rs69
-rw-r--r--src/ui/tab_surface.rs327
7 files changed, 468 insertions, 156 deletions
diff --git a/src/kitty_graphics.rs b/src/kitty_graphics.rs
index 9cbe4c2f..98577ef8 100644
--- a/src/kitty_graphics.rs
+++ b/src/kitty_graphics.rs
@@ -153,7 +153,13 @@ pub(crate) fn paint_local_pane_graphics(
let cache = LOCAL_HOST_GRAPHICS.get_or_init(|| Mutex::new(HostGraphicsCache::default()));
let mut bytes = Vec::new();
if let Ok(mut cache) = cache.lock() {
- bytes = encode_local_pane_graphics(app, terminal_runtimes, cell_size, &mut cache);
+ bytes = encode_local_pane_graphics(
+ app,
+ terminal_runtimes,
+ app.view.tab_surface(),
+ cell_size,
+ &mut cache,
+ );
}
if bytes.is_empty() {
return Ok(());
@@ -172,6 +178,7 @@ pub(crate) fn paint_local_pane_graphics(
pub(crate) fn encode_local_pane_graphics(
app: &AppState,
terminal_runtimes: &TerminalRuntimeRegistry,
+ surface: crate::ui::TabSurfaceView<'_>,
cell_size: HostCellSize,
cache: &mut HostGraphicsCache,
) -> Vec<u8> {
@@ -183,7 +190,7 @@ pub(crate) fn encode_local_pane_graphics(
cell_width_px = cell_size.width_px,
cell_height_px = cell_size.height_px,
active = ?app.active,
- pane_infos_len = app.view.pane_infos.len(),
+ pane_infos_len = surface.pane_infos.len(),
"paint_local_pane_graphics entry"
);
if !mode_ok || !cell_ok {
@@ -199,7 +206,8 @@ pub(crate) fn encode_local_pane_graphics(
}
let view_key = active_view_key(app);
- let placements = collect_visible_placements(app, terminal_runtimes, cell_size, &cache.images);
+ let placements =
+ collect_visible_placements(app, terminal_runtimes, surface, cell_size, &cache.images);
tracing::debug!(
placements_collected = placements.len(),
"collect_visible_placements result"
@@ -228,6 +236,7 @@ pub(crate) fn encode_local_pane_graphics(
pub(crate) fn has_visible_pane_graphics(
app: &AppState,
terminal_runtimes: &TerminalRuntimeRegistry,
+ surface: crate::ui::TabSurfaceView<'_>,
cell_size: HostCellSize,
) -> bool {
if app.mode != Mode::Terminal || !cell_size.is_known() {
@@ -246,7 +255,7 @@ pub(crate) fn has_visible_pane_graphics(
return false;
}
- for info in &app.view.pane_infos {
+ for info in surface.pane_infos {
let empty_uploaded = HashMap::new();
if app.pane_graphics_layers.get(&info.id).is_some_and(|layer| {
let host_placement =
@@ -515,6 +524,7 @@ fn active_view_key(app: &AppState) -> Option<HostViewKey> {
fn collect_visible_placements(
app: &AppState,
terminal_runtimes: &TerminalRuntimeRegistry,
+ surface: crate::ui::TabSurfaceView<'_>,
cell_size: HostCellSize,
uploaded_images: &HashMap<u32, ImageSignature>,
) -> Vec<HostPlacement> {
@@ -538,11 +548,11 @@ fn collect_visible_placements(
tracing::debug!(
ws_idx,
terminal_runtimes_len = terminal_runtimes.len(),
- pane_infos_len = app.view.pane_infos.len(),
+ pane_infos_len = surface.pane_infos.len(),
"collect_visible_placements: starting iteration"
);
let mut placements = Vec::new();
- for info in &app.view.pane_infos {
+ for info in surface.pane_infos {
if let Some(layer) = app.pane_graphics_layers.get(&info.id) {
placements.push(pane_graphics_host_placement(
info,
diff --git a/src/server/headless.rs b/src/server/headless.rs
index 9c21aa1b..c5e8ca62 100644
--- a/src/server/headless.rs
+++ b/src/server/headless.rs
@@ -3384,6 +3384,7 @@ impl HeadlessServer {
&& crate::kitty_graphics::has_visible_pane_graphics(
&self.app.state,
&self.app.terminal_runtimes,
+ self.app.state.view.tab_surface(),
*cell_size,
)
{
@@ -3670,6 +3671,7 @@ impl HeadlessServer {
.extend(crate::kitty_graphics::encode_local_pane_graphics(
&self.app.state,
&self.app.terminal_runtimes,
+ self.app.state.view.tab_surface(),
cell_size,
&mut next_graphics_cache,
));
@@ -7145,16 +7147,28 @@ next_tab = ""
);
}
- #[test]
- fn render_and_stream_uses_each_client_terminal_size() {
+ #[tokio::test]
+ async fn render_and_stream_uses_each_client_terminal_size() {
let mut server = test_headless_server();
- server.app.state.workspaces = vec![crate::workspace::Workspace::test_new("test")];
+ let mut workspace = crate::workspace::Workspace::test_new("test");
+ let active_pane = workspace.tabs[0].root_pane;
+ let background_tab = workspace.test_add_tab(Some("background"));
+ let background_pane = workspace.tabs[background_tab].root_pane;
+ workspace.tabs[0].runtimes.insert(
+ active_pane,
+ crate::terminal::TerminalRuntime::test_with_screen_bytes(80, 24, b"active"),
+ );
+ workspace.tabs[background_tab].runtimes.insert(
+ background_pane,
+ crate::terminal::TerminalRuntime::test_with_screen_bytes(80, 24, b"background"),
+ );
+ server.app.state.workspaces = vec![workspace];
server.app.state.active = Some(0);
server.app.state.selected = 0;
server.app.state.mode = crate::app::Mode::Terminal;
let (desktop_tx, _desktop_control_rx, desktop_rx) = test_client_writer();
- let (phone_tx, _phone_control_rx, phone_rx) = test_client_writer();
+ let (mobile_tx, _mobile_control_rx, mobile_rx) = test_client_writer();
server.clients.insert(
1,
@@ -7171,13 +7185,13 @@ next_tab = ""
server.clients.insert(
2,
ClientConnection::new(
- (80, 24),
+ (44, 20),
crate::kitty_graphics::HostCellSize::default(),
crate::terminal_theme::TerminalTheme::default(),
None,
2,
RenderEncoding::SemanticFrame,
- Some(phone_tx),
+ Some(mobile_tx),
),
);
server.foreground_client_id = Some(1);
@@ -7187,10 +7201,44 @@ next_tab = ""
server.render_and_stream();
let desktop_frame = read_server_frame(desktop_rx.recv().expect("desktop frame"));
- let phone_frame = read_server_frame(phone_rx.recv().expect("phone frame"));
+ let mobile_frame = read_server_frame(mobile_rx.recv().expect("mobile frame"));
assert_eq!((desktop_frame.width, desktop_frame.height), (120, 40));
- assert_eq!((phone_frame.width, phone_frame.height), (80, 24));
+ assert_eq!((mobile_frame.width, mobile_frame.height), (44, 20));
+ let mobile_text = frame_text(&mobile_frame);
+ let mut mobile_rows = mobile_text.lines();
+ let mobile_header = mobile_rows.by_ref().take(2).collect::<String>();
+ let mobile_surface = mobile_rows.collect::<String>();
+ assert!(mobile_header.contains("test"), "header: {mobile_header:?}");
+ assert!(
+ mobile_surface.contains("active"),
+ "surface: {mobile_surface:?}"
+ );
+ assert!(!mobile_surface.contains("background"));
+
+ let foreground_terminal_area = Rect::new(26, 1, 94, 39);
+ let expected_pane_size = (
+ foreground_terminal_area.height,
+ foreground_terminal_area.width.saturating_sub(1),
+ );
+ assert_eq!(
+ server.app.state.view.layout,
+ crate::app::state::ViewLayout::Desktop
+ );
+ assert_eq!(server.app.state.view.mobile_header_rect, Rect::default());
+ assert_eq!(
+ server.app.state.view.terminal_area,
+ foreground_terminal_area
+ );
+ assert_eq!(
+ server.app.state.workspaces[0].tabs[0].runtimes[&active_pane].current_size(),
+ expected_pane_size
+ );
+ assert_eq!(
+ server.app.state.workspaces[0].tabs[background_tab].runtimes[&background_pane]
+ .current_size(),
+ expected_pane_size
+ );
}
#[tokio::test]
diff --git a/src/server/headless/pane_graphics.rs b/src/server/headless/pane_graphics.rs
index 18174f45..ed28eab0 100644
--- a/src/server/headless/pane_graphics.rs
+++ b/src/server/headless/pane_graphics.rs
@@ -121,6 +121,7 @@ impl HeadlessServer {
frame_pane_graphics_for_client(crate::kitty_graphics::encode_local_pane_graphics(
&self.app.state,
&self.app.terminal_runtimes,
+ self.app.state.view.tab_surface(),
cell_size,
&mut next_graphics_cache,
));
diff --git a/src/server/render_stream.rs b/src/server/render_stream.rs
index facebd2c..8858bb6b 100644
--- a/src/server/render_stream.rs
+++ b/src/server/render_stream.rs
@@ -384,94 +384,14 @@ pub(crate) fn visible_hyperlinks(
app_state: &AppState,
terminal_runtimes: &TerminalRuntimeRegistry,
) -> Vec<((u16, u16), String, String)> {
- let Some(ws_idx) = app_state.active else {
- return Vec::new();
- };
- if app_state.workspaces.get(ws_idx).is_none() {
- return Vec::new();
- }
-
- let mut links = Vec::new();
- for info in &app_state.view.pane_infos {
- if let Some(runtime) =
- app_state.runtime_for_pane_in_workspace(terminal_runtimes, ws_idx, info.id)
- {
- links.extend(runtime.visible_hyperlinks(info.inner_rect));
- }
- }
- links
+ crate::ui::tab_surface_hyperlinks(app_state, terminal_runtimes, app_state.view.tab_surface())
}
pub(crate) fn focused_terminal_cursor(
app_state: &AppState,
terminal_runtimes: &TerminalRuntimeRegistry,
) -> Option<CursorState> {
- if app_state.mode != Mode::Terminal {
- return None;
- }
-
- let ws_idx = app_state.active?;
- let info = app_state
- .view
- .pane_infos
- .iter()
- .find(|info| info.is_focused)?;
- if !app_state.pane_exposes_host_cursor(ws_idx, info.id) {
- return None;
- }
- let rt = app_state.runtime_for_pane_in_workspace(terminal_runtimes, ws_idx, info.id)?;
- if rt.synchronized_output_active() {
- return None;
- }
- let scrolled_back = crate::ui::pane_is_scrolled_back(rt);
- // Determine whether the IME-anchor reveal applies to this focused pane.
- // The master switch must be on, and either no agent filter is configured
- // (apply to any pane) or the focused pane's detected agent matches the
- // allow-list. A configured list with no valid entries reveals nothing.
- let reveal = app_state.reveal_hidden_cursor_for_cjk_ime
- && (!app_state.cjk_ime_agent_filter_configured || {
- let detected = app_state
- .workspaces
- .get(ws_idx)
- .and_then(|ws| ws.terminal_id(info.id))
- .and_then(|tid| app_state.terminals.get(tid))
- .and_then(|t| t.detected_agent);
- detected.is_some_and(|agent| app_state.cjk_ime_agents.contains(&agent))
- });
-
- if let Some(cursor) = rt.cursor_state(info.inner_rect, true) {
- // When the reveal applies, expose the cursor anchor regardless of the
- // pane's `?25l` request so macOS IMEs keep tracking the candidate
- // window when TUIs paint their own cursor. Scrollback suppression
- // still applies.
- let visible = if reveal {
- !scrolled_back
- } else {
- cursor.visible && !scrolled_back
- };
- Some(CursorState {
- x: cursor.x,
- y: cursor.y,
- visible,
- shape: if reveal && visible {
- app_state.cjk_ime_cursor_shape
- } else {
- cursor.shape
- },
- })
- } else if reveal && !scrolled_back {
- // cursor_state() returned None — the viewport has no cursor position
- // (can happen with complex TUIs). Fall back to the pane's top-left so
- // the outer terminal still exposes a cursor anchor for IME tracking.
- Some(CursorState {
- x: info.inner_rect.x,
- y: info.inner_rect.y,
- visible: true,
- shape: app_state.cjk_ime_cursor_shape,
- })
- } else {
- None
- }
+ crate::ui::tab_surface_cursor(app_state, terminal_runtimes, app_state.view.tab_surface())
}
fn focused_terminal_owns_host_cursor(
diff --git a/src/ui.rs b/src/ui.rs
index 85574989..c4789ea4 100644
--- a/src/ui.rs
+++ b/src/ui.rs
@@ -17,6 +17,7 @@ mod scrollbar;
mod settings;
mod sidebar;
mod status;
+mod tab_surface;
mod tabs;
mod text;
mod widgets;
@@ -39,9 +40,7 @@ use self::navigator::render_navigator_overlay;
pub(crate) use self::onboarding::onboarding_welcome_continue_rect;
use self::onboarding::render_onboarding_overlay;
pub(crate) use self::panes::popup_pane_rects;
-use self::panes::{
- compute_pane_infos, render_panes, render_popup_pane, resize_popup_pane, resize_tab_panes,
-};
+use self::panes::{render_empty, render_popup_pane, resize_popup_pane};
pub(crate) use self::release_notes::{
product_announcement_display_lines, release_notes_close_button_rect,
release_notes_display_lines, release_notes_wrapped_line_count, PRODUCT_ANNOUNCEMENT_MODAL_SIZE,
@@ -58,6 +57,9 @@ use self::status::{
copy_feedback_rect, render_config_diagnostic, render_copy_feedback, render_toast_notification,
toast_notification_rect,
};
+pub(crate) use self::tab_surface::{
+ compute_tab_surface, render_tab_surface, resize_tab_surface, TabSurfaceLayout,
+};
use self::tabs::render_tab_bar;
pub(crate) use self::{
dialogs::{
@@ -89,6 +91,7 @@ pub(crate) use self::{
mobile_switcher_workspace_doc_range, MobileSwitcherTarget,
},
panes::{apply_pane_chrome, pane_inner_rect, pane_is_scrolled_back},
+ tab_surface::{tab_surface_cursor, tab_surface_hyperlinks, TabSurfaceView},
tabs::compute_tab_bar_view,
widgets::{centered_popup_rect, modal_stack_areas},
};
@@ -168,7 +171,7 @@ fn resize_background_tab_panes_to_area(
if app.active == Some(ws_idx) && tab_idx == ws.active_tab_index() {
continue;
}
- resize_tab_panes(app, terminal_runtimes, tab, terminal_area, cell_size);
+ resize_tab_surface(app, terminal_runtimes, tab, terminal_area, cell_size);
}
}
}
@@ -185,7 +188,7 @@ fn resize_background_tab_panes_for_desktop(
if app.active == Some(ws_idx) && tab_idx == ws.active_tab_index() {
continue;
}
- resize_tab_panes(app, terminal_runtimes, tab, terminal_area, cell_size);
+ resize_tab_surface(app, terminal_runtimes, tab, terminal_area, cell_size);
}
}
}
@@ -269,19 +272,10 @@ fn compute_view_internal(
.unwrap_or_default();
app.tab_scroll = tab_bar_view.scroll;
- let split_borders = app
- .active
- .and_then(|i| app.workspaces.get(i))
- .map(|ws| {
- if ws.zoomed {
- Vec::new()
- } else {
- ws.layout.splits(terminal_area)
- }
- })
- .unwrap_or_default();
-
- let pane_infos = compute_pane_infos(
+ let TabSurfaceLayout {
+ pane_infos,
+ split_borders,
+ } = compute_tab_surface(
app,
terminal_runtimes,
terminal_area,
@@ -347,19 +341,10 @@ fn compute_mobile_view(
app.mobile_switcher_scroll = app.mobile_switcher_scroll.min(max_scroll);
}
- let split_borders = app
- .active
- .and_then(|i| app.workspaces.get(i))
- .map(|ws| {
- if ws.zoomed {
- Vec::new()
- } else {
- ws.layout.splits(terminal_area)
- }
- })
- .unwrap_or_default();
-
- let pane_infos = compute_pane_infos(
+ let TabSurfaceLayout {
+ pane_infos,
+ split_borders,
+ } = compute_tab_surface(
app,
terminal_runtimes,
terminal_area,
@@ -425,7 +410,15 @@ pub fn render_with_runtime_registry(
if app.view.layout != ViewLayout::Mobile {
render_tab_bar(app, frame, tab_bar_area);
}
- render_panes(app, terminal_runtimes, frame, terminal_area);
+ if app
+ .active
+ .and_then(|ws_idx| app.workspaces.get(ws_idx))
+ .is_some()
+ {
+ render_tab_surface(app, terminal_runtimes, app.view.tab_surface(), frame);
+ } else {
+ render_empty(app, frame, terminal_area);
+ }
// Ambient notifications sit above panes, but below interactive overlays.
render_notifications(app, frame, terminal_area);
diff --git a/src/ui/panes.rs b/src/ui/panes.rs
index ae4cc132..981f3246 100644
--- a/src/ui/panes.rs
+++ b/src/ui/panes.rs
@@ -297,21 +297,20 @@ pub(super) fn render_panes(
app: &AppState,
terminal_runtimes: &TerminalRuntimeRegistry,
frame: &mut Frame,
- area: Rect,
+ pane_infos: &[PaneInfo],
+ split_borders: &[crate::layout::SplitBorder],
) {
let Some(ws_idx) = app.active else {
- render_empty(app, frame, area);
return;
};
let Some(ws) = app.workspaces.get(ws_idx) else {
- render_empty(app, frame, area);
return;
};
let multi_pane = ws.layout.pane_count() > 1;
let terminal_active = app.mode == Mode::Terminal;
- for info in &app.view.pane_infos {
+ for info in pane_infos {
if let Some(rt) = app.runtime_for_pane_in_workspace(terminal_runtimes, ws_idx, info.id) {
let show_cursor = info.is_focused
&& terminal_active
@@ -365,7 +364,7 @@ pub(super) fn render_panes(
}
}
- render_pane_borders(app, ws, frame);
+ render_pane_borders(app, ws, pane_infos, split_borders, frame);
}
pub(crate) fn popup_pane_rects(app: &AppState, area: Rect) -> Option<(Rect, Rect)> {
@@ -437,22 +436,22 @@ struct LineCell {
right: bool,
}
-fn render_pane_borders(app: &AppState, ws: &crate::workspace::Workspace, frame: &mut Frame) {
- if !app.pane_borders
- || app
- .view
- .pane_infos
- .iter()
- .all(|info| info.borders.is_empty())
- {
+fn render_pane_borders(
+ app: &AppState,
+ ws: &crate::workspace::Workspace,
+ pane_infos: &[PaneInfo],
+ split_borders: &[crate::layout::SplitBorder],
+ frame: &mut Frame,
+) {
+ if !app.pane_borders || pane_infos.iter().all(|info| info.borders.is_empty()) {
return;
}
let mut cells = std::collections::HashMap::<(u16, u16), LineCell>::new();
- for info in &app.view.pane_infos {
+ for info in pane_infos {
add_pane_border_cells(&mut cells, info);
}
- add_split_border_cells(app, &mut cells);
+ add_split_border_cells(app.pane_gaps, split_borders, &mut cells);
let buf = frame.buffer_mut();
let area = buf.area;
@@ -464,9 +463,7 @@ fn render_pane_borders(app: &AppState, ws: &crate::workspace::Workspace, frame:
{
continue;
}
- let focused = app
- .view
- .pane_infos
+ let focused = pane_infos
.iter()
.any(|info| info.is_focused && line_touches_pane(x, y, info, app.pane_gaps));
let symbol = line_cell_symbol(line);
@@ -483,18 +480,19 @@ fn render_pane_borders(app: &AppState, ws: &crate::workspace::Workspace, frame:
cell.set_style(Style::default().fg(color));
}
- render_pane_border_titles(app, ws, frame);
+ render_pane_border_titles(app, ws, pane_infos, frame);
}
fn add_split_border_cells(
- app: &AppState,
+ pane_gaps: bool,
+ split_borders: &[crate::layout::SplitBorder],
cells: &mut std::collections::HashMap<(u16, u16), LineCell>,
) {
- if app.pane_gaps {
+ if pane_gaps {
return;
}
- for split in &app.view.split_borders {
+ for split in split_borders {
match split.direction {
ratatui::layout::Direction::Horizontal => {
let x = split.pos;
@@ -607,10 +605,15 @@ fn line_touches_pane(x: u16, y: u16, info: &PaneInfo, pane_gaps: bool) -> bool {
|| (x == shared_right && y == shared_bottom)
}
-fn render_pane_border_titles(app: &AppState, ws: &crate::workspace::Workspace, frame: &mut Frame) {
+fn render_pane_border_titles(
+ app: &AppState,
+ ws: &crate::workspace::Workspace,
+ pane_infos: &[PaneInfo],
+ frame: &mut Frame,
+) {
let buf = frame.buffer_mut();
let area = buf.area;
- for info in &app.view.pane_infos {
+ for info in pane_infos {
if !info.borders.contains(Borders::TOP) || info.rect.width <= 4 {
continue;
}
@@ -911,7 +914,7 @@ fn color_to_rgb(color: Color) -> Option<Rgb> {
}
}
-fn render_empty(app: &AppState, frame: &mut Frame, area: Rect) {
+pub(super) fn render_empty(app: &AppState, frame: &mut Frame, area: Rect) {
let p = &app.palette;
let lines = vec![
Line::from(""),
@@ -961,6 +964,16 @@ mod tests {
use crate::terminal::TerminalState;
use crate::workspace::Workspace;
+ fn render_view_pane_borders(app: &AppState, ws: &Workspace, frame: &mut Frame) {
+ render_pane_borders(
+ app,
+ ws,
+ &app.view.pane_infos,
+ &app.view.split_borders,
+ frame,
+ );
+ }
+
#[test]
fn pane_border_title_trims_and_truncates() {
assert_eq!(
@@ -1015,7 +1028,7 @@ mod tests {
let mut terminal =
ratatui::Terminal::new(ratatui::backend::TestBackend::new(12, 3)).unwrap();
terminal
- .draw(|frame| render_pane_borders(&app, &ws, frame))
+ .draw(|frame| render_view_pane_borders(&app, &ws, frame))
.unwrap();
let buffer = terminal.backend().buffer();
@@ -1182,7 +1195,7 @@ mod tests {
ratatui::Terminal::new(ratatui::backend::TestBackend::new(4, 4)).unwrap();
terminal
- .draw(|frame| render_pane_borders(&app, &ws, frame))
+ .draw(|frame| render_view_pane_borders(&app, &ws, frame))
.unwrap();
let buffer = terminal.backend().buffer();
@@ -1221,7 +1234,7 @@ mod tests {
ratatui::Terminal::new(ratatui::backend::TestBackend::new(4, 3)).unwrap();
terminal
- .draw(|frame| render_pane_borders(&app, &ws, frame))
+ .draw(|frame| render_view_pane_borders(&app, &ws, frame))
.unwrap();
let buffer = terminal.backend().buffer();
diff --git a/src/ui/tab_surface.rs b/src/ui/tab_surface.rs
new file mode 100644
index 00000000..1594f83d
--- /dev/null
+++ b/src/ui/tab_surface.rs
@@ -0,0 +1,327 @@
+use ratatui::{layout::Rect, Frame};
+
+use super::panes::{compute_pane_infos, render_panes, resize_tab_panes};
+use crate::app::state::ViewState;
+use crate::app::{AppState, Mode};
+use crate::layout::{PaneInfo, SplitBorder};
+use crate::protocol::CursorState;
+use crate::terminal::TerminalRuntimeRegistry;
+
+pub(crate) struct TabSurfaceLayout {
+ pub(crate) pane_infos: Vec<PaneInfo>,
+ pub(crate) split_borders: Vec<SplitBorder>,
+}
+
+#[derive(Clone, Copy)]
+pub(crate) struct TabSurfaceView<'a> {
+ pub(crate) pane_infos: &'a [PaneInfo],
+ pub(crate) split_borders: &'a [SplitBorder],
+}
+
+impl ViewState {
+ pub(crate) fn tab_surface(&self) -> TabSurfaceView<'_> {
+ TabSurfaceView {
+ pane_infos: &self.pane_infos,
+ split_borders: &self.split_borders,
+ }
+ }
+}
+
+pub(crate) fn compute_tab_surface(
+ app: &AppState,
+ terminal_runtimes: &TerminalRuntimeRegistry,
+ area: Rect,
+ resize_panes: bool,
+ cell_size: crate::kitty_graphics::HostCellSize,
+) -> TabSurfaceLayout {
+ let split_borders = app
+ .active
+ .and_then(|i| app.workspaces.get(i))
+ .map(|ws| {
+ if ws.zoomed {
+ Vec::new()
+ } else {
+ ws.layout.splits(area)
+ }
+ })
+ .unwrap_or_default();
+ let pane_infos = compute_pane_infos(app, terminal_runtimes, area, resize_panes, cell_size);
+
+ TabSurfaceLayout {
+ pane_infos,
+ split_borders,
+ }
+}
+
+pub(crate) fn resize_tab_surface(
+ app: &AppState,
+ terminal_runtimes: &TerminalRuntimeRegistry,
+ tab: &crate::workspace::Tab,
+ area: Rect,
+ cell_size: crate::kitty_graphics::HostCellSize,
+) {
+ resize_tab_panes(app, terminal_runtimes, tab, area, cell_size);
+}
+
+pub(crate) fn render_tab_surface(
+ app: &AppState,
+ terminal_runtimes: &TerminalRuntimeRegistry,
+ surface: TabSurfaceView<'_>,
+ frame: &mut Frame,
+) {
+ render_panes(
+ app,
+ terminal_runtimes,
+ frame,
+ surface.pane_infos,
+ surface.split_borders,
+ );
+}
+
+pub(crate) fn tab_surface_hyperlinks(
+ app: &AppState,
+ terminal_runtimes: &TerminalRuntimeRegistry,
+ surface: TabSurfaceView<'_>,
+) -> Vec<((u16, u16), String, String)> {
+ let Some(ws_idx) = app.active else {
+ return Vec::new();
+ };
+ if app.workspaces.get(ws_idx).is_none() {
+ return Vec::new();
+ }
+
+ let mut links = Vec::new();
+ for info in surface.pane_infos {
+ if let Some(runtime) = app.runtime_for_pane_in_workspace(terminal_runtimes, ws_idx, info.id)
+ {
+ links.extend(runtime.visible_hyperlinks(info.inner_rect));
+ }
+ }
+ links
+}
+
+pub(crate) fn tab_surface_cursor(
+ app: &AppState,
+ terminal_runtimes: &TerminalRuntimeRegistry,
+ surface: TabSurfaceView<'_>,
+) -> Option<CursorState> {
+ if app.mode != Mode::Terminal {
+ return None;
+ }
+
+ let ws_idx = app.active?;
+ let info = surface.pane_infos.iter().find(|info| info.is_focused)?;
+ if !app.pane_exposes_host_cursor(ws_idx, info.id) {
+ return None;
+ }
+ let runtime = app.runtime_for_pane_in_workspace(terminal_runtimes, ws_idx, info.id)?;
+ if runtime.synchronized_output_active() {
+ return None;
+ }
+ let scrolled_back = super::panes::pane_is_scrolled_back(runtime);
+ let reveal = app.reveal_hidden_cursor_for_cjk_ime
+ && (!app.cjk_ime_agent_filter_configured || {
+ let detected = app
+ .workspaces
+ .get(ws_idx)
+ .and_then(|ws| ws.terminal_id(info.id))
+ .and_then(|terminal_id| app.terminals.get(terminal_id))
+ .and_then(|terminal| terminal.detected_agent);
+ detected.is_some_and(|agent| app.cjk_ime_agents.contains(&agent))
+ });
+
+ if let Some(cursor) = runtime.cursor_state(info.inner_rect, true) {
+ let visible = if reveal {
+ !scrolled_back
+ } else {
+ cursor.visible && !scrolled_back
+ };
+ Some(CursorState {
+ x: cursor.x,
+ y: cursor.y,
+ visible,
+ shape: if reveal && visible {
+ app.cjk_ime_cursor_shape
+ } else {
+ cursor.shape
+ },
+ })
+ } else if reveal && !scrolled_back {
+ Some(CursorState {
+ x: info.inner_rect.x,
+ y: info.inner_rect.y,
+ visible: true,
+ shape: app.cjk_ime_cursor_shape,
+ })
+ } else {
+ None
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::workspace::Workspace;
+ use ratatui::backend::TestBackend;
+ use ratatui::layout::Direction;
+ use ratatui::Terminal;
+
+ #[tokio::test]
+ async fn explicit_surface_layout_drives_render_cursor_and_hyperlinks() {
+ let uri = "https://example.com/surface";
+ let mut workspace = Workspace::test_new("shell-workspace");
+ let left = workspace.tabs[0].root_pane;
+ let right = workspace.test_split(Direction::Horizontal);
+ workspace.insert_test_runtime(
+ left,
+ crate::terminal::TerminalRuntime::test_with_screen_bytes(
+ 20,
+ 8,
+ format!("\x1b]8;;{uri}\x1b\\LEFT\x1b]8;;\x1b\\").as_bytes(),
+ ),
+ );
+ workspace.insert_test_runtime(
+ right,
+ crate::terminal::TerminalRuntime::test_with_screen_bytes(20, 8, b"RIGHT"),
+ );
+
+ let mut app = AppState::test_new();
+ app.workspaces = vec![workspace];
+ app.active = Some(0);
+ app.selected = 0;
+ app.mode = Mode::Terminal;
+
+ let full_area = Rect::new(0, 0, 106, 20);
+ crate::ui::compute_view(&mut app, full_area);
+ let area = app.view.terminal_area;
+ assert_eq!(area, Rect::new(26, 1, 80, 19));
+ let surface = compute_tab_surface(
+ &app,
+ &TerminalRuntimeRegistry::new(),
+ area,
+ false,
+ crate::kitty_graphics::HostCellSize::default(),
+ );
+ assert_eq!(surface.pane_infos.len(), 2);
+ assert!(!surface.split_borders.is_empty());
+
+ app.view.terminal_area = Rect::new(9, 8, 7, 6);
+ app.view.pane_infos.clear();
+ app.view.split_borders.clear();
+
+ let surface_view = TabSurfaceView {
+ pane_infos: &surface.pane_infos,
+ split_borders: &surface.split_borders,
+ };
+ let mut terminal =
+ Terminal::new(TestBackend::new(full_area.width, full_area.height)).unwrap();
+ terminal
+ .draw(|frame| {
+ render_tab_surface(&app, &TerminalRuntimeRegistry::new(), surface_view, frame)
+ })
+ .unwrap();
+
+ let rendered = terminal
+ .backend()
+ .buffer()
+ .content()
+ .iter()
+ .map(|cell| cell.symbol())
+ .collect::<String>();
+ assert!(rendered.contains("LEFT"), "surface: {rendered:?}");
+ assert!(rendered.contains("RIGHT"), "surface: {rendered:?}");
+ assert!(!rendered.contains("shell-workspace"));
+
+ let links = tab_surface_hyperlinks(&app, &TerminalRuntimeRegistry::new(), surface_view);
+ assert!(links
+ .iter()
+ .any(|(_, symbol, link)| { symbol == "L" && link == uri }));
+ assert!(tab_surface_cursor(&app, &TerminalRuntimeRegistry::new(), surface_view,).is_some());
+ }
+
+ fn full_app_frame(app: &mut AppState, area: Rect) -> crate::protocol::FrameData {
+ let (buffer, cursor) = crate::server::render_stream::render_virtual(app, area, true);
+ let hyperlinks =
+ crate::server::render_stream::visible_hyperlinks(app, &TerminalRuntimeRegistry::new());
+ crate::protocol::FrameData::from_ratatui_buffer_with_hyperlinks(
+ &buffer,
+ cursor,
+ &hyperlinks,
+ )
+ }
+
+ fn frame_digest(frame: &crate::protocol::FrameData) -> String {
+ use sha2::{Digest, Sha256};
+
+ let encoded = bincode::serde::encode_to_vec(frame, bincode::config::standard()).unwrap();
+ format!("{:x}", Sha256::digest(encoded))
+ }
+
+ fn full_app_characterization_state(uri: &str) -> AppState {
+ let mut workspace = Workspace::test_new("characterization");
+ workspace.identity_cwd = std::path::PathBuf::from("characterization");
+ workspace.cached_git_branch = None;
+ workspace.cached_git_ahead_behind = None;
+ workspace.cached_git_space = None;
+ workspace.test_add_tab(Some("logs"));
+ workspace.switch_tab(0);
+ let left = workspace.tabs[0].root_pane;
+ let right = workspace.test_split(Direction::Horizontal);
+ workspace.insert_test_runtime(
+ left,
+ crate::terminal::TerminalRuntime::test_with_screen_bytes(
+ 40,
+ 10,
+ format!("\x1b]8;;{uri}\x1b\\LINK\x1b]8;;\x1b\\").as_bytes(),
+ ),
+ );
+ workspace.insert_test_runtime(
+ right,
+ crate::terminal::TerminalRuntime::test_with_screen_bytes(40, 10, b"RIGHT\r\nPANE"),
+ );
+
+ let mut app = AppState::test_new();
+ app.workspaces = vec![workspace];
+ app.active = Some(0);
+ app.selected = 0;
+ app.mode = Mode::Terminal;
+ app
+ }
+
+ #[tokio::test]
+ async fn desktop_full_app_semantic_frame_is_characterized() {
+ let uri = "https://example.com/full-app";
+ let mut app = full_app_characterization_state(uri);
+ let frame = full_app_frame(&mut app, Rect::new(0, 0, 106, 20));
+
+ assert_eq!((frame.width, frame.height), (106, 20));
+ assert_eq!(app.view.sidebar_rect, Rect::new(0, 0, 26, 20));
+ assert_eq!(app.view.tab_bar_rect, Rect::new(26, 0, 80, 1));
+ assert_eq!(app.view.terminal_area, Rect::new(26, 1, 80, 19));
+ assert_eq!(app.view.pane_infos.len(), 2);
+ assert!(!app.view.split_borders.is_empty());
+ assert!(frame.cursor.is_some());
+ assert_eq!(frame.hyperlinks, vec![uri.to_owned()]);
+ assert_eq!(
+ frame_digest(&frame),
+ "ce383feeaac30922502b7c4f8af53b5ca30e816ec4503ca6d015738b584da487"
+ );
+ }
+
+ #[tokio::test]
+ async fn mobile_full_app_semantic_frame_is_characterized() {
+ let mut app = full_app_characterization_state("https://example.com/mobile");
+ app.mode = Mode::Navigate;
+ let frame = full_app_frame(&mut app, Rect::new(0, 0, 44, 20));
+
+ assert_eq!((frame.width, frame.height), (44, 20));
+ assert_eq!(app.view.layout, crate::app::state::ViewLayout::Mobile);
+ assert_eq!(app.view.mobile_header_rect, Rect::new(0, 0, 44, 2));
+ assert_eq!(app.view.terminal_area, Rect::new(0, 2, 44, 18));
+ assert_eq!(frame.cursor, None);
+ assert_eq!(
+ frame_digest(&frame),
+ "295608a66067f1e1f066c0adb3cf427e8a2d68bba8f68949fb72d464dcd8baab"
+ );
+ }
+}