diff options
| author | Can Celik <ogulcancelik@gmail.com> | 2026-09-01 15:48:22 +0300 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2026-09-01 15:48:22 +0300 |
| commit | 207be3c771d281baae6e5fa0fb74be9a056e97a2 (patch) | |
| tree | d7b037ecdf629224a048c841d11659efdc23507d /src/server/clients.rs | |
| parent | 8a6d697308162874a236c84aac0b0f8c7ac01f83 (diff) | |
refactor: render the shell in the client (#3487)
* feat: add opt-in client-rendered shell
* feat: add client-owned shell mouse controls
* feat: complete client-owned shell chrome
* feat: add client-owned shell settings and notifications
* feat: run client shell custom commands
* feat: render popup terminals in client shell
* feat: add client-owned scrollback and copy mode
* fix: preserve client worktree trust defaults
* feat: render startup diagnostics in client shell
* feat: render onboarding in client shell
* feat: render product announcements in client shell
* feat: render release notes in client shell
* feat: render mobile switcher in client shell
* feat: render kitty graphics in client shell
* fix: preserve client shell lifecycle coherence
* refactor: route client shell commands through endpoint connections
* refactor: make the client-rendered shell the default
* refactor: remove monolithic launch mode
* refactor: complete client-rendered shell cutover
* perf: retain client pane surface updates
* perf: scale retained pane surface updates
* perf: remove remote pane input latency
* test: make client shell checks platform-independent
* fix: prevent client timer starvation
* test: fix client shell platform gates
* test: accept retained surfaces in cross-area checks
* test: make client mode assertions platform-neutral
* fix: badge only outdated integrations
Diffstat (limited to 'src/server/clients.rs')
| -rw-r--r-- | src/server/clients.rs | 423 |
1 files changed, 304 insertions, 119 deletions
diff --git a/src/server/clients.rs b/src/server/clients.rs index 0d3d9ae0..d380e2c3 100644 --- a/src/server/clients.rs +++ b/src/server/clients.rs @@ -1,13 +1,17 @@ use std::collections::HashMap; use std::path::PathBuf; -use crate::protocol::RenderEncoding; +use crate::protocol::{ + ClientKeyCode, ClientKeyKind, ClientMouseButton, ClientMouseKind, ClientPaneInputEvent, + RenderEncoding, +}; use crate::server::client_transport::ClientWriter; use crate::server::render_stream::ClientRenderState; #[derive(Debug, Clone, PartialEq, Eq)] pub(crate) enum ClientConnectionMode { - App, + ClientShell, + TerminalPending, TerminalAttach { terminal_id: String }, TerminalObserve { terminal_id: String }, } @@ -20,6 +24,35 @@ pub(crate) type RenderTarget = ( ClientConnectionMode, ); +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) enum ClientShellInputTarget { + Pane(String), + Popup(String), +} + +#[derive(Clone, Debug, PartialEq, Eq, Hash)] +enum ClientShellPressId { + PhysicalKey(u32), + SemanticKey(ClientKeyCode), + Mouse(ClientMouseButton), +} + +fn client_shell_key_press_id( + code: &ClientKeyCode, + physical_key_id: Option<u32>, +) -> ClientShellPressId { + physical_key_id.map_or_else( + || ClientShellPressId::SemanticKey(code.clone()), + ClientShellPressId::PhysicalKey, + ) +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct ClientShellHeldInput { + pub(crate) target: ClientShellInputTarget, + pub(crate) release: ClientPaneInputEvent, +} + #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] pub(crate) enum DeferredRender { #[default] @@ -29,48 +62,54 @@ pub(crate) enum DeferredRender { /// A connected client tracked by the server. pub(crate) struct ClientConnection { - /// Whether this connection is the full app client or a direct terminal attach. + /// Whether this connection owns the Herdr shell or one direct terminal stream. pub(crate) mode: ClientConnectionMode, - /// True after the handshake for clients that will switch into direct terminal attach mode. - pub(crate) pending_terminal_attach: bool, - /// Client-local app keybindings. None means use the server's keybindings. - pub(crate) keybindings: Option<Box<crate::config::LiveKeybindConfig>>, /// The client's terminal size after clamping. pub(crate) terminal_size: (u16, u16), /// Pixel size of one client terminal cell. pub(crate) cell_size: crate::kitty_graphics::HostCellSize, - /// Last known host terminal default colors for this client. - pub(crate) host_terminal_theme: crate::terminal_theme::TerminalTheme, - /// Last known host terminal appearance for this client. - pub(crate) host_terminal_appearance: Option<crate::terminal_theme::HostAppearance>, - /// True when appearance came from an explicit host color-scheme report. - pub(crate) host_terminal_appearance_explicit: bool, - /// Last reported focus state for this client's outer terminal. - pub(crate) outer_terminal_focus: Option<bool>, - /// Stateful parser for app-client input split across transport reads. - pub(crate) raw_input: crate::raw_input::RawInputFramer, /// Monotonic activity stamp used to choose the fallback foreground client. pub(crate) last_activity: u64, /// Render baseline for the negotiated client encoding. pub(crate) render_state: ClientRenderState, - /// Client-local host Kitty graphics cache. - pub(crate) graphics_cache: crate::kitty_graphics::HostGraphicsCache, + /// Image assets already included in the selected ClientShell scene. + pub(crate) shell_graphics_delivery: crate::kitty_graphics::surface::DeliveryCache, /// Passive eligibility for audited local Kitty regular-file graphics. pub(crate) direct_graphics: bool, /// Whether this frontend preserves exact SGR pixel reports. pub(crate) pixel_mouse: bool, - /// Whether the next graphics frame must clear and rebuild host-side Kitty state. - pub(crate) graphics_surface_reset_pending: bool, + /// Last host terminal default colors reported by this client. + pub(crate) host_terminal_theme: crate::terminal_theme::TerminalTheme, + /// Last host light/dark appearance reported by this client. + pub(crate) host_terminal_appearance: Option<crate::terminal_theme::HostAppearance>, + /// Whether appearance came from an explicit host color-scheme report. + pub(crate) host_terminal_appearance_explicit: bool, + /// Last reported focus state for this client's outer terminal. + pub(crate) outer_terminal_focus: Option<bool>, + /// Last focused-pane report-all demand sent to a client-owned shell. + pub(crate) host_keyboard_report_all_active: Option<bool>, /// Whether an ordinary render was skipped because the render channel was full. pub(crate) render_pending: bool, + /// Whether this shell wants host mouse capture without pane demand. + pub(crate) shell_mouse_capture: bool, /// Last host mouse capture mode sent to this client. pub(crate) host_mouse_capture_active: Option<bool>, /// Last SGR pixel provenance mode sent to this client. pub(crate) host_sgr_pixels_active: Option<bool>, - /// Last Kitty report-all mode sent to this client's host terminal. - pub(crate) host_keyboard_report_all_active: Option<bool>, + /// Last keyboard protocol state sent to a directly attached terminal client. + pub(crate) host_keyboard_protocol_active: Option<(u16, u8)>, + /// Presses forwarded by this shell that need release on abrupt teardown. + shell_held_inputs: HashMap<ClientShellPressId, ClientShellHeldInput>, /// Temporary files staged from this client's local clipboard image pastes. pub(crate) staged_clipboard_files: Vec<PathBuf>, + /// Last coherent shell replacement sent to this client. + pub(crate) shell_snapshot: Option<crate::protocol::ClientShellSnapshot>, + /// Monotonic shell replacement revision for this connection. + pub(crate) shell_projection_revision: u64, + /// Whether this shell is waiting for one ordered endpoint command response. + pub(crate) shell_endpoint_command_in_flight: bool, + /// Whether this shell uses the endpoint-owned keymap rather than a client-owned keymap. + pub(crate) shell_uses_endpoint_keybindings: bool, /// Channels for sending framed ServerMessage data to the client writer thread. pub(crate) writer: Option<ClientWriter>, } @@ -80,62 +119,53 @@ impl ClientConnection { pub(crate) fn new( terminal_size: (u16, u16), cell_size: crate::kitty_graphics::HostCellSize, - host_terminal_theme: crate::terminal_theme::TerminalTheme, - outer_terminal_focus: Option<bool>, last_activity: u64, render_encoding: RenderEncoding, writer: Option<ClientWriter>, ) -> Self { Self::new_with_mode( - ClientConnectionMode::App, - None, + ClientConnectionMode::ClientShell, terminal_size, cell_size, - host_terminal_theme, - outer_terminal_focus, last_activity, render_encoding, - false, writer, ) } pub(crate) fn new_with_mode( mode: ClientConnectionMode, - keybindings: Option<Box<crate::config::LiveKeybindConfig>>, terminal_size: (u16, u16), cell_size: crate::kitty_graphics::HostCellSize, - host_terminal_theme: crate::terminal_theme::TerminalTheme, - outer_terminal_focus: Option<bool>, last_activity: u64, render_encoding: RenderEncoding, - pending_terminal_attach: bool, writer: Option<ClientWriter>, ) -> Self { Self { mode, - pending_terminal_attach, - keybindings, terminal_size, cell_size, - host_terminal_appearance: host_terminal_theme - .background - .map(crate::terminal_theme::RgbColor::inferred_appearance), - host_terminal_appearance_explicit: false, - host_terminal_theme, - outer_terminal_focus, - raw_input: crate::raw_input::RawInputFramer::default(), last_activity, render_state: ClientRenderState::new(render_encoding), - graphics_cache: crate::kitty_graphics::HostGraphicsCache::default(), + shell_graphics_delivery: crate::kitty_graphics::surface::DeliveryCache::default(), direct_graphics: false, pixel_mouse: false, - graphics_surface_reset_pending: false, + host_terminal_theme: crate::terminal_theme::TerminalTheme::default(), + host_terminal_appearance: None, + host_terminal_appearance_explicit: false, + outer_terminal_focus: None, + host_keyboard_report_all_active: None, render_pending: false, + shell_mouse_capture: false, host_mouse_capture_active: None, host_sgr_pixels_active: None, - host_keyboard_report_all_active: None, + host_keyboard_protocol_active: None, + shell_held_inputs: HashMap::new(), staged_clipboard_files: Vec::new(), + shell_snapshot: None, + shell_projection_revision: 0, + shell_endpoint_command_in_flight: false, + shell_uses_endpoint_keybindings: false, writer, } } @@ -144,62 +174,151 @@ impl ClientConnection { self.render_state.request_repaint(); } - pub(crate) fn deferred_render(&self) -> DeferredRender { - if self.render_pending { - DeferredRender::Full - } else { - DeferredRender::None + pub(crate) fn track_shell_input( + &mut self, + target: ClientShellInputTarget, + events: &[ClientPaneInputEvent], + ) { + for event in events { + match event { + ClientPaneInputEvent::Key { + code, + modifiers, + kind: ClientKeyKind::Press, + shifted_codepoint, + tracks_release: true, + physical_key_id, + .. + } => { + self.shell_held_inputs.insert( + client_shell_key_press_id(code, *physical_key_id), + ClientShellHeldInput { + target: target.clone(), + release: ClientPaneInputEvent::Key { + code: code.clone(), + modifiers: *modifiers, + kind: ClientKeyKind::Release, + repeat_count: 1, + shifted_codepoint: *shifted_codepoint, + generated_text: None, + tracks_release: true, + physical_key_id: *physical_key_id, + }, + }, + ); + } + ClientPaneInputEvent::Key { + code, + kind: ClientKeyKind::Release, + physical_key_id, + .. + } => { + self.shell_held_inputs + .remove(&client_shell_key_press_id(code, *physical_key_id)); + } + ClientPaneInputEvent::Mouse { + kind: ClientMouseKind::Down(button), + position, + geometry, + modifiers, + .. + } + | ClientPaneInputEvent::Mouse { + kind: ClientMouseKind::Drag(button), + position, + geometry, + modifiers, + .. + } => { + let id = ClientShellPressId::Mouse(*button); + if matches!( + event, + ClientPaneInputEvent::Mouse { + kind: ClientMouseKind::Down(_), + .. + } + ) || self.shell_held_inputs.contains_key(&id) + { + self.shell_held_inputs.insert( + id, + ClientShellHeldInput { + target: target.clone(), + release: ClientPaneInputEvent::Mouse { + kind: ClientMouseKind::Up(*button), + position: *position, + geometry: *geometry, + modifiers: *modifiers, + lines: 1, + }, + }, + ); + } + } + ClientPaneInputEvent::Mouse { + kind: ClientMouseKind::Up(button), + .. + } => { + self.shell_held_inputs + .remove(&ClientShellPressId::Mouse(*button)); + } + ClientPaneInputEvent::Key { + kind: ClientKeyKind::Press | ClientKeyKind::Repeat, + .. + } + | ClientPaneInputEvent::TextCommit(_) + | ClientPaneInputEvent::Mouse { .. } + | ClientPaneInputEvent::Paste(_) => {} + } } } - pub(crate) fn clear_deferred_render(&mut self) { - self.render_pending = false; - } - - pub(crate) fn defer_full_render(&mut self) { - self.render_pending = true; - } - - pub(crate) fn take_deferred_render(&mut self) -> DeferredRender { - let deferred = self.deferred_render(); - self.clear_deferred_render(); - deferred - } - - pub(crate) fn is_full_app_client(&self) -> bool { - matches!(self.mode, ClientConnectionMode::App) && !self.pending_terminal_attach + pub(crate) fn drain_shell_held_inputs(&mut self) -> Vec<ClientShellHeldInput> { + self.shell_held_inputs + .drain() + .map(|(_, held)| held) + .collect() } - pub(crate) fn request_semantic_redraw_after_input(&mut self) { - self.render_state.reset_semantic_input_baseline(); - } - - pub(crate) fn update_host_theme_from_events( + pub(crate) fn update_host_theme( &mut self, - events: &[crate::raw_input::RawInputEvent], + update: &crate::protocol::ClientHostThemeUpdate, ) -> bool { let mut next_theme = self.host_terminal_theme; let mut changed = false; - for event in events { - match event { - crate::raw_input::RawInputEvent::HostDefaultColor { kind, color } => { - next_theme = next_theme.with_color(*kind, *color); - if matches!(kind, crate::terminal_theme::DefaultColorKind::Background) - && !self.host_terminal_appearance_explicit - { - changed |= - self.set_host_appearance(Some(color.inferred_appearance()), false); + + match update { + crate::protocol::ClientHostThemeUpdate::DefaultColor { kind, color } => { + let kind = match kind { + crate::protocol::ClientHostDefaultColorKind::Foreground => { + crate::terminal_theme::DefaultColorKind::Foreground } - } - crate::raw_input::RawInputEvent::HostPaletteColors { colors } => { - for &(index, color) in colors { - next_theme = next_theme.with_palette_color(index, color); + crate::protocol::ClientHostDefaultColorKind::Background => { + crate::terminal_theme::DefaultColorKind::Background } + }; + let color = (*color).into(); + next_theme = next_theme.with_color(kind, color); + if matches!(kind, crate::terminal_theme::DefaultColorKind::Background) + && !self.host_terminal_appearance_explicit + { + changed |= self.set_host_appearance(Some(color.inferred_appearance()), false); } - crate::raw_input::RawInputEvent::HostColorSchemeChanged(appearance) => { - changed |= self.set_host_appearance(Some(*appearance), true); + } + crate::protocol::ClientHostThemeUpdate::PaletteColors(colors) => { + for &(index, color) in colors { + next_theme = next_theme.with_palette_color(index, color.into()); } - _ => {} + } + crate::protocol::ClientHostThemeUpdate::Appearance(appearance) => { + let appearance = match appearance { + crate::protocol::ClientHostAppearance::Dark => { + crate::terminal_theme::HostAppearance::Dark + } + crate::protocol::ClientHostAppearance::Light => { + crate::terminal_theme::HostAppearance::Light + } + }; + changed |= self.set_host_appearance(Some(appearance), true); } } @@ -228,41 +347,37 @@ impl ClientConnection { true } - pub(crate) fn update_outer_focus_from_events( - &mut self, - events: &[crate::raw_input::RawInputEvent], - ) -> Option<bool> { - let next_focus = events - .iter() - .filter_map(|event| match event { - crate::raw_input::RawInputEvent::OuterFocusGained => Some(true), - crate::raw_input::RawInputEvent::OuterFocusLost => Some(false), - _ => None, - }) - .next_back()?; - - self.outer_terminal_focus = Some(next_focus); - Some(next_focus) + pub(crate) fn deferred_render(&self) -> DeferredRender { + if self.render_pending { + DeferredRender::Full + } else { + DeferredRender::None + } } -} -pub(crate) fn events_include_interaction(events: &[crate::raw_input::RawInputEvent]) -> bool { - events.iter().any(|event| { - matches!( - event, - crate::raw_input::RawInputEvent::Key(_) - | crate::raw_input::RawInputEvent::Text(_) - | crate::raw_input::RawInputEvent::Mouse(_) - | crate::raw_input::RawInputEvent::Paste(_) - | crate::raw_input::RawInputEvent::OuterFocusGained - ) - }) + pub(crate) fn clear_deferred_render(&mut self) { + self.render_pending = false; + } + + pub(crate) fn defer_full_render(&mut self) { + self.render_pending = true; + } + + pub(crate) fn take_deferred_render(&mut self) -> DeferredRender { + let deferred = self.deferred_render(); + self.clear_deferred_render(); + deferred + } + + pub(crate) fn is_shell_client(&self) -> bool { + matches!(self.mode, ClientConnectionMode::ClientShell) + } } -pub(crate) fn latest_app_client(clients: &HashMap<u64, ClientConnection>) -> Option<u64> { +pub(crate) fn latest_shell_client(clients: &HashMap<u64, ClientConnection>) -> Option<u64> { clients .iter() - .filter(|(_, client)| client.is_full_app_client()) + .filter(|(_, client)| client.is_shell_client()) .max_by_key(|(_, client)| client.last_activity) .map(|(&client_id, _)| client_id) } @@ -293,7 +408,7 @@ pub(crate) fn render_targets( .iter() .filter(|(_, client)| { client.writer.is_some() - && (client.is_full_app_client() + && (client.is_shell_client() || matches!( client.mode, ClientConnectionMode::TerminalAttach { .. } @@ -314,3 +429,73 @@ pub(crate) fn render_targets( targets.sort_by_key(|(client_id, _, _, is_foreground, _)| (*is_foreground, *client_id)); targets } + +#[cfg(test)] +mod tests { + use super::*; + + fn shell_client() -> ClientConnection { + ClientConnection::new( + (80, 24), + crate::kitty_graphics::HostCellSize::default(), + 1, + crate::protocol::RenderEncoding::SemanticFrame, + None, + ) + } + + #[test] + fn semantic_text_press_does_not_create_a_server_release_lease() { + let mut client = shell_client(); + client.track_shell_input( + ClientShellInputTarget::Pane("w1:p1".into()), + &[ClientPaneInputEvent::Key { + code: crate::protocol::ClientKeyCode::Char('x'), + modifiers: 0, + kind: ClientKeyKind::Press, + repeat_count: 1, + shifted_codepoint: None, + generated_text: Some("x".into()), + tracks_release: false, + physical_key_id: None, + }], + ); + + assert!(client.drain_shell_held_inputs().is_empty()); + } + + #[test] + fn physical_keys_with_the_same_semantic_code_keep_distinct_release_leases() { + let mut client = shell_client(); + let key = |kind, physical_key_id| ClientPaneInputEvent::Key { + code: crate::protocol::ClientKeyCode::Enter, + modifiers: 0, + kind, + repeat_count: 1, + shifted_codepoint: None, + generated_text: None, + tracks_release: true, + physical_key_id: Some(physical_key_id), + }; + client.track_shell_input( + ClientShellInputTarget::Pane("w1:p1".into()), + &[ + key(ClientKeyKind::Press, 13), + key(ClientKeyKind::Press, 108), + key(ClientKeyKind::Release, 13), + ], + ); + + let held = client.drain_shell_held_inputs(); + assert_eq!(held.len(), 1); + assert!(matches!( + &held[0].release, + ClientPaneInputEvent::Key { + code: crate::protocol::ClientKeyCode::Enter, + kind: ClientKeyKind::Release, + physical_key_id: Some(108), + .. + } + )); + } +} |
