diff options
| author | Ogulcan Celik <ogulcancelik@gmail.com> | 2026-07-15 15:48:38 +0300 |
|---|---|---|
| committer | Ogulcan Celik <ogulcancelik@gmail.com> | 2026-07-16 22:47:10 +0300 |
| commit | 8dcb75a5c08b4dd1f225ea531a27824a9f41ae4a (patch) | |
| tree | 48b4b74e857c93ffc03ff1b790b2f9e76e0113ac /src | |
| parent | f43a6d4a5591a0286e01de737be7b3f42416898b (diff) | |
refactor: extract active tab surface rendering
Diffstat (limited to 'src')
| -rw-r--r-- | src/kitty_graphics.rs | 22 | ||||
| -rw-r--r-- | src/server/headless.rs | 64 | ||||
| -rw-r--r-- | src/server/headless/pane_graphics.rs | 1 | ||||
| -rw-r--r-- | src/server/render_stream.rs | 84 | ||||
| -rw-r--r-- | src/ui.rs | 57 | ||||
| -rw-r--r-- | src/ui/panes.rs | 69 | ||||
| -rw-r--r-- | src/ui/tab_surface.rs | 327 |
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( @@ -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" + ); + } +} |
