mirror of
https://github.com/l0ng-ai/tty7.git
synced 2026-09-22 00:02:23 +00:00
fix(windows): advertise terminal background to TUI apps (#332)
* fix(windows): advertise terminal background to TUI apps * refactor(windows): keep the background hint out of config.json The daemon needs to know whether the window is light or dark when it spawns a Windows pane, because ConPTY drops the child's OSC 11 query before tty7's emulator can answer it. It was reading that from `Config::theme` — a field nothing had written since it went dead — which meant the GUI had to rewrite the user's `config.json` every time the effective preset changed sides. Move the hint to `appearance.json`, beside `machine.json` in the data dir, and leave `Config::theme` exactly as it was. It is derived state: written by the process that paints the window, read by the process that has to describe it, and of no interest to the user. A file of its own rather than a field on `Machine`, because the machine tree is owned by the daemon and flushed on a timer, so a second writer would clobber the workspaces and panes it had not seen. Absent, unreadable, and unparsable all read as light — what the default preset is — so a daemon that starts before the GUI has ever applied a theme describes the default window instead of guessing. Also silence the `unused variable` warning the hint parameter raised on every non-Windows build, where the `COLORFGBG` block it feeds is compiled out. Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
l0ng-ai
parent
0f3d176e98
commit
5d14603722
@@ -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<PathBuf> {
|
||||
Ok(data_dir()?.join(MACHINE_FILE))
|
||||
}
|
||||
|
||||
pub fn appearance_path() -> io::Result<PathBuf> {
|
||||
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::<Appearance>(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<PathBuf> {
|
||||
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,
|
||||
|
||||
@@ -311,6 +311,8 @@ fn names_capability_env(key: &str) -> bool {
|
||||
|
||||
fn pane_environment(
|
||||
extra_env: &std::collections::HashMap<String, String>,
|
||||
// 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"),
|
||||
|
||||
@@ -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::<Config>();
|
||||
let mode = if theme.dark {
|
||||
ThemeMode::Dark
|
||||
|
||||
Reference in New Issue
Block a user