diff options
Diffstat (limited to 'src')
| -rw-r--r-- | src/protocol/wire.rs | 24 | ||||
| -rw-r--r-- | src/server/headless.rs | 280 | ||||
| -rw-r--r-- | src/server/render_stream.rs | 39 | ||||
| -rw-r--r-- | src/ui.rs | 27 |
4 files changed, 335 insertions, 35 deletions
diff --git a/src/protocol/wire.rs b/src/protocol/wire.rs index d724a5ae..0d4b14bb 100644 --- a/src/protocol/wire.rs +++ b/src/protocol/wire.rs @@ -444,6 +444,19 @@ pub struct CellData { pub hyperlink: Option<u32>, } +impl CellData { + pub(crate) fn from_ratatui_cell(cell: &ratatui::buffer::Cell) -> Self { + Self { + symbol: cell.symbol().to_owned(), + fg: color_to_u32(cell.fg), + bg: color_to_u32(cell.bg), + modifier: modifier_to_u16(cell.modifier), + skip: cell.skip, + hyperlink: None, + } + } +} + /// Cursor shape encoded as a DECSCUSR parameter. /// /// 0 = terminal default, 1 = blinking block, 2 = steady block, @@ -527,14 +540,9 @@ impl FrameData { index })) }); - cells.push(CellData { - symbol: cell.symbol().to_owned(), - fg: color_to_u32(cell.fg), - bg: color_to_u32(cell.bg), - modifier: modifier_to_u16(cell.modifier), - skip: cell.skip, - hyperlink, - }); + let mut cell = CellData::from_ratatui_cell(cell); + cell.hyperlink = hyperlink; + cells.push(cell); } } diff --git a/src/server/headless.rs b/src/server/headless.rs index 99702512..94721c38 100644 --- a/src/server/headless.rs +++ b/src/server/headless.rs @@ -145,6 +145,14 @@ impl RenderImpact { } } +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +enum ScheduledRenderImpact { + #[default] + None, + Animation, + Full, +} + fn record_render_impact(source: &'static str, impact: RenderImpact) { let event = match (source, impact) { ("api_requests", RenderImpact::Graphics) => "graphics_render_cause.api_requests", @@ -493,6 +501,7 @@ impl HeadlessServer { let mut needs_render = true; let mut needs_full_render = true; let mut needs_graphics_render = false; + let mut needs_animation_render = false; loop { crate::render_prof::event("loop.tick"); @@ -589,11 +598,19 @@ impl HeadlessServer { // 6. Handle scheduled tasks. let now = Instant::now(); - if self.handle_scheduled_tasks_headless(now, needs_render) { - needs_render = true; - needs_full_render = true; - needs_graphics_render = false; - crate::render_prof::event("full_render_cause.scheduled_tasks"); + match self.handle_scheduled_tasks_headless(now, needs_render) { + ScheduledRenderImpact::None => {} + ScheduledRenderImpact::Animation => { + needs_render = true; + needs_animation_render = true; + crate::render_prof::event("animation_render_cause.scheduled_tasks"); + } + ScheduledRenderImpact::Full => { + needs_render = true; + needs_full_render = true; + needs_graphics_render = false; + crate::render_prof::event("full_render_cause.scheduled_tasks"); + } } if self.handle_deferred_requests_headless() { @@ -630,7 +647,16 @@ impl HeadlessServer { crate::render_prof::event("retained_gate.not_pty_dirty"); } let mut deferred_graphics = false; - let rendered_retained = if needs_graphics_render && !needs_full_render && !pty_dirty + let rendered_retained = if needs_animation_render + && !needs_full_render + && !needs_graphics_render + && !pty_dirty + { + self.render_retained_animation_update_and_stream() + } else if needs_graphics_render + && !needs_full_render + && !needs_animation_render + && !pty_dirty { match self.render_retained_graphics_update_and_stream() { RetainedGraphicsOutcome::Sent => true, @@ -641,7 +667,10 @@ impl HeadlessServer { RetainedGraphicsOutcome::Fallback => false, } } else { - pty_dirty && !needs_full_render && self.render_retained_pty_update_and_stream() + pty_dirty + && !needs_full_render + && !needs_animation_render + && self.render_retained_pty_update_and_stream() }; if deferred_graphics { needs_render = false; @@ -655,6 +684,7 @@ impl HeadlessServer { needs_render = false; needs_full_render = false; needs_graphics_render = false; + needs_animation_render = false; continue; } @@ -3396,6 +3426,69 @@ impl HeadlessServer { } } + fn render_retained_animation_update_and_stream(&mut self) -> bool { + crate::render_prof::event("retained_animation.attempt"); + if !self.retained_pty_update_allowed_by_app_state() + || self.app.state.config_diagnostic.is_some() + { + crate::render_prof::event("retained_animation_fallback.unsafe_app_state"); + return false; + } + + let mut targets = Vec::new(); + for (client_id, (cols, rows), _, _, mode) in + render_targets(&self.clients, self.foreground_client_id) + { + if !matches!(mode, ClientConnectionMode::App) { + continue; + } + let Some(client) = self.clients.get(&client_id) else { + return false; + }; + if client.deferred_render() != DeferredRender::None + || client.graphics_surface_reset_pending + { + crate::render_prof::event("retained_animation_fallback.client_state"); + return false; + } + let Some(previous) = client.render_state.last_frame().cloned() else { + crate::render_prof::event("retained_animation_fallback.no_last_frame"); + return false; + }; + if previous.width != cols || previous.height != rows { + crate::render_prof::event("retained_animation_fallback.frame_size_mismatch"); + return false; + } + targets.push((client_id, previous)); + } + + let mut frames = Vec::with_capacity(targets.len()); + for (client_id, previous) in targets { + let Some(frame) = crate::server::render_stream::render_working_animation_from_frame( + &mut self.app.state, + &self.app.terminal_runtimes, + previous, + ) else { + crate::render_prof::event("retained_animation_fallback.render_failed"); + return false; + }; + frames.push((client_id, frame)); + } + + let mut broken_clients = Vec::new(); + let mut sent_all = true; + for (client_id, frame) in frames { + sent_all &= self.send_retained_frame_to_client(client_id, frame, &mut broken_clients); + } + for client_id in broken_clients { + self.remove_client_and_resize_if_needed(client_id); + } + if sent_all { + crate::render_prof::event("retained_animation.success"); + } + sent_all + } + fn render_retained_pty_update_and_stream(&mut self) -> bool { crate::render_prof::event("retained.attempt"); let retained_started = crate::render_prof::timer(); @@ -3892,8 +3985,13 @@ impl HeadlessServer { /// /// Similar to `App::handle_scheduled_tasks` but without resize polling /// (the server doesn't have a terminal to resize). - fn handle_scheduled_tasks_headless(&mut self, now: Instant, geometry_dirty: bool) -> bool { + fn handle_scheduled_tasks_headless( + &mut self, + now: Instant, + geometry_dirty: bool, + ) -> ScheduledRenderImpact { let mut changed = false; + let mut animation_changed = false; self.app.sync_headless_animation_timer(now); @@ -3960,7 +4058,7 @@ impl HeadlessServer { .spinner_tick .wrapping_add(app::HEADLESS_ANIMATION_TICK_STEP); self.app.next_animation_tick = Some(now + app::HEADLESS_ANIMATION_INTERVAL); - changed = true; + animation_changed = true; } if self @@ -4020,7 +4118,13 @@ impl HeadlessServer { .start_pending_agent_resumes(self.app.pending_agent_resume_due(now)); } self.app.sync_headless_animation_timer(now); - changed + if changed { + ScheduledRenderImpact::Full + } else if animation_changed { + ScheduledRenderImpact::Animation + } else { + ScheduledRenderImpact::None + } } /// Initiates graceful shutdown. @@ -4825,6 +4929,22 @@ mod tests { (server, client_rx, pane_id) } + fn set_first_test_pane_working(server: &mut HeadlessServer) { + server.app.state.ensure_test_terminals(); + let pane_id = server.app.state.workspaces[0].tabs[0].root_pane; + let terminal_id = server.app.state.workspaces[0].tabs[0].panes[&pane_id] + .attached_terminal_id + .clone(); + let terminal = server + .app + .state + .terminals + .get_mut(&terminal_id) + .expect("test terminal"); + terminal.detected_agent = Some(crate::detect::Agent::Pi); + terminal.state = crate::detect::AgentState::Working; + } + fn assert_frame_data_eq(actual: &FrameData, expected: &FrameData) { assert_eq!( (actual.width, actual.height), @@ -4851,6 +4971,88 @@ mod tests { } } + #[tokio::test] + async fn retained_animation_matches_full_render_for_mixed_client_sizes() { + fn test_server_with_mobile_client() -> ( + HeadlessServer, + std::sync::mpsc::Receiver<Vec<u8>>, + std::sync::mpsc::Receiver<Vec<u8>>, + ) { + let (mut server, desktop_rx, _) = retained_test_server( + b"\x1b]8;;https://example.com\x1b\\\x1b[4:3mlinked\x1b[0m\x1b]8;;\x1b\\", + ); + set_first_test_pane_working(&mut server); + let (mobile_tx, _mobile_control_rx, mobile_rx) = test_client_writer(); + server.clients.insert( + 2, + ClientConnection::new( + (44, 20), + crate::kitty_graphics::HostCellSize::default(), + crate::terminal_theme::TerminalTheme::default(), + None, + 2, + RenderEncoding::SemanticFrame, + Some(mobile_tx), + ), + ); + (server, desktop_rx, mobile_rx) + } + + let (mut retained_server, retained_desktop_rx, retained_mobile_rx) = + test_server_with_mobile_client(); + let (mut full_server, full_desktop_rx, full_mobile_rx) = test_server_with_mobile_client(); + + retained_server.render_and_stream(); + let _ = retained_desktop_rx + .recv_timeout(Duration::from_millis(100)) + .expect("retained desktop baseline"); + let _ = retained_mobile_rx + .recv_timeout(Duration::from_millis(100)) + .expect("retained mobile baseline"); + full_server.render_and_stream(); + let _ = full_desktop_rx + .recv_timeout(Duration::from_millis(100)) + .expect("full desktop baseline"); + let _ = full_mobile_rx + .recv_timeout(Duration::from_millis(100)) + .expect("full mobile baseline"); + + retained_server.app.state.spinner_tick = app::HEADLESS_ANIMATION_TICK_STEP; + full_server.app.state.spinner_tick = app::HEADLESS_ANIMATION_TICK_STEP; + + assert!(retained_server.render_retained_animation_update_and_stream()); + full_server.render_and_stream(); + + let retained_desktop = read_server_frame( + retained_desktop_rx + .recv_timeout(Duration::from_millis(100)) + .expect("retained desktop animation"), + ); + let retained_mobile = read_server_frame( + retained_mobile_rx + .recv_timeout(Duration::from_millis(100)) + .expect("retained mobile animation"), + ); + let full_desktop = read_server_frame( + full_desktop_rx + .recv_timeout(Duration::from_millis(100)) + .expect("full desktop animation"), + ); + let full_mobile = read_server_frame( + full_mobile_rx + .recv_timeout(Duration::from_millis(100)) + .expect("full mobile animation"), + ); + + assert_frame_data_eq(&retained_desktop, &full_desktop); + assert_frame_data_eq(&retained_mobile, &full_mobile); + assert_eq!( + retained_server.app.state.view.layout, + crate::app::state::ViewLayout::Desktop + ); + assert!(!retained_desktop.hyperlinks.is_empty()); + } + #[test] fn foreground_client_applies_client_keybindings() { let mut server = test_headless_server(); @@ -5938,7 +6140,10 @@ next_tab = "" Some("short lived") ); - assert!(server.handle_scheduled_tasks_headless(deadline + Duration::from_millis(1), false)); + assert_eq!( + server.handle_scheduled_tasks_headless(deadline + Duration::from_millis(1), false), + ScheduledRenderImpact::Full + ); assert_eq!(server.app.agent_metadata_deadline, None); assert_eq!( @@ -5969,12 +6174,38 @@ next_tab = "" } #[test] + fn headless_scheduled_animation_is_distinct_but_other_changes_win() { + let mut server = test_headless_server(); + server.app.state.workspaces = vec![crate::workspace::Workspace::test_new("working")]; + set_first_test_pane_working(&mut server); + let now = Instant::now(); + server.app.next_animation_tick = Some(now); + + assert_eq!( + server.handle_scheduled_tasks_headless(now, false), + ScheduledRenderImpact::Animation + ); + + server.app.next_animation_tick = Some(now); + server.app.state.config_diagnostic = Some("expired".into()); + server.app.config_diagnostic_deadline = Some(now); + assert_eq!( + server.handle_scheduled_tasks_headless(now, false), + ScheduledRenderImpact::Full + ); + assert!(server.app.state.config_diagnostic.is_none()); + } + + #[test] fn headless_scheduled_tasks_clears_disabled_agent_manifest_update_deadline() { let mut server = test_headless_server(); let now = Instant::now(); server.app.next_agent_manifest_update_check = Some(now - Duration::from_millis(1)); - assert!(!server.handle_scheduled_tasks_headless(now, false)); + assert_eq!( + server.handle_scheduled_tasks_headless(now, false), + ScheduledRenderImpact::None + ); assert_eq!(server.app.next_agent_manifest_update_check, None); } @@ -6030,7 +6261,10 @@ next_tab = "" }); server.app.pending_agent_resume_deadline = Some(Instant::now() - Duration::from_millis(1)); - assert!(!server.handle_scheduled_tasks_headless(Instant::now(), true)); + assert_eq!( + server.handle_scheduled_tasks_headless(Instant::now(), true), + ScheduledRenderImpact::None + ); assert!(server.app.terminal_runtimes.get(&terminal_id).is_none()); assert!(server .app @@ -6084,7 +6318,10 @@ next_tab = "" }); server.app.pending_agent_resume_deadline = Some(Instant::now() - Duration::from_millis(1)); - assert!(!server.handle_scheduled_tasks_headless(Instant::now(), false)); + assert_eq!( + server.handle_scheduled_tasks_headless(Instant::now(), false), + ScheduledRenderImpact::None + ); assert!(server.app.terminal_runtimes.get(&terminal_id).is_none()); assert!(server .app @@ -9311,7 +9548,10 @@ next_tab = "" } ); let deadline = server.app.toast_deadline.expect("api toast deadline"); - assert!(server.handle_scheduled_tasks_headless(deadline, false)); + assert_eq!( + server.handle_scheduled_tasks_headless(deadline, false), + ScheduledRenderImpact::Full + ); assert!(server.app.state.toast.is_none()); assert!(server.app.toast_deadline.is_none()); } @@ -9438,7 +9678,10 @@ next_tab = "" .state .next_pending_agent_notification_deadline() .expect("pending notification deadline"); - assert!(server.handle_scheduled_tasks_headless(deadline, false)); + assert_eq!( + server.handle_scheduled_tasks_headless(deadline, false), + ScheduledRenderImpact::Full + ); let first = read_server_message( client_control_rx @@ -9526,7 +9769,10 @@ next_tab = "" .state .next_pending_agent_notification_deadline() .expect("pending notification deadline"); - assert!(server.handle_scheduled_tasks_headless(deadline, false)); + assert_eq!( + server.handle_scheduled_tasks_headless(deadline, false), + ScheduledRenderImpact::Full + ); let first = read_server_message( client_control_rx diff --git a/src/server/render_stream.rs b/src/server/render_stream.rs index f1f6c428..41a5781d 100644 --- a/src/server/render_stream.rs +++ b/src/server/render_stream.rs @@ -345,6 +345,45 @@ pub(crate) fn render_virtual_with_runtime_registry( (buffer, cursor) } +pub(crate) fn render_working_animation_from_frame( + app_state: &mut AppState, + terminal_runtimes: &TerminalRuntimeRegistry, + mut frame: FrameData, +) -> Option<FrameData> { + let area = Rect::new(0, 0, frame.width, frame.height); + crate::ui::compute_view_without_resizing_panes(app_state, terminal_runtimes, area); + + let backend = CursorTrackingBackend::new(area.width, area.height); + let mut terminal = ratatui::Terminal::new(backend).ok()?; + terminal + .draw(|frame| { + crate::ui::render_working_animation(app_state, terminal_runtimes, frame); + }) + .ok()?; + + let updated_area = if app_state.view.layout == crate::app::state::ViewLayout::Mobile { + app_state.view.mobile_header_rect + } else { + app_state.view.sidebar_rect + }; + if updated_area.x.saturating_add(updated_area.width) > frame.width + || updated_area.y.saturating_add(updated_area.height) > frame.height + { + return None; + } + + let rendered = terminal.backend().buffer(); + frame.graphics.clear(); + for y in updated_area.y..updated_area.y.saturating_add(updated_area.height) { + for x in updated_area.x..updated_area.x.saturating_add(updated_area.width) { + let index = usize::from(y) * usize::from(frame.width) + usize::from(x); + let cell = rendered.cell((x, y))?; + frame.cells[index] = crate::protocol::CellData::from_ratatui_cell(cell); + } + } + Some(frame) +} + fn popup_terminal_cursor( app_state: &AppState, terminal_runtimes: &TerminalRuntimeRegistry, @@ -394,19 +394,10 @@ pub fn render_with_runtime_registry( terminal_runtimes: &TerminalRuntimeRegistry, frame: &mut Frame, ) { - let sidebar_area = app.view.sidebar_rect; let tab_bar_area = app.view.tab_bar_rect; let terminal_area = app.view.terminal_area; - if app.view.layout == ViewLayout::Mobile { - render_mobile_header(app, terminal_runtimes, frame, app.view.mobile_header_rect); - } else if sidebar_area.width > 0 { - if app.sidebar_collapsed { - render_sidebar_collapsed(app, frame, sidebar_area); - } else { - render_sidebar(app, terminal_runtimes, frame, sidebar_area); - } - } + render_working_animation(app, terminal_runtimes, frame); if app.view.layout != ViewLayout::Mobile { render_tab_bar(app, frame, tab_bar_area); } @@ -457,6 +448,22 @@ pub fn render_with_runtime_registry( } } +pub(crate) fn render_working_animation( + app: &AppState, + terminal_runtimes: &TerminalRuntimeRegistry, + frame: &mut Frame, +) { + if app.view.layout == ViewLayout::Mobile { + render_mobile_header(app, terminal_runtimes, frame, app.view.mobile_header_rect); + } else if app.view.sidebar_rect.width > 0 { + if app.sidebar_collapsed { + render_sidebar_collapsed(app, frame, app.view.sidebar_rect); + } else { + render_sidebar(app, terminal_runtimes, frame, app.view.sidebar_rect); + } + } +} + fn render_notifications(app: &AppState, frame: &mut Frame, terminal_area: Rect) { let has_config_diagnostic = app.config_diagnostic.is_some(); if let Some(message) = &app.config_diagnostic { |
