diff options
| author | Can Celik <ogulcancelik@gmail.com> | 2026-04-05 03:46:16 +0300 |
|---|---|---|
| committer | Can Celik <ogulcancelik@gmail.com> | 2026-04-05 03:46:16 +0300 |
| commit | 8525503dba9029d0a5f24990e026d8164f0bf39c (patch) | |
| tree | 43b902dfab49603c667ae016baa97aee27724073 /src | |
| parent | a08dfaef2f13873fc85dc9e1357235b8b0703a46 (diff) | |
fix: match ghostty pane default colors to host terminal theme
Diffstat (limited to 'src')
| -rw-r--r-- | src/app/input.rs | 14 | ||||
| -rw-r--r-- | src/app/mod.rs | 42 | ||||
| -rw-r--r-- | src/app/state.rs | 4 | ||||
| -rw-r--r-- | src/ghostty/mod.rs | 27 | ||||
| -rw-r--r-- | src/input/mod.rs | 4 | ||||
| -rw-r--r-- | src/main.rs | 1 | ||||
| -rw-r--r-- | src/pane.rs | 175 | ||||
| -rw-r--r-- | src/persist.rs | 1 | ||||
| -rw-r--r-- | src/raw_input.rs | 65 | ||||
| -rw-r--r-- | src/terminal_theme.rs | 143 | ||||
| -rw-r--r-- | src/workspace.rs | 28 |
11 files changed, 431 insertions, 73 deletions
diff --git a/src/app/input.rs b/src/app/input.rs index 688a2a05..baa46e4c 100644 --- a/src/app/input.rs +++ b/src/app/input.rs @@ -2080,9 +2080,12 @@ impl AppState { } } - MouseEventKind::Up(MouseButton::Middle) | MouseEventKind::Up(MouseButton::Right) + MouseEventKind::Up(MouseButton::Middle) + | MouseEventKind::Up(MouseButton::Right) | MouseEventKind::Drag(MouseButton::Middle) - | MouseEventKind::Drag(MouseButton::Right) if !in_sidebar => { + | MouseEventKind::Drag(MouseButton::Right) + if !in_sidebar => + { if let Some(info) = self.pane_mouse_target(mouse.column, mouse.row).cloned() { let _ = self.forward_pane_mouse_button(&info, mouse); } @@ -2390,7 +2393,8 @@ impl AppState { } fn pane_mouse_target(&self, col: u16, row: u16) -> Option<&PaneInfo> { - self.pane_at(col, row).or_else(|| self.pane_frame_at(col, row)) + self.pane_at(col, row) + .or_else(|| self.pane_frame_at(col, row)) } fn pane_frame_at(&self, col: u16, row: u16) -> Option<&PaneInfo> { @@ -2613,7 +2617,9 @@ impl AppState { .and_then(|rt| rt.cwd()); if let Some(ws) = self.active.and_then(|i| self.workspaces.get_mut(i)) { - if let Ok(new_id) = ws.split_focused(direction, new_rows, new_cols, cwd) { + if let Ok(new_id) = + ws.split_focused(direction, new_rows, new_cols, cwd, self.host_terminal_theme) + { ws.layout.focus_pane(new_id); self.mode = Mode::Terminal; } diff --git a/src/app/mod.rs b/src/app/mod.rs index b53024b1..064b153a 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -256,6 +256,7 @@ impl App { original_theme: None, }, global_menu: state::MenuListState::new(0), + host_terminal_theme: crate::terminal_theme::TerminalTheme::default(), }; for ws in &mut state.workspaces { @@ -308,6 +309,7 @@ impl App { if self.input_rx.is_none() { self.input_rx = Some(crate::raw_input::spawn_input_reader()); } + self.query_host_terminal_theme(); let mut needs_render = true; @@ -494,10 +496,44 @@ impl App { self.handle_mouse(mouse); true } + crate::raw_input::RawInputEvent::HostDefaultColor { kind, color } => { + let next_theme = self.state.host_terminal_theme.with_color(kind, color); + if next_theme == self.state.host_terminal_theme { + return false; + } + self.state.host_terminal_theme = next_theme; + self.apply_host_terminal_theme_to_panes(); + true + } crate::raw_input::RawInputEvent::Unsupported => false, } } + fn query_host_terminal_theme(&self) { + use std::io::Write; + + let _ = std::io::stdout() + .write_all(crate::terminal_theme::HOST_COLOR_QUERY_SEQUENCE.as_bytes()); + let _ = std::io::stdout().flush(); + } + + fn apply_host_terminal_theme_to_panes(&self) { + if self.state.host_terminal_theme.is_empty() { + return; + } + + for workspace in &self.state.workspaces { + for tab in &workspace.tabs { + for runtime in tab.runtimes.values() { + runtime.apply_host_terminal_theme(self.state.host_terminal_theme); + } + } + } + + self.render_dirty.store(true, Ordering::Release); + self.render_notify.notify_one(); + } + fn handle_resize_poll(&mut self) -> bool { let Ok(size) = terminal::size() else { return false; @@ -1179,7 +1215,7 @@ impl App { .workspaces .get_mut(ws_idx) .ok_or_else(|| std::io::Error::other("workspace disappeared")) - .and_then(|ws| ws.create_tab(rows, cols, cwd)); + .and_then(|ws| ws.create_tab(rows, cols, cwd, self.state.host_terminal_theme)); match result { Ok(tab_idx) => { if params.focus { @@ -1378,6 +1414,7 @@ impl App { rows, cols, params.cwd.map(std::path::PathBuf::from), + self.state.host_terminal_theme, ) { Ok(new_pane_id) => new_pane_id, Err(err) => { @@ -1907,7 +1944,7 @@ impl App { }; let (rows, cols) = self.state.estimate_pane_size(); let ws = &mut self.state.workspaces[ws_idx]; - let idx = ws.create_tab(rows, cols, initial_cwd)?; + let idx = ws.create_tab(rows, cols, initial_cwd, self.state.host_terminal_theme)?; if focus { ws.switch_tab(idx); self.state.mode = Mode::Terminal; @@ -1925,6 +1962,7 @@ impl App { initial_cwd, rows, cols, + self.state.host_terminal_theme, self.event_tx.clone(), self.render_notify.clone(), self.render_dirty.clone(), diff --git a/src/app/state.rs b/src/app/state.rs index 6708ef65..c55b5cd8 100644 --- a/src/app/state.rs +++ b/src/app/state.rs @@ -5,6 +5,7 @@ use ratatui::style::Color; use crate::layout::{PaneInfo, SplitBorder}; use crate::selection::Selection; +use crate::terminal_theme::TerminalTheme; use crate::workspace::Workspace; // --------------------------------------------------------------------------- @@ -595,6 +596,8 @@ pub struct AppState { pub settings: SettingsState, /// Highlight state for the bottom-right global launcher menu. pub global_menu: MenuListState, + /// Resolved host terminal default colors for theming embedded panes. + pub host_terminal_theme: TerminalTheme, } impl AppState { @@ -740,6 +743,7 @@ impl AppState { original_theme: None, }, global_menu: MenuListState::new(0), + host_terminal_theme: TerminalTheme::default(), } } } diff --git a/src/ghostty/mod.rs b/src/ghostty/mod.rs index 718abd0c..fe76d00d 100644 --- a/src/ghostty/mod.rs +++ b/src/ghostty/mod.rs @@ -350,9 +350,7 @@ impl Terminal { } Ok(match out { ffi::GhosttyTerminalScreen_GHOSTTY_TERMINAL_SCREEN_PRIMARY => ActiveScreen::Primary, - ffi::GhosttyTerminalScreen_GHOSTTY_TERMINAL_SCREEN_ALTERNATE => { - ActiveScreen::Alternate - } + ffi::GhosttyTerminalScreen_GHOSTTY_TERMINAL_SCREEN_ALTERNATE => ActiveScreen::Alternate, _ => ActiveScreen::Primary, }) } @@ -408,8 +406,13 @@ impl Terminal { return Ok(buffer); } unsafe { - ffi::ghostty_grid_ref_graphemes(&grid_ref, buffer.as_mut_ptr(), buffer.len(), &mut required) - .into_result()?; + ffi::ghostty_grid_ref_graphemes( + &grid_ref, + buffer.as_mut_ptr(), + buffer.len(), + &mut required, + ) + .into_result()?; } buffer.truncate(required); Ok(buffer) @@ -527,12 +530,16 @@ impl RenderState { } pub fn cursor_viewport(&self) -> Result<Option<CursorViewport>, Error> { - if !self.get_bool(ffi::GhosttyRenderStateData_GHOSTTY_RENDER_STATE_DATA_CURSOR_VIEWPORT_HAS_VALUE)? { + if !self.get_bool( + ffi::GhosttyRenderStateData_GHOSTTY_RENDER_STATE_DATA_CURSOR_VIEWPORT_HAS_VALUE, + )? { return Ok(None); } Ok(Some(CursorViewport { - x: self.get_u16(ffi::GhosttyRenderStateData_GHOSTTY_RENDER_STATE_DATA_CURSOR_VIEWPORT_X)?, - y: self.get_u16(ffi::GhosttyRenderStateData_GHOSTTY_RENDER_STATE_DATA_CURSOR_VIEWPORT_Y)?, + x: self + .get_u16(ffi::GhosttyRenderStateData_GHOSTTY_RENDER_STATE_DATA_CURSOR_VIEWPORT_X)?, + y: self + .get_u16(ffi::GhosttyRenderStateData_GHOSTTY_RENDER_STATE_DATA_CURSOR_VIEWPORT_Y)?, wide_tail: self.get_bool( ffi::GhosttyRenderStateData_GHOSTTY_RENDER_STATE_DATA_CURSOR_VIEWPORT_WIDE_TAIL, )?, @@ -1007,9 +1014,7 @@ mod tests { let responses = std::sync::Arc::new(std::sync::Mutex::new(Vec::<u8>::new())); let sink = responses.clone(); terminal - .set_write_pty_callback(move |bytes| { - sink.lock().unwrap().extend_from_slice(bytes) - }) + .set_write_pty_callback(move |bytes| sink.lock().unwrap().extend_from_slice(bytes)) .unwrap(); terminal.write(b"\x1b[6n"); diff --git a/src/input/mod.rs b/src/input/mod.rs index 439c310e..026d51d9 100644 --- a/src/input/mod.rs +++ b/src/input/mod.rs @@ -4,7 +4,9 @@ mod parse; #[cfg(test)] pub use encode::encode_key; -pub use encode::{encode_cursor_key, encode_mouse_button, encode_mouse_scroll, encode_terminal_key}; +pub use encode::{ + encode_cursor_key, encode_mouse_button, encode_mouse_scroll, encode_terminal_key, +}; pub use model::{KeyboardProtocol, TerminalKey}; pub use parse::parse_terminal_key_sequence; diff --git a/src/main.rs b/src/main.rs index 03f2ce78..b0f986bd 100644 --- a/src/main.rs +++ b/src/main.rs @@ -37,6 +37,7 @@ mod raw_input; mod release_notes; mod selection; mod sound; +mod terminal_theme; mod ui; mod update; mod workspace; diff --git a/src/pane.rs b/src/pane.rs index 0ef46a8f..2e014027 100644 --- a/src/pane.rs +++ b/src/pane.rs @@ -7,11 +7,9 @@ use std::sync::{ use bytes::Bytes; use portable_pty::{native_pty_system, CommandBuilder, PtySize}; -use ratatui::{ - layout::Rect, - style::{Color, Modifier, Style}, - Frame, -}; +#[cfg(feature = "ghostty-vt")] +use ratatui::style::{Color, Modifier, Style}; +use ratatui::{layout::Rect, Frame}; use tokio::sync::{mpsc, Notify}; use tracing::{debug, error, info, warn}; use tui_term::widget::PseudoTerminal; @@ -416,6 +414,15 @@ impl PaneTerminal { } } + fn apply_host_terminal_theme(&self, theme: crate::terminal_theme::TerminalTheme) { + let _ = theme; + match self { + Self::Vt(_) => {} + #[cfg(feature = "ghostty-vt")] + Self::Ghostty(ghostty) => ghostty.apply_host_terminal_theme(theme), + } + } + fn keyboard_protocol( &self, fallback: crate::input::KeyboardProtocol, @@ -676,8 +683,8 @@ impl GhosttyPaneTerminal { }) .map_err(|e| std::io::Error::other(e.to_string()))?; - let render_state = crate::ghostty::RenderState::new() - .map_err(|e| std::io::Error::other(e.to_string()))?; + let render_state = + crate::ghostty::RenderState::new().map_err(|e| std::io::Error::other(e.to_string()))?; Ok(Self { core: Mutex::new(GhosttyPaneCore { terminal, @@ -686,6 +693,25 @@ impl GhosttyPaneTerminal { }) } + fn apply_host_terminal_theme(&self, theme: crate::terminal_theme::TerminalTheme) { + if let Ok(mut core) = self.core.lock() { + if let Some(color) = theme.foreground { + let sequence = crate::terminal_theme::osc_set_default_color_sequence( + crate::terminal_theme::DefaultColorKind::Foreground, + color, + ); + core.terminal.write(sequence.as_bytes()); + } + if let Some(color) = theme.background { + let sequence = crate::terminal_theme::osc_set_default_color_sequence( + crate::terminal_theme::DefaultColorKind::Background, + color, + ); + core.terminal.write(sequence.as_bytes()); + } + } + } + fn process_pty_bytes( &self, pane_id: PaneId, @@ -745,7 +771,9 @@ impl GhosttyPaneTerminal { }; let scrollbar = core.terminal.scrollbar().ok()?; Some(ScrollMetrics { - offset_from_bottom: scrollbar.total.saturating_sub(scrollbar.offset + scrollbar.len), + offset_from_bottom: scrollbar + .total + .saturating_sub(scrollbar.offset + scrollbar.len), max_offset_from_bottom: scrollbar.total.saturating_sub(scrollbar.len), viewport_rows: scrollbar.len, }) @@ -778,8 +806,14 @@ impl GhosttyPaneTerminal { .terminal .mode_get(crate::ghostty::MODE_FOCUS_EVENT) .ok()?; - let mouse_sgr = core.terminal.mode_get(crate::ghostty::MODE_MOUSE_SGR).ok()?; - let mouse_utf8 = core.terminal.mode_get(crate::ghostty::MODE_MOUSE_UTF8).ok()?; + let mouse_sgr = core + .terminal + .mode_get(crate::ghostty::MODE_MOUSE_SGR) + .ok()?; + let mouse_utf8 = core + .terminal + .mode_get(crate::ghostty::MODE_MOUSE_UTF8) + .ok()?; let mouse_alternate_scroll = core .terminal .mode_get(crate::ghostty::MODE_MOUSE_ALTERNATE_SCROLL) @@ -1056,33 +1090,42 @@ fn ghostty_mouse_event_from_button_kind( ) -> Option<crate::ghostty::MouseEvent> { let mut event = crate::ghostty::MouseEvent::new().ok()?; let (action, button) = match kind { - crossterm::event::MouseEventKind::Down(crossterm::event::MouseButton::Left) => { - (crate::ghostty::MOUSE_ACTION_PRESS, Some(crate::ghostty::MOUSE_BUTTON_LEFT)) - } - crossterm::event::MouseEventKind::Down(crossterm::event::MouseButton::Middle) => { - (crate::ghostty::MOUSE_ACTION_PRESS, Some(crate::ghostty::MOUSE_BUTTON_MIDDLE)) - } - crossterm::event::MouseEventKind::Down(crossterm::event::MouseButton::Right) => { - (crate::ghostty::MOUSE_ACTION_PRESS, Some(crate::ghostty::MOUSE_BUTTON_RIGHT)) - } - crossterm::event::MouseEventKind::Up(crossterm::event::MouseButton::Left) => { - (crate::ghostty::MOUSE_ACTION_RELEASE, Some(crate::ghostty::MOUSE_BUTTON_LEFT)) - } - crossterm::event::MouseEventKind::Up(crossterm::event::MouseButton::Middle) => { - (crate::ghostty::MOUSE_ACTION_RELEASE, Some(crate::ghostty::MOUSE_BUTTON_MIDDLE)) - } - crossterm::event::MouseEventKind::Up(crossterm::event::MouseButton::Right) => { - (crate::ghostty::MOUSE_ACTION_RELEASE, Some(crate::ghostty::MOUSE_BUTTON_RIGHT)) - } - crossterm::event::MouseEventKind::Drag(crossterm::event::MouseButton::Left) => { - (crate::ghostty::MOUSE_ACTION_MOTION, Some(crate::ghostty::MOUSE_BUTTON_LEFT)) - } - crossterm::event::MouseEventKind::Drag(crossterm::event::MouseButton::Middle) => { - (crate::ghostty::MOUSE_ACTION_MOTION, Some(crate::ghostty::MOUSE_BUTTON_MIDDLE)) - } - crossterm::event::MouseEventKind::Drag(crossterm::event::MouseButton::Right) => { - (crate::ghostty::MOUSE_ACTION_MOTION, Some(crate::ghostty::MOUSE_BUTTON_RIGHT)) - } + crossterm::event::MouseEventKind::Down(crossterm::event::MouseButton::Left) => ( + crate::ghostty::MOUSE_ACTION_PRESS, + Some(crate::ghostty::MOUSE_BUTTON_LEFT), + ), + crossterm::event::MouseEventKind::Down(crossterm::event::MouseButton::Middle) => ( + crate::ghostty::MOUSE_ACTION_PRESS, + Some(crate::ghostty::MOUSE_BUTTON_MIDDLE), + ), + crossterm::event::MouseEventKind::Down(crossterm::event::MouseButton::Right) => ( + crate::ghostty::MOUSE_ACTION_PRESS, + Some(crate::ghostty::MOUSE_BUTTON_RIGHT), + ), + crossterm::event::MouseEventKind::Up(crossterm::event::MouseButton::Left) => ( + crate::ghostty::MOUSE_ACTION_RELEASE, + Some(crate::ghostty::MOUSE_BUTTON_LEFT), + ), + crossterm::event::MouseEventKind::Up(crossterm::event::MouseButton::Middle) => ( + crate::ghostty::MOUSE_ACTION_RELEASE, + Some(crate::ghostty::MOUSE_BUTTON_MIDDLE), + ), + crossterm::event::MouseEventKind::Up(crossterm::event::MouseButton::Right) => ( + crate::ghostty::MOUSE_ACTION_RELEASE, + Some(crate::ghostty::MOUSE_BUTTON_RIGHT), + ), + crossterm::event::MouseEventKind::Drag(crossterm::event::MouseButton::Left) => ( + crate::ghostty::MOUSE_ACTION_MOTION, + Some(crate::ghostty::MOUSE_BUTTON_LEFT), + ), + crossterm::event::MouseEventKind::Drag(crossterm::event::MouseButton::Middle) => ( + crate::ghostty::MOUSE_ACTION_MOTION, + Some(crate::ghostty::MOUSE_BUTTON_MIDDLE), + ), + crossterm::event::MouseEventKind::Drag(crossterm::event::MouseButton::Right) => ( + crate::ghostty::MOUSE_ACTION_MOTION, + Some(crate::ghostty::MOUSE_BUTTON_RIGHT), + ), _ => return None, }; event.set_action(action); @@ -1273,6 +1316,7 @@ fn ghostty_unshifted_ascii_pair(c: char) -> Option<char> { }) } +#[cfg(feature = "ghostty-vt")] fn ghostty_visible_text(core: &mut GhosttyPaneCore) -> Result<String, crate::ghostty::Error> { let GhosttyPaneCore { terminal, @@ -1292,7 +1336,10 @@ fn ghostty_visible_text(core: &mut GhosttyPaneCore) -> Result<String, crate::gho } #[cfg(feature = "ghostty-vt")] -fn ghostty_recent_text(core: &GhosttyPaneCore, lines: usize) -> Result<String, crate::ghostty::Error> { +fn ghostty_recent_text( + core: &GhosttyPaneCore, + lines: usize, +) -> Result<String, crate::ghostty::Error> { let total_rows = core.terminal.total_rows()?; let cols = core.terminal.cols()?; let start = total_rows.saturating_sub(lines); @@ -1387,7 +1434,9 @@ fn ghostty_line_from_cells( } #[cfg(feature = "ghostty-vt")] -fn ghostty_cell_symbol(cells: &crate::ghostty::RowCellIter<'_>) -> Result<String, crate::ghostty::Error> { +fn ghostty_cell_symbol( + cells: &crate::ghostty::RowCellIter<'_>, +) -> Result<String, crate::ghostty::Error> { let graphemes = cells.graphemes()?; if graphemes.is_empty() { return Ok(" ".to_string()); @@ -1411,8 +1460,18 @@ fn ghostty_cell_style( default_bg: Option<Color>, ) -> Style { let style_data = cells.style().unwrap_or_default(); - let mut fg = cells.fg_color().ok().flatten().map(ghostty_color).or(default_fg); - let mut bg = cells.bg_color().ok().flatten().map(ghostty_color).or(default_bg); + let mut fg = cells + .fg_color() + .ok() + .flatten() + .map(ghostty_color) + .or(default_fg); + let mut bg = cells + .bg_color() + .ok() + .flatten() + .map(ghostty_color) + .or(default_bg); if style_data.invisible { fg = bg.or(default_bg); } @@ -1607,11 +1666,16 @@ impl PaneRuntime { shutdown_pane_processes(pane_id, self.child_pid.load(Ordering::Acquire)); } + pub fn apply_host_terminal_theme(&self, theme: crate::terminal_theme::TerminalTheme) { + self.terminal.apply_host_terminal_theme(theme); + } + pub fn spawn( pane_id: PaneId, rows: u16, cols: u16, cwd: std::path::PathBuf, + _host_terminal_theme: crate::terminal_theme::TerminalTheme, events: mpsc::Sender<AppEvent>, render_notify: Arc<Notify>, render_dirty: Arc<AtomicBool>, @@ -1655,11 +1719,10 @@ impl PaneRuntime { let terminal = { let terminal = crate::ghostty::Terminal::new(cols, rows, 10000) .map_err(|e| std::io::Error::other(e.to_string()))?; + let pane_terminal = GhosttyPaneTerminal::new(terminal, input_tx.clone())?; + pane_terminal.apply_host_terminal_theme(_host_terminal_theme); ( - Arc::new(PaneTerminal::Ghostty(GhosttyPaneTerminal::new( - terminal, - input_tx.clone(), - )?)), + Arc::new(PaneTerminal::Ghostty(pane_terminal)), Arc::new(AtomicU16::new(0)), ) }; @@ -2159,7 +2222,9 @@ mod tests { fn ghostty_key_encoding_honors_application_cursor_mode() { let (tx, _rx) = mpsc::channel(4); let mut terminal = crate::ghostty::Terminal::new(80, 24, 0).unwrap(); - terminal.mode_set(crate::ghostty::MODE_APPLICATION_CURSOR_KEYS, true).unwrap(); + terminal + .mode_set(crate::ghostty::MODE_APPLICATION_CURSOR_KEYS, true) + .unwrap(); let pane = GhosttyPaneTerminal::new(terminal, tx).unwrap(); let encoded = pane.encode_terminal_key( @@ -2215,9 +2280,13 @@ mod tests { let (tx, mut rx) = mpsc::channel(4); let (resize_tx, _resize_rx) = mpsc::channel(1); let mut terminal = crate::ghostty::Terminal::new(80, 24, 0).unwrap(); - terminal.mode_set(crate::ghostty::MODE_FOCUS_EVENT, true).unwrap(); + terminal + .mode_set(crate::ghostty::MODE_FOCUS_EVENT, true) + .unwrap(); let runtime = PaneRuntime { - terminal: Arc::new(PaneTerminal::Ghostty(GhosttyPaneTerminal::new(terminal, tx.clone()).unwrap())), + terminal: Arc::new(PaneTerminal::Ghostty( + GhosttyPaneTerminal::new(terminal, tx.clone()).unwrap(), + )), sender: tx, resize_tx, current_size: Cell::new((80, 24)), @@ -2239,7 +2308,9 @@ mod tests { let (resize_tx, _resize_rx) = mpsc::channel(1); let terminal = crate::ghostty::Terminal::new(80, 24, 0).unwrap(); let runtime = PaneRuntime { - terminal: Arc::new(PaneTerminal::Ghostty(GhosttyPaneTerminal::new(terminal, tx.clone()).unwrap())), + terminal: Arc::new(PaneTerminal::Ghostty( + GhosttyPaneTerminal::new(terminal, tx.clone()).unwrap(), + )), sender: tx, resize_tx, current_size: Cell::new((80, 24)), @@ -2251,7 +2322,11 @@ mod tests { }; assert!(!runtime.try_send_focus_event(crate::ghostty::FocusEvent::Gained)); - assert!(tokio::time::timeout(std::time::Duration::from_millis(10), rx.recv()).await.is_err()); + assert!( + tokio::time::timeout(std::time::Duration::from_millis(10), rx.recv()) + .await + .is_err() + ); } #[test] diff --git a/src/persist.rs b/src/persist.rs index 3b181b06..345fe9f7 100644 --- a/src/persist.rs +++ b/src/persist.rs @@ -385,6 +385,7 @@ fn restore_tab( rows, cols, cwd.clone(), + crate::terminal_theme::TerminalTheme::default(), events.clone(), render_notify.clone(), render_dirty.clone(), diff --git a/src/raw_input.rs b/src/raw_input.rs index 77aaecdb..059791ea 100644 --- a/src/raw_input.rs +++ b/src/raw_input.rs @@ -7,6 +7,7 @@ use std::os::fd::AsRawFd; use tokio::sync::mpsc; use crate::input::{parse_terminal_key_sequence, TerminalKey}; +use crate::terminal_theme::{parse_default_color_response, DefaultColorKind, RgbColor}; const ESC: u8 = 0x1b; const BRACKETED_PASTE_START: &[u8] = b"\x1b[200~"; @@ -17,6 +18,10 @@ pub enum RawInputEvent { Key(TerminalKey), Paste(String), Mouse(MouseEvent), + HostDefaultColor { + kind: DefaultColorKind, + color: RgbColor, + }, Unsupported, } @@ -161,6 +166,10 @@ fn extract_one_event(buffer: &[u8]) -> Option<(RawInputEvent, usize)> { let seq_len = complete_escape_sequence_len(buffer)?; let seq = std::str::from_utf8(&buffer[..seq_len]).ok()?; + if let Some((kind, color)) = parse_default_color_response(seq) { + return Some((RawInputEvent::HostDefaultColor { kind, color }, seq_len)); + } + if let Some(mouse) = parse_sgr_mouse(seq) { return Some((RawInputEvent::Mouse(mouse), seq_len)); } @@ -200,8 +209,11 @@ fn complete_escape_sequence_len(buffer: &[u8]) -> Option<usize> { ); } - if buffer.starts_with(b"\x1b]") || buffer.starts_with(b"\x1bP") || buffer.starts_with(b"\x1b_") - { + if buffer.starts_with(b"\x1b]") { + return find_osc_terminator(buffer); + } + + if buffer.starts_with(b"\x1bP") || buffer.starts_with(b"\x1b_") { return find_subsequence(buffer, b"\x1b\\").map(|idx| idx + 2); } @@ -212,6 +224,17 @@ fn complete_escape_sequence_len(buffer: &[u8]) -> Option<usize> { Some(2) } +fn find_osc_terminator(buffer: &[u8]) -> Option<usize> { + find_subsequence(buffer, b"\x1b\\") + .map(|idx| idx + 2) + .or_else(|| { + buffer + .iter() + .position(|byte| *byte == b'\x07') + .map(|idx| idx + 1) + }) +} + fn find_csi_final(buffer: &[u8], finals: &[u8]) -> Option<usize> { for (idx, byte) in buffer.iter().enumerate().skip(2) { if finals.contains(byte) { @@ -407,6 +430,44 @@ mod tests { } #[test] + fn parses_host_default_color_response_with_st() { + let (RawInputEvent::HostDefaultColor { kind, color }, consumed) = + extract_one_event(b"\x1b]10;rgb:cccc/dddd/eeee\x1b\\").unwrap() + else { + panic!("expected host color response"); + }; + assert_eq!(consumed, 25); + assert_eq!(kind, DefaultColorKind::Foreground); + assert_eq!( + color, + RgbColor { + r: 0xcc, + g: 0xdd, + b: 0xee + } + ); + } + + #[test] + fn parses_host_default_color_response_with_bel() { + let (RawInputEvent::HostDefaultColor { kind, color }, consumed) = + extract_one_event(b"\x1b]11;#112233\x07").unwrap() + else { + panic!("expected host color response"); + }; + assert_eq!(consumed, 13); + assert_eq!(kind, DefaultColorKind::Background); + assert_eq!( + color, + RgbColor { + r: 0x11, + g: 0x22, + b: 0x33 + } + ); + } + + #[test] fn parses_legacy_up_arrow() { let (RawInputEvent::Key(key), consumed) = extract_one_event(b"\x1b[A").unwrap() else { panic!("expected key"); diff --git a/src/terminal_theme.rs b/src/terminal_theme.rs new file mode 100644 index 00000000..e0b4b9d5 --- /dev/null +++ b/src/terminal_theme.rs @@ -0,0 +1,143 @@ +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub struct RgbColor { + pub r: u8, + pub g: u8, + pub b: u8, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub struct TerminalTheme { + pub foreground: Option<RgbColor>, + pub background: Option<RgbColor>, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum DefaultColorKind { + Foreground, + Background, +} + +pub const HOST_COLOR_QUERY_SEQUENCE: &str = "\x1b]10;?\x1b\\\x1b]11;?\x1b\\"; + +impl TerminalTheme { + pub fn with_color(mut self, kind: DefaultColorKind, color: RgbColor) -> Self { + match kind { + DefaultColorKind::Foreground => self.foreground = Some(color), + DefaultColorKind::Background => self.background = Some(color), + } + self + } + + pub fn is_empty(self) -> bool { + self.foreground.is_none() && self.background.is_none() + } +} + +pub fn parse_default_color_response(sequence: &str) -> Option<(DefaultColorKind, RgbColor)> { + let body = sequence.strip_prefix("\x1b]")?; + let body = body + .strip_suffix("\x1b\\") + .or_else(|| body.strip_suffix('\u{7}'))?; + let (command, value) = body.split_once(';')?; + let kind = match command { + "10" => DefaultColorKind::Foreground, + "11" => DefaultColorKind::Background, + _ => return None, + }; + Some((kind, parse_rgb_color(value)?)) +} + +#[cfg_attr(not(feature = "ghostty-vt"), allow(dead_code))] +pub fn osc_set_default_color_sequence(kind: DefaultColorKind, color: RgbColor) -> String { + let command = match kind { + DefaultColorKind::Foreground => 10, + DefaultColorKind::Background => 11, + }; + format!( + "\x1b]{command};rgb:{:02x}/{:02x}/{:02x}\x1b\\", + color.r, color.g, color.b + ) +} + +fn parse_rgb_color(value: &str) -> Option<RgbColor> { + if let Some(rgb) = value.strip_prefix("rgb:") { + let mut parts = rgb.split('/'); + return Some(RgbColor { + r: parse_hex_component(parts.next()?)?, + g: parse_hex_component(parts.next()?)?, + b: parse_hex_component(parts.next()?)?, + }) + .filter(|_| parts.next().is_none()); + } + + if let Some(hex) = value.strip_prefix('#') { + let digits = hex.len() / 3; + if !matches!(digits, 1..=4) || hex.len() != digits * 3 { + return None; + } + return Some(RgbColor { + r: parse_hex_component(&hex[..digits])?, + g: parse_hex_component(&hex[digits..digits * 2])?, + b: parse_hex_component(&hex[digits * 2..])?, + }); + } + + None +} + +fn parse_hex_component(component: &str) -> Option<u8> { + if component.is_empty() + || component.len() > 4 + || !component.chars().all(|ch| ch.is_ascii_hexdigit()) + { + return None; + } + let value = u32::from_str_radix(component, 16).ok()?; + let max = (1u32 << (component.len() * 4)) - 1; + Some(((value * 255 + (max / 2)) / max) as u8) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_st_terminated_rgb_response() { + let parsed = parse_default_color_response("\x1b]10;rgb:cccc/dddd/eeee\x1b\\"); + assert_eq!( + parsed, + Some(( + DefaultColorKind::Foreground, + RgbColor { + r: 0xcc, + g: 0xdd, + b: 0xee, + }, + )) + ); + } + + #[test] + fn parses_bel_terminated_hash_response() { + let parsed = parse_default_color_response("\x1b]11;#123456\u{7}"); + assert_eq!( + parsed, + Some(( + DefaultColorKind::Background, + RgbColor { + r: 0x12, + g: 0x34, + b: 0x56, + }, + )) + ); + } + + #[test] + fn scales_short_hex_components() { + assert_eq!(parse_hex_component("f"), Some(255)); + assert_eq!(parse_hex_component("80"), Some(128)); + assert_eq!(parse_hex_component("800"), Some(128)); + assert_eq!(parse_hex_component("8000"), Some(128)); + } +} diff --git a/src/workspace.rs b/src/workspace.rs index 18825cb9..25de6568 100644 --- a/src/workspace.rs +++ b/src/workspace.rs @@ -58,6 +58,7 @@ impl Tab { initial_cwd: PathBuf, rows: u16, cols: u16, + host_terminal_theme: crate::terminal_theme::TerminalTheme, events: mpsc::Sender<AppEvent>, render_notify: Arc<Notify>, render_dirty: Arc<AtomicBool>, @@ -68,6 +69,7 @@ impl Tab { rows, cols, initial_cwd.clone(), + host_terminal_theme, events.clone(), render_notify.clone(), render_dirty.clone(), @@ -111,6 +113,7 @@ impl Tab { rows: u16, cols: u16, cwd: Option<PathBuf>, + host_terminal_theme: crate::terminal_theme::TerminalTheme, ) -> std::io::Result<PaneId> { let new_id = self.layout.split_focused(direction); let actual_cwd = @@ -120,6 +123,7 @@ impl Tab { rows, cols, actual_cwd.clone(), + host_terminal_theme, self.events.clone(), self.render_notify.clone(), self.render_dirty.clone(), @@ -277,6 +281,7 @@ impl Workspace { initial_cwd: PathBuf, rows: u16, cols: u16, + host_terminal_theme: crate::terminal_theme::TerminalTheme, events: mpsc::Sender<AppEvent>, render_notify: Arc<Notify>, render_dirty: Arc<AtomicBool>, @@ -286,6 +291,7 @@ impl Workspace { initial_cwd.clone(), rows, cols, + host_terminal_theme, events, render_notify, render_dirty, @@ -328,7 +334,13 @@ impl Workspace { } } - pub fn create_tab(&mut self, rows: u16, cols: u16, cwd: PathBuf) -> std::io::Result<usize> { + pub fn create_tab( + &mut self, + rows: u16, + cols: u16, + cwd: PathBuf, + host_terminal_theme: crate::terminal_theme::TerminalTheme, + ) -> std::io::Result<usize> { let number = self.tabs.len() + 1; let events = self .active_tab() @@ -343,7 +355,16 @@ impl Workspace { .map(|tab| tab.render_dirty.clone()) .expect("workspace must always have at least one tab"); - let tab = Tab::new(number, cwd, rows, cols, events, render_notify, render_dirty)?; + let tab = Tab::new( + number, + cwd, + rows, + cols, + host_terminal_theme, + events, + render_notify, + render_dirty, + )?; self.register_new_pane(tab.root_pane); self.tabs.push(tab); self.active_tab = self.tabs.len() - 1; @@ -377,11 +398,12 @@ impl Workspace { rows: u16, cols: u16, cwd: Option<PathBuf>, + host_terminal_theme: crate::terminal_theme::TerminalTheme, ) -> std::io::Result<PaneId> { let new_id = self .active_tab_mut() .expect("workspace must always have at least one tab") - .split_focused(direction, rows, cols, cwd)?; + .split_focused(direction, rows, cols, cwd, host_terminal_theme)?; self.register_new_pane(new_id); Ok(new_id) } |
