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/ui | |
| parent | f43a6d4a5591a0286e01de737be7b3f42416898b (diff) | |
refactor: extract active tab surface rendering
Diffstat (limited to 'src/ui')
| -rw-r--r-- | src/ui/panes.rs | 69 | ||||
| -rw-r--r-- | src/ui/tab_surface.rs | 327 |
2 files changed, 368 insertions, 28 deletions
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" + ); + } +} |
