diff --git a/crates/tty7-core/src/core/machine.rs b/crates/tty7-core/src/core/machine.rs index f5244563..f3120f3d 100644 --- a/crates/tty7-core/src/core/machine.rs +++ b/crates/tty7-core/src/core/machine.rs @@ -12,6 +12,8 @@ use crate::daemon::protocol::NativeSshSpec; pub const MACHINE_FILE: &str = "machine.json"; +pub const APPEARANCE_FILE: &str = "appearance.json"; + pub const DATA_DIR_ENV: &str = "TTY7_DATA_DIR"; pub const MAX_WORKSPACES: usize = 1024; @@ -1233,6 +1235,78 @@ pub fn default_machine_path() -> io::Result { Ok(data_dir()?.join(MACHINE_FILE)) } +pub fn appearance_path() -> io::Result { + Ok(data_dir()?.join(APPEARANCE_FILE)) +} + +/// The light/dark mode the GUI last applied, cached beside the machine tree. +/// +/// The daemon needs it when it spawns a pane on Windows, where ConPTY drops an +/// OSC 11 background query before tty7's emulator can answer it (see +/// `daemon::pane::pane_environment`), and the daemon is a separate process from +/// the GUI that owns the theme. This is derived state, not a setting: it is +/// written by whichever process paints the window and read by whoever needs to +/// describe that window, so it lives here rather than in `config.json` — the +/// user's file, which nothing should rewrite behind their back. +/// +/// A file of its own rather than a field on [`Machine`]: the machine tree is +/// owned by the daemon, held in memory and flushed on a timer, so a second +/// writer would clobber the workspaces and panes it had not seen. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct Appearance { + #[serde(default)] + pub dark: bool, +} + +/// Read the cached appearance, or the default when there is none to read. +/// +/// Absent, unreadable, and unparsable all answer `dark: false`, which is what +/// tty7's default preset is: a daemon that spawns a pane before the GUI has +/// ever applied a theme describes the default window rather than guessing. +pub fn appearance() -> Appearance { + match appearance_path() { + Ok(path) => read_appearance(&path), + Err(e) => { + log::debug!("no appearance hint ({e}); assuming the default preset"); + Appearance::default() + } + } +} + +/// Record the appearance the GUI just applied. A no-op when it has not changed, +/// so repainting the same theme does not touch the disk. +pub fn note_appearance(dark: bool) { + let Ok(path) = appearance_path() else { return }; + let next = Appearance { dark }; + if read_appearance(&path) == next && path.exists() { + return; + } + if let Err(e) = write_appearance(&path, next) { + log::warn!("could not write {}: {e}", path.display()); + } +} + +fn read_appearance(path: &Path) -> Appearance { + let Ok(text) = std::fs::read_to_string(path) else { + return Appearance::default(); + }; + serde_json::from_str::(crate::core::config::strip_bom(&text)).unwrap_or_else(|e| { + log::warn!( + "{} does not parse ({e}); assuming the default preset", + path.display() + ); + Appearance::default() + }) +} + +fn write_appearance(path: &Path, appearance: Appearance) -> io::Result<()> { + let bytes = serde_json::to_vec_pretty(&appearance).map_err(io::Error::other)?; + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent)?; + } + crate::core::config::write_atomic_private(path, &bytes) +} + fn data_dir() -> io::Result { if let Some(explicit) = std::env::var_os(DATA_DIR_ENV).filter(|v| !v.is_empty()) { return Ok(PathBuf::from(explicit)); @@ -1273,6 +1347,31 @@ mod tests { (MachineStore::open(dir.path().join(MACHINE_FILE)), dir) } + #[test] + fn the_appearance_hint_round_trips_and_defaults_to_light() { + let dir = tempfile::TempDir::new().unwrap(); + let path = dir.path().join("nested").join(APPEARANCE_FILE); + + assert_eq!( + read_appearance(&path), + Appearance { dark: false }, + "a hint nobody has written yet reads as the default preset" + ); + + write_appearance(&path, Appearance { dark: true }).unwrap(); + assert_eq!(read_appearance(&path), Appearance { dark: true }); + + write_appearance(&path, Appearance { dark: false }).unwrap(); + assert_eq!(read_appearance(&path), Appearance { dark: false }); + + std::fs::write(&path, "not json").unwrap(); + assert_eq!( + read_appearance(&path), + Appearance { dark: false }, + "a corrupt hint must not decide the background either" + ); + } + fn seed(pane: u64, cwd: &str) -> PaneSeed { PaneSeed { pane, diff --git a/crates/tty7-core/src/daemon/pane.rs b/crates/tty7-core/src/daemon/pane.rs index eb6efdb9..979aab6e 100644 --- a/crates/tty7-core/src/daemon/pane.rs +++ b/crates/tty7-core/src/daemon/pane.rs @@ -311,6 +311,8 @@ fn names_capability_env(key: &str) -> bool { fn pane_environment( extra_env: &std::collections::HashMap, + // Only Windows has a use for it — see the `COLORFGBG` block below. + #[cfg_attr(not(windows), allow(unused_variables))] dark: bool, pane: u64, workspace: Option<&str>, ) -> Vec<(String, String)> { @@ -326,6 +328,17 @@ fn pane_environment( ("TERM_PROGRAM_VERSION".to_string(), version.to_string()), (TTY7_PANE_ENV.to_string(), pane.to_string()), ]; + #[cfg(windows)] + if !extra_env + .keys() + .any(|key| key.eq_ignore_ascii_case("COLORFGBG")) + { + // ConPTY consumes OSC 11 queries before tty7's emulator can answer + // them. Give TUI applications the conventional fallback hint while + // preserving an explicit user override below. + let colorfgbg = if dark { "15;0" } else { "0;15" }; + env.push(("COLORFGBG".to_string(), colorfgbg.to_string())); + } if let Some(ws) = workspace { env.push((TTY7_WS_ENV.to_string(), ws.to_string())); } @@ -351,7 +364,8 @@ fn apply_common_command_setup( cmd.cwd(dir); } let extra_env = crate::core::config::extra_env(); - for (k, v) in pane_environment(&extra_env, pane, workspace) { + let dark = crate::core::machine::appearance().dark; + for (k, v) in pane_environment(&extra_env, dark, pane, workspace) { cmd.env(k, v); } @@ -4084,7 +4098,7 @@ mod tests { #[test] fn pane_environment_advertises_the_terminal_under_the_standard_names() { let env: std::collections::HashMap<_, _> = - pane_environment(&std::collections::HashMap::new(), 7, Some("ws-main")) + pane_environment(&std::collections::HashMap::new(), false, 7, Some("ws-main")) .into_iter() .collect(); let version = env!("CARGO_PKG_VERSION"); @@ -4108,10 +4122,14 @@ mod tests { #[test] fn pane_environment_hands_the_shell_its_own_address() { - let env: std::collections::HashMap<_, _> = - pane_environment(&std::collections::HashMap::new(), 42, Some("ws-main")) - .into_iter() - .collect(); + let env: std::collections::HashMap<_, _> = pane_environment( + &std::collections::HashMap::new(), + false, + 42, + Some("ws-main"), + ) + .into_iter() + .collect(); assert_eq!( env.get(TTY7_PANE_ENV).map(String::as_str), @@ -4137,7 +4155,7 @@ mod tests { } let unfiled: std::collections::HashMap<_, _> = - pane_environment(&std::collections::HashMap::new(), 42, None) + pane_environment(&std::collections::HashMap::new(), false, 42, None) .into_iter() .collect(); assert!( @@ -4160,7 +4178,9 @@ mod tests { .collect(); let applied: std::collections::HashMap<_, _> = - pane_environment(&configured, 1, None).into_iter().collect(); + pane_environment(&configured, false, 1, None) + .into_iter() + .collect(); assert_eq!( applied.get("TERM_PROGRAM").map(String::as_str), @@ -4181,6 +4201,37 @@ mod tests { ); } + #[cfg(windows)] + #[test] + fn pane_environment_advertises_light_and_dark_backgrounds() { + let empty = std::collections::HashMap::new(); + let light: std::collections::HashMap<_, _> = pane_environment(&empty, false, 1, None) + .into_iter() + .collect(); + let dark: std::collections::HashMap<_, _> = pane_environment(&empty, true, 1, None) + .into_iter() + .collect(); + + assert_eq!(light.get("COLORFGBG").map(String::as_str), Some("0;15")); + assert_eq!(dark.get("COLORFGBG").map(String::as_str), Some("15;0")); + } + + #[cfg(windows)] + #[test] + fn configured_colorfgbg_wins_case_insensitively() { + let configured = [("ColorFgBg".to_string(), "3;4".to_string())] + .into_iter() + .collect(); + let applied = pane_environment(&configured, false, 1, None); + + assert!(!applied.iter().any(|(key, _)| key == "COLORFGBG")); + assert!( + applied + .iter() + .any(|(key, value)| key == "ColorFgBg" && value == "3;4") + ); + } + #[cfg(windows)] #[test] fn pane_environment_capability_keys_cannot_be_overridden_by_recasing() { @@ -4189,7 +4240,7 @@ mod tests { .map(|(k, v)| ((*k).to_string(), (*v).to_string())) .collect(); - let applied = pane_environment(&configured, 1, None); + let applied = pane_environment(&configured, false, 1, None); assert!( !applied.iter().any(|(k, _)| k == "Term" || k == "ColorTerm"), diff --git a/src/ui/theme.rs b/src/ui/theme.rs index fe2a242a..18bf7c06 100644 --- a/src/ui/theme.rs +++ b/src/ui/theme.rs @@ -243,6 +243,11 @@ pub(crate) fn apply_theme(mut window: Option<&mut Window>, cx: &mut App) { refresh_system_appearance(cx); } let theme = presets::by_id(cx, &effective_preset_id(cx)); + // Cache the mode beside the machine tree: the daemon is a separate process + // and reads it back when it spawns a Windows pane, where ConPTY drops an + // OSC 11 background query before tty7's emulator can answer it. Derived + // state, so it deliberately stays out of the user's `config.json`. + crate::core::machine::note_appearance(theme.dark); let config = cx.global::(); let mode = if theme.dark { ThemeMode::Dark