aboutsummaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
authorCan Celik <ogulcancelik@gmail.com>2026-04-05 03:46:16 +0300
committerCan Celik <ogulcancelik@gmail.com>2026-04-05 03:46:16 +0300
commit8525503dba9029d0a5f24990e026d8164f0bf39c (patch)
tree43b902dfab49603c667ae016baa97aee27724073 /src
parenta08dfaef2f13873fc85dc9e1357235b8b0703a46 (diff)
fix: match ghostty pane default colors to host terminal theme
Diffstat (limited to 'src')
-rw-r--r--src/app/input.rs14
-rw-r--r--src/app/mod.rs42
-rw-r--r--src/app/state.rs4
-rw-r--r--src/ghostty/mod.rs27
-rw-r--r--src/input/mod.rs4
-rw-r--r--src/main.rs1
-rw-r--r--src/pane.rs175
-rw-r--r--src/persist.rs1
-rw-r--r--src/raw_input.rs65
-rw-r--r--src/terminal_theme.rs143
-rw-r--r--src/workspace.rs28
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)
}