From 04878af279330a9a4d03f83223dbe73ab8d81cd3 Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Mon, 14 Sep 2026 21:40:04 +0800 Subject: [PATCH 1/3] fix(terminal): hold one steady bell flash through a burst of bells Every visual bell armed its own 150 ms clear timer, so when bells arrived faster than that - holding Backspace on an empty bash prompt, or Tab with nothing to complete, rings at key-repeat rate - an older bell's timer blanked the flash a newer bell had just lit, and the pane strobed for as long as the key was held. Only the timer armed by the latest bell clears the flash now. Fixes #874 Claude-Session: https://claude.ai/code/session_01JRqYZ9E153WpSHGS2AW3BM --- src/terminal/view.rs | 46 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 46 insertions(+) diff --git a/src/terminal/view.rs b/src/terminal/view.rs index 3d77f506..a9996eed 100644 --- a/src/terminal/view.rs +++ b/src/terminal/view.rs @@ -381,6 +381,9 @@ pub struct TerminalView { /// time, however fast the pane is printing. pub(super) search_scan_armed: bool, pub bell_flash: bool, + /// Bumped by every bell, so only the timer armed by the latest one clears + /// the flash: a burst of bells holds one steady flash instead of strobing. + bell_epoch: u64, pub report_mouse: bool, last_at_prompt: bool, last_typeahead_blocked: bool, @@ -1577,6 +1580,7 @@ impl TerminalView { search_scan_epoch: 0, search_scan_armed: false, bell_flash: false, + bell_epoch: 0, last_at_prompt: false, last_typeahead_blocked: false, running_since: None, @@ -2980,6 +2984,8 @@ impl TerminalView { } fn flash_bell(&mut self, cx: &mut Context) { + self.bell_epoch += 1; + let epoch = self.bell_epoch; self.bell_flash = true; cx.notify(); cx.spawn(async move |this, cx| { @@ -2987,6 +2993,12 @@ impl TerminalView { .timer(std::time::Duration::from_millis(150)) .await; let _ = this.update(cx, |view, cx| { + // A bell rung since this one owns the flash now. Holding + // Backspace on an empty bash prompt rings at key-repeat rate, + // and clearing here would blank it every few frames (#874). + if view.bell_epoch != epoch { + return; + } view.bell_flash = false; cx.notify(); }); @@ -14310,6 +14322,40 @@ mod gpui_tests { .unwrap(); } + /// Holding Backspace on an empty bash prompt (or Tab with nothing to + /// complete) rings the bell at key-repeat rate. Every flash used to arm its + /// own clear timer, so the first bell's timer blanked a flash the fifth bell + /// had just re-lit, and the pane strobed for as long as the key was held + /// (#874). + #[gpui::test] + fn a_bell_rung_at_key_repeat_rate_holds_one_steady_flash(cx: &mut TestAppContext) { + let (window, _daemon) = harness(cx); + let lit = + |cx: &mut TestAppContext| window.update(cx, |view, _, _| view.bell_flash).unwrap(); + + let repeat = std::time::Duration::from_millis(33); + let mut dark = Vec::new(); + for i in 0..30 { + window + .update(cx, |view, _, cx| view.handle_event(AlacEvent::Bell, cx)) + .unwrap(); + cx.executor().advance_clock(repeat); + cx.run_until_parked(); + if !lit(cx) { + dark.push(i); + } + } + assert!( + dark.is_empty(), + "the flash went dark between bells after repeats {dark:?}" + ); + + cx.executor() + .advance_clock(std::time::Duration::from_millis(300)); + cx.run_until_parked(); + assert!(!lit(cx), "the flash outlived the last bell"); + } + #[gpui::test] fn text_area_size_request_replies_with_the_current_geometry(cx: &mut TestAppContext) { let (window, mut daemon) = harness(cx); From ef55505a6ad3c9e6d68b2ea89b76323bde60ef95 Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Mon, 14 Sep 2026 21:45:15 +0800 Subject: [PATCH 2/3] fix(agents): don't re-badge a turn the reader already saw when a pane is rebuilt Switching workspaces or reopening a window from the tray throws a pane's TerminalView away and builds a new one over the same daemon pane. The new view starts with no last status, so an agent that was already Done arrives as None -> Done, which poll_agent_status cannot tell from a turn finishing live, and every unfocused rebuilt pane got its unread badge back. The daemon now counts finished turns per agent session (turns, bumped on entering Done), and views leave an app-lifetime mark per (host, pane) of the session, turn count and badge the reader was last shown. A rebuilt view's first sight of Done takes the badge back from a matching mark instead of raising a new one; a turn that finished while the view was gone has no mark or a lower count, and still badges. Fixes #870 Claude-Session: https://claude.ai/code/session_01JRqYZ9E153WpSHGS2AW3BM --- crates/tty7-core/src/core/cli_agent.rs | 39 ++++ crates/tty7-core/src/daemon/control.rs | 1 + crates/tty7-core/src/daemon/pane.rs | 1 + crates/tty7-core/src/daemon/protocol.rs | 1 + src/terminal/remote.rs | 1 + src/terminal/view.rs | 267 +++++++++++++++++++++++- src/ui/app.rs | 2 +- 7 files changed, 303 insertions(+), 9 deletions(-) diff --git a/crates/tty7-core/src/core/cli_agent.rs b/crates/tty7-core/src/core/cli_agent.rs index 952831e8..c4340f67 100644 --- a/crates/tty7-core/src/core/cli_agent.rs +++ b/crates/tty7-core/src/core/cli_agent.rs @@ -714,6 +714,12 @@ pub struct AgentSessionState { pub cwd: Option, #[serde(default)] pub activity: u64, + /// How many turns this session has finished: bumped each time it settles + /// into `Done`. The status alone cannot tell a client that attaches to a + /// `Done` pane whether that is the turn it already showed the reader or a + /// later one that finished while nobody was watching (#870). + #[serde(default)] + pub turns: u64, } impl AgentStatus { @@ -767,6 +773,9 @@ impl AgentSessionState { } } AgentEventKind::Stop => { + if self.status != AgentStatus::Done { + self.turns = self.turns.wrapping_add(1); + } self.status = AgentStatus::Done; self.message = ev.message.clone(); } @@ -1273,6 +1282,36 @@ mod tests { assert_eq!(s.activity, 4); } + #[test] + fn each_finished_turn_is_counted_once() { + let ev = |kind| AgentEvent { + agent: Some(CLIAgent::Claude), + kind, + session_id: None, + message: None, + cwd: None, + prompt: None, + }; + + let mut s = AgentSessionState::default(); + s.apply_event(&ev(AgentEventKind::PromptSubmit)); + assert_eq!(s.turns, 0, "a turn starting has not finished anything"); + + s.apply_event(&ev(AgentEventKind::Stop)); + assert_eq!(s.turns, 1); + s.apply_event(&ev(AgentEventKind::Stop)); + assert_eq!(s.turns, 1, "a repeated stop is the same turn"); + s.apply_event(&ev(AgentEventKind::Notification)); + assert_eq!(s.turns, 1); + + s.apply_event(&ev(AgentEventKind::PromptSubmit)); + s.apply_event(&ev(AgentEventKind::Stop)); + assert_eq!(s.turns, 2, "a second turn is a second count"); + + s.apply_event(&ev(AgentEventKind::SessionEnd)); + assert_eq!(s.turns, 2); + } + #[test] fn session_state_tracks_and_releases_the_agent_cwd() { use std::path::PathBuf; diff --git a/crates/tty7-core/src/daemon/control.rs b/crates/tty7-core/src/daemon/control.rs index d8b01dd4..dc47d25c 100644 --- a/crates/tty7-core/src/daemon/control.rs +++ b/crates/tty7-core/src/daemon/control.rs @@ -1607,6 +1607,7 @@ mod tests { rich: true, cwd: Some("/work/api".into()), activity: 3, + turns: 1, }, }])), ControlReply::Ok(ReplyOk::AgentStates(Vec::new())), diff --git a/crates/tty7-core/src/daemon/pane.rs b/crates/tty7-core/src/daemon/pane.rs index 29a0e542..7b6add69 100644 --- a/crates/tty7-core/src/daemon/pane.rs +++ b/crates/tty7-core/src/daemon/pane.rs @@ -5125,6 +5125,7 @@ mod tests { rich: true, cwd: None, activity: 0, + turns: 0, }); apply_signals(&mut st, sniffer.feed(b"\x1b]9;noise\x07")); assert_eq!( diff --git a/crates/tty7-core/src/daemon/protocol.rs b/crates/tty7-core/src/daemon/protocol.rs index 8bdad16f..0538c114 100644 --- a/crates/tty7-core/src/daemon/protocol.rs +++ b/crates/tty7-core/src/daemon/protocol.rs @@ -1840,6 +1840,7 @@ mod tests { rich: true, cwd: Some("/repo/.claude/worktrees/fix-x".into()), activity: 12, + turns: 4, })), DaemonMsg::AgentStatus(None), DaemonMsg::LoopbackForward(LoopbackForward { local_port: 49152 }), diff --git a/src/terminal/remote.rs b/src/terminal/remote.rs index be4cee3e..4e561763 100644 --- a/src/terminal/remote.rs +++ b/src/terminal/remote.rs @@ -5630,6 +5630,7 @@ mod tests { rich: true, cwd: None, activity: 0, + turns: 0, })) .encode(&mut daemon_side) .unwrap(); diff --git a/src/terminal/view.rs b/src/terminal/view.rs index 3d77f506..93d27c88 100644 --- a/src/terminal/view.rs +++ b/src/terminal/view.rs @@ -191,6 +191,26 @@ pub struct ShellParts { pub(crate) owner: Option, } +/// What the reader was last shown of each pane's finished agent turn, kept for +/// the life of the app rather than of a view. +/// +/// A view is thrown away and built again over the same daemon pane whenever a +/// workspace is switched out and back or a window is reopened from the tray, +/// and a fresh view sees a `Done` agent arrive from nothing — exactly what a +/// turn finishing live looks like. This is how the new view tells the two apart +/// (#870). +#[derive(Default)] +struct AgentReadMarks(std::collections::HashMap<(crate::ui::host_ops::HostId, u64), AgentReadMark>); + +impl gpui::Global for AgentReadMarks {} + +#[derive(Clone)] +struct AgentReadMark { + session: (Option, Option>), + turns: u64, + unread: bool, +} + #[derive(Clone, Copy)] struct DragScroll { overshoot: f32, @@ -393,6 +413,9 @@ pub struct TerminalView { agent_was_rich: bool, agent_result_unread: bool, keep_unread_on_focus: bool, + /// Whether this view has seen its pane's agent status move at all. The + /// first move is where a rebuilt view consults [`AgentReadMarks`]. + agent_status_seen: bool, git_status_cwd: Option, last_agent_activity: u64, cmd: CmdEditor, @@ -1428,6 +1451,7 @@ impl TerminalView { view.keep_unread_on_focus = false; } else { view.agent_result_unread = false; + view.note_agent_result_unread(cx); } view.report_focus_change(true); cx.notify(); @@ -1588,6 +1612,7 @@ impl TerminalView { agent_was_rich: false, agent_result_unread: false, keep_unread_on_focus: false, + agent_status_seen: false, git_status_cwd: None, last_agent_activity: 0, cmd: CmdEditor::new(), @@ -1867,9 +1892,41 @@ impl TerminalView { self.agent_result_unread } - pub fn mark_agent_result_unread(&mut self, refocus_incoming: bool) { + pub fn mark_agent_result_unread(&mut self, refocus_incoming: bool, cx: &mut App) { self.agent_result_unread = true; self.keep_unread_on_focus = refocus_incoming; + self.note_agent_result_unread(cx); + } + + /// Leave what the reader has seen of this pane's agent where the pane's + /// next view will look for it — see [`AgentReadMarks`]. Anything but a + /// finished turn drops the mark: whatever finishes next is news. + fn record_agent_read_mark(&self, turns: u64, cx: &mut App) { + let key = (self.host_id, self.pane_id); + let marks = &mut cx.default_global::().0; + if self.last_agent_status == Some(crate::core::cli_agent::AgentStatus::Done) { + marks.insert( + key, + AgentReadMark { + session: self.last_agent_session.clone(), + turns, + unread: self.agent_result_unread, + }, + ); + } else { + marks.remove(&key); + } + } + + /// Carry a change to the badge alone into the mark the last status left. + fn note_agent_result_unread(&self, cx: &mut App) { + if !cx.has_global::() { + return; + } + let key = (self.host_id, self.pane_id); + if let Some(mark) = cx.global_mut::().0.get_mut(&key) { + mark.unread = self.agent_result_unread; + } } pub fn git_status(&self, cx: &App) -> Option { @@ -3909,6 +3966,30 @@ impl TerminalView { return false; } let prev = std::mem::replace(&mut self.last_agent_status, status); + let first_sight = !std::mem::replace(&mut self.agent_status_seen, true); + let turns = session.as_ref().map_or(0, |s| s.turns); + + // A view built over a pane that already holds a finished turn sees + // `Done` arrive from nothing, the same as a turn finishing now. If the + // pane's previous view left a mark for this session at this turn count, + // it is the turn the reader was already shown: take their badge back as + // they left it instead of raising a new one (#870). A turn that + // finished after the old view went has no such mark, or a lower count. + if first_sight + && status == Some(AgentStatus::Done) + && let Some(mark) = cx + .try_global::() + .and_then(|marks| marks.0.get(&(self.host_id, self.pane_id))) + .filter(|mark| mark.session == self.last_agent_session && mark.turns == turns) + .cloned() + { + self.agent_result_unread = mark.unread && !self.focus_handle.is_focused(window); + self.keep_unread_on_focus = false; + self.record_agent_read_mark(turns, cx); + cx.notify(); + return false; + } + let turn_finished = status == Some(AgentStatus::Done) && prev != Some(AgentStatus::Done); match status { @@ -3928,6 +4009,7 @@ impl TerminalView { self.keep_unread_on_focus = false; } } + self.record_agent_read_mark(turns, cx); let rich = session.as_ref().is_some_and(|s| s.rich); let agent_name = self @@ -9870,6 +9952,7 @@ mod gpui_tests { rich: true, cwd: None, activity: 0, + turns: 0, })) .encode(daemon) .unwrap(); @@ -9925,6 +10008,7 @@ mod gpui_tests { rich: true, cwd: None, activity: 0, + turns: 0, })) .encode(&mut daemon) .unwrap(); @@ -9969,10 +10053,21 @@ mod gpui_tests { pane: &gpui::Entity, cx: &mut TestAppContext, daemon: &mut Stream, + ) { + report_agent_turn(status, 0, pane, cx, daemon); + } + + /// The same, for a session that has finished `turns` turns so far. + fn report_agent_turn( + status: crate::core::cli_agent::AgentStatus, + turns: u64, + pane: &gpui::Entity, + cx: &mut TestAppContext, + daemon: &mut Stream, ) { use crate::core::cli_agent::AgentSessionState; - DaemonMsg::AgentStatus(Some(AgentSessionState { + let state = AgentSessionState { status, message: None, session_id: Some("sid-abc".into()), @@ -9980,13 +10075,13 @@ mod gpui_tests { rich: true, cwd: None, activity: 0, - })) - .encode(daemon) - .unwrap(); + turns, + }; + DaemonMsg::AgentStatus(Some(state.clone())) + .encode(daemon) + .unwrap(); for _ in 0..200 { - if cx.update(|cx| pane.read(cx).terminal.agent_session().map(|s| s.status)) - == Some(status) - { + if cx.update(|cx| pane.read(cx).terminal.agent_session()) == Some(state.clone()) { return; } std::thread::sleep(std::time::Duration::from_millis(5)); @@ -9994,6 +10089,161 @@ mod gpui_tests { panic!("the agent status never reached the pane"); } + /// Poll `pane`'s agent status inside `window` and read its badge back. + fn poll_unread( + window: gpui::WindowHandle, + pane: &gpui::Entity, + cx: &mut TestAppContext, + ) -> bool { + // Through the untyped handle: the typed one leases the root view, and + // `pane` may be that view. + cx.update_window(window.into(), |_, window, cx| { + pane.update(cx, |pane, cx| { + pane.poll_agent_status(false, window, cx); + pane.agent_result_unread() + }) + }) + .unwrap() + } + + /// Switching workspaces, or reopening a window from the tray, throws the + /// pane's view away and builds a new one on the same daemon pane (#870). + /// The new view's first look at a `Done` agent is not a turn finishing — + /// the reader watched that one finish before the old view went. + #[gpui::test] + fn a_rebuilt_pane_does_not_re_badge_a_turn_the_reader_already_saw(cx: &mut TestAppContext) { + use crate::core::cli_agent::AgentStatus; + + let (window, mut before_daemon) = harness(cx); + let before = window.update(cx, |_, _, cx| cx.entity()).unwrap(); + window + .update(cx, |view, window, cx| { + view.focus_handle.clone().focus(window, cx) + }) + .unwrap(); + cx.run_until_parked(); + report_agent_turn(AgentStatus::Done, 1, &before, cx, &mut before_daemon); + assert!( + !poll_unread(window, &before, cx), + "the reader watched it finish" + ); + + // The same daemon pane, rebuilt the way `tabs_from_session` rebuilds it, + // with the reader's focus somewhere else. + let (after, mut daemon) = window + .update(cx, |_, window, cx| super::quiet_test_pane(1, window, cx)) + .unwrap(); + window + .update(cx, |view, window, cx| { + view.focus_handle.clone().focus(window, cx) + }) + .unwrap(); + cx.run_until_parked(); + report_agent_turn(AgentStatus::Done, 1, &after, cx, &mut daemon); + assert!( + !poll_unread(window, &after, cx), + "rebuilding the pane is not a turn finishing" + ); + } + + /// What the rebuild must not swallow: a turn that was still running when + /// the view went away and finished before the new one arrived. + #[gpui::test] + fn a_turn_that_finished_while_the_pane_was_away_still_badges(cx: &mut TestAppContext) { + use crate::core::cli_agent::AgentStatus; + + let (window, mut before_daemon) = harness(cx); + let before = window.update(cx, |_, _, cx| cx.entity()).unwrap(); + window + .update(cx, |view, window, cx| { + view.focus_handle.clone().focus(window, cx) + }) + .unwrap(); + cx.run_until_parked(); + report_agent_turn(AgentStatus::Working, 0, &before, cx, &mut before_daemon); + assert!(!poll_unread(window, &before, cx)); + + let (after, mut daemon) = window + .update(cx, |_, window, cx| super::quiet_test_pane(1, window, cx)) + .unwrap(); + window + .update(cx, |view, window, cx| { + view.focus_handle.clone().focus(window, cx) + }) + .unwrap(); + cx.run_until_parked(); + report_agent_turn(AgentStatus::Done, 1, &after, cx, &mut daemon); + assert!( + poll_unread(window, &after, cx), + "nobody saw this turn finish" + ); + } + + /// Nor a whole later turn: the reader saw turn one, the agent was sent + /// another and finished it while the pane was away. The status reads + /// `Done` both times; only the turn count tells them apart. + #[gpui::test] + fn a_later_turn_that_finished_while_the_pane_was_away_still_badges(cx: &mut TestAppContext) { + use crate::core::cli_agent::AgentStatus; + + let (window, mut before_daemon) = harness(cx); + let before = window.update(cx, |_, _, cx| cx.entity()).unwrap(); + window + .update(cx, |view, window, cx| { + view.focus_handle.clone().focus(window, cx) + }) + .unwrap(); + cx.run_until_parked(); + report_agent_turn(AgentStatus::Done, 1, &before, cx, &mut before_daemon); + assert!(!poll_unread(window, &before, cx)); + + let (after, mut daemon) = window + .update(cx, |_, window, cx| super::quiet_test_pane(1, window, cx)) + .unwrap(); + window + .update(cx, |view, window, cx| { + view.focus_handle.clone().focus(window, cx) + }) + .unwrap(); + cx.run_until_parked(); + report_agent_turn(AgentStatus::Done, 2, &after, cx, &mut daemon); + assert!( + poll_unread(window, &after, cx), + "the second turn finished unseen" + ); + } + + /// And a badge the reader had not cleared yet comes back with the pane. + #[gpui::test] + fn an_unread_turn_is_still_unread_after_the_pane_is_rebuilt(cx: &mut TestAppContext) { + use crate::core::cli_agent::AgentStatus; + + let (window, mut before_daemon) = harness(cx); + let before = window.update(cx, |_, _, cx| cx.entity()).unwrap(); + // Building another pane takes the window's focus off `before`. + let (_elsewhere, _elsewhere_daemon) = window + .update(cx, |_, window, cx| super::quiet_test_pane(5, window, cx)) + .unwrap(); + cx.run_until_parked(); + report_agent_turn(AgentStatus::Done, 1, &before, cx, &mut before_daemon); + assert!(poll_unread(window, &before, cx), "nobody was looking"); + + let (after, mut daemon) = window + .update(cx, |_, window, cx| super::quiet_test_pane(1, window, cx)) + .unwrap(); + window + .update(cx, |view, window, cx| { + view.focus_handle.clone().focus(window, cx) + }) + .unwrap(); + cx.run_until_parked(); + report_agent_turn(AgentStatus::Done, 1, &after, cx, &mut daemon); + assert!( + poll_unread(window, &after, cx), + "rebuilding the pane is not reading it" + ); + } + /// The badge answers "did the reader see this?", so it has to read the /// window's live focus rather than anything a focus callback left behind. /// @@ -10091,6 +10341,7 @@ mod gpui_tests { rich: true, cwd: Some(working_in.clone()), activity: 0, + turns: 0, })) .encode(&mut daemon) .unwrap(); diff --git a/src/ui/app.rs b/src/ui/app.rs index 54d5a7eb..254ec1f3 100644 --- a/src/ui/app.rs +++ b/src/ui/app.rs @@ -4834,7 +4834,7 @@ impl Tty7App { refocus.as_ref().map(|s| s.entity_id()) == Some(leaf.entity_id()); leaf.update(cx, |view, cx| { if view.agent_session().map(|s| s.status) == Some(AgentStatus::Done) { - view.mark_agent_result_unread(refocus_incoming); + view.mark_agent_result_unread(refocus_incoming, cx); cx.notify(); } }); From 50957f312403302e84a651dfb5f3e5055d2711bd Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Mon, 14 Sep 2026 21:49:08 +0800 Subject: [PATCH 3/3] Keep SGR 2 text legible on light themes Faint text was painted as its ink at 66% alpha over the cell. On a light background that fixed fade collapses the WCAG ratio: Catppuccin Latte's foreground fell from 7.06:1 to 3.18:1, Rose Pine Dawn's to 3.08:1, and every bright-black the palette rescue had lifted to 4.5:1 fell back to ~2.5:1 on all four light builtins (#858). On a light cell the fade is now walked back toward the ink until it clears the 4.5:1 text floor, capped at the ink's own ratio. Dark cells, and the legible-palette switch turned off, keep the plain fade byte-for-byte. Fixes #858 Claude-Session: https://claude.ai/code/session_01JRqYZ9E153WpSHGS2AW3BM --- src/terminal/element.rs | 207 +++++++++++++++++++++++++++++++++++++++- src/ui/presets.rs | 31 +++++- 2 files changed, 236 insertions(+), 2 deletions(-) diff --git a/src/terminal/element.rs b/src/terminal/element.rs index c1a5b372..2d05b999 100644 --- a/src/terminal/element.rs +++ b/src/terminal/element.rs @@ -249,11 +249,46 @@ fn snapshot_cell( rc.selected = true; } if flags.contains(Flags::DIM) { - rc.fg.a *= DIM_OPACITY; + rc.fg = dim_fg(rc.fg, bgc, colors.legible_dim); } rc } +/// SGR 2's colour: the ink at `DIM_OPACITY` over its cell, or — on a light +/// cell where that fade would be illegible — the opaque ink +/// `presets::legible_dim` solved for. `legible` is Settings → Appearance's +/// palette switch; off keeps the plain fade everywhere. +fn dim_fg(fg: Hsla, under: Rgb, legible: bool) -> Hsla { + let mut faded = fg; + faded.a *= DIM_OPACITY; + if !legible { + return faded; + } + // A faint run is one (ink, background) pair repeated across the row, and + // the rescue is a contrast bisection — remember the last answer so a + // screen of dim text costs one solve per colour change, not one per cell. + thread_local! { + static LAST: std::cell::Cell)>> = + const { std::cell::Cell::new(None) }; + } + let (ink, bg) = (pack_rgb(super::palette::hsla_to_rgb(fg)), pack_rgb(under)); + let solved = LAST.with(|last| match last.get() { + Some((i, b, s)) if i == ink && b == bg => s, + _ => { + let s = crate::ui::presets::legible_dim(ink, bg, DIM_OPACITY); + last.set(Some((ink, bg, s))); + s + } + }); + match solved { + Some(c) => Hsla { + a: fg.a, + ..to_hsla(unpack_rgb(c)) + }, + None => faded, + } +} + fn active_selection_bg(cx: &gpui::App) -> Rgb { match cx.try_global::() { Some(a) => a.sel_bg, @@ -305,6 +340,9 @@ pub(super) struct PaintColors { current_match_bg: Hsla, fg_rgb: Rgb, bg_rgb: Rgb, + /// Whether faint text on a light cell is held at the text floor (see + /// `dim_fg`). Mirrors `theme_legible_palette`. + legible_dim: bool, } /// The under-colour a dimmed pane blends its content toward: the window @@ -444,6 +482,9 @@ impl PaintColors { current_match_bg, fg_rgb, bg_rgb, + legible_dim: cx + .try_global::() + .is_none_or(|c| c.theme_legible_palette), } } @@ -466,6 +507,7 @@ impl PaintColors { current_match_bg: blend_toward(self.current_match_bg, dim, under), fg_rgb: self.fg_rgb, bg_rgb: self.bg_rgb, + legible_dim: self.legible_dim, } } } @@ -2412,6 +2454,7 @@ mod tests { selection_bg: Hsla::default(), match_bg: Hsla::default(), current_match_bg: Hsla::default(), + legible_dim: true, fg_rgb: Rgb { r: 17, g: 17, @@ -3600,6 +3643,7 @@ mod tests { current_match_bg: wash(0.85), fg_rgb: fg, bg_rgb: bg, + legible_dim: true, } } @@ -3722,6 +3766,167 @@ mod tests { ); } + /// What a cell's foreground lands on screen as: its rgb composited over + /// the cell background by its alpha, the way the glyph is actually drawn. + fn on_screen(fg: Hsla, under: Rgb) -> u32 { + let c = Rgba::from(fg); + let ch = |v: f32, u: u8| ((v * c.a + (u as f32 / 255.) * (1. - c.a)) * 255.).round() as u32; + ch(c.r, under.r) << 16 | ch(c.g, under.g) << 8 | ch(c.b, under.b) + } + + /// The colours a pane on builtin `t` resolves cells with. + fn builtin_colors(t: &crate::ui::presets::Theme) -> (PaintColors, [Rgb; 256]) { + let (fg, bg) = (unpack_rgb(t.foreground), unpack_rgb(t.background_color())); + let mut colors = test_colors(); + colors.default_fg = to_hsla(fg); + colors.default_bg = to_hsla(bg); + colors.fg_rgb = fg; + colors.bg_rgb = bg; + let mut palette = super::super::palette::build(); + palette[..16].copy_from_slice(&t.active_palette(true).ansi16); + (colors, palette) + } + + /// Every colour faint text is commonly written in: the default foreground, + /// the sixteen palette slots, the 256-colour greys apps reach for as + /// "muted", and a truecolour grey. + fn faint_samples() -> Vec<(String, AnsiColor)> { + let mut v = vec![("fg".to_string(), AnsiColor::Named(NamedColor::Foreground))]; + for i in 0..16u8 { + v.push((format!("ansi{i}"), AnsiColor::Indexed(i))); + } + for i in [240u8, 244, 248, 250] { + v.push((format!("256:{i}"), AnsiColor::Indexed(i))); + } + v.push(( + "#999999".to_string(), + AnsiColor::Spec(Rgb { + r: 153, + g: 153, + b: 153, + }), + )); + v + } + + /// (plain, faint) on-screen colours of `color` as a pane on `t` paints them. + fn plain_and_faint( + colors: &PaintColors, + palette: &[Rgb; 256], + color: AnsiColor, + ) -> (RenderCell, RenderCell) { + let point = AlacPoint::new(AlacLine(0), AlacColumn(0)); + let mut cell = Cell { + c: 'x', + fg: color, + ..Cell::default() + }; + let plain = snapshot_cell(&cell, point, palette, colors, None); + cell.flags = Flags::DIM; + let faint = snapshot_cell(&cell, point, palette, colors, None); + (plain, faint) + } + + #[test] + fn faint_text_keeps_the_text_floor_on_every_light_builtin() { + // #858: SGR 2 painted the ink at 66% over the cell, which on a light + // background took Catppuccin Latte's foreground to 3.2:1 and every + // bright-black the palette rescue had lifted to 4.5:1 back to ~2.5:1. + // Faint text on a light cell must now clear 4.5:1 — or, for an ink + // that never cleared it undimmed, stay at the ink's own ratio. + use crate::ui::presets::{builtins, contrast}; + let mut failures = Vec::new(); + eprintln!("| theme | colour | plain | faint |"); + for t in builtins().into_iter().filter(|t| !t.dark) { + let (colors, palette) = builtin_colors(&t); + let bg = t.background_color(); + for (name, color) in faint_samples() { + let (plain, faint) = plain_and_faint(&colors, &palette, color); + let plain = contrast(on_screen(plain.fg, colors.bg_rgb), bg); + let faint = contrast(on_screen(faint.fg, colors.bg_rgb), bg); + eprintln!("| {} | {name} | {plain:.2} | {faint:.2} |", t.id); + if faint < 4.5_f32.min(plain) - 0.05 { + failures.push(format!( + "{}/{name}: faint {faint:.2}:1 (plain {plain:.2}:1)", + t.id + )); + } + } + } + assert!( + failures.is_empty(), + "faint text under the floor:\n{}", + failures.join("\n") + ); + } + + #[test] + fn faint_text_on_dark_builtins_is_the_plain_fade() { + // The floor is a light-background rescue: every dark builtin must + // keep painting SGR 2 exactly as it did, as the ink at DIM_OPACITY. + for t in crate::ui::presets::builtins() + .into_iter() + .filter(|t| t.dark) + { + let (colors, palette) = builtin_colors(&t); + for (name, color) in faint_samples() { + let (plain, faint) = plain_and_faint(&colors, &palette, color); + let mut fade = plain.fg; + fade.a *= DIM_OPACITY; + assert_eq!( + faint.fg, fade, + "{}/{name}: dark theme faint text changed", + t.id + ); + } + } + } + + #[test] + fn faint_text_stays_fainter_than_plain_text() { + // The rescue buys legibility, not a restyle: faint text never gains + // contrast over its own ink, and an ink with room to spare above the + // floor still reads visibly fainter. + use crate::ui::presets::{builtins, contrast}; + for t in builtins() { + let (colors, palette) = builtin_colors(&t); + let bg = t.background_color(); + for (name, color) in faint_samples() { + let (plain, faint) = plain_and_faint(&colors, &palette, color); + let plain = contrast(on_screen(plain.fg, colors.bg_rgb), bg); + let faint = contrast(on_screen(faint.fg, colors.bg_rgb), bg); + assert!( + faint <= plain + 0.01, + "{}/{name}: faint {faint:.2} > plain {plain:.2}", + t.id + ); + if plain >= 6.0 { + assert!( + faint <= plain * 0.85, + "{}/{name}: faint {faint:.2} no longer reads fainter than {plain:.2}", + t.id + ); + } + } + } + } + + #[test] + fn faint_text_is_the_plain_fade_with_the_legibility_switch_off() { + // Settings → Appearance's palette switch off renders colours as + // authored; that includes faint text. + for t in crate::ui::presets::builtins() { + let (mut colors, palette) = builtin_colors(&t); + colors.legible_dim = false; + for (name, color) in faint_samples() { + let (plain, faint) = plain_and_faint(&colors, &palette, color); + let mut fade = plain.fg; + fade.a *= DIM_OPACITY; + assert_eq!(faint.fg, fade, "{}/{name}: switch off still rescued", t.id); + } + } + } + #[test] fn blend_toward_mixes_in_rgb_space_and_keeps_alpha() { let under = Rgba { diff --git a/src/ui/presets.rs b/src/ui/presets.rs index f2f0a72f..2da3e61d 100644 --- a/src/ui/presets.rs +++ b/src/ui/presets.rs @@ -523,12 +523,41 @@ fn channel_distance(a: u32, b: u32) -> u32 { d(16).max(d(8)).max(d(0)) } -fn contrast(a: u32, b: u32) -> f32 { +pub(crate) fn contrast(a: u32, b: u32) -> f32 { let (l1, l2) = (relative_luminance(a), relative_luminance(b)); let (hi, lo) = if l1 >= l2 { (l1, l2) } else { (l2, l1) }; (hi + 0.05) / (lo + 0.05) } +/// The opaque ink SGR 2 (faint) text is painted in on a light cell, when the +/// plain fade would drop it under the text floor — `None` when the fade is +/// already legible and should stay a fade. +/// +/// Faint text is `opacity` of its ink over the cell. That costs a fixed share +/// of the ink's luminance distance, and on a light background the ratio that +/// distance buys collapses fast: Catppuccin Latte's own foreground fades from +/// 7.1:1 to 3.2:1, and every bright-black the palette rescue lifted to 4.5:1 +/// fades back to 2.5:1 — the "illegible secondary text" of #858. So the fade +/// is walked back toward the ink until it clears `TEXT_FLOOR` again, capped at +/// the ink's own ratio: text that was never above the floor is left as dim as +/// it would have been undimmed, not darkened past what the app asked for. +/// +/// Light backgrounds only. Dark themes read the same faint text at the same +/// ratios without complaint, and keeping them byte-for-byte as they were is +/// worth more than a symmetric rule nobody asked for. The test is the cell's +/// own background, so a light cell inside a dark theme is rescued too. +pub(crate) fn legible_dim(ink: u32, bg: u32, opacity: f32) -> Option { + if is_dark(bg) { + return None; + } + let faded = mix(bg, ink, opacity); + let floor = TEXT_FLOOR.min(contrast(ink, bg)); + if contrast(faded, bg) >= floor { + return None; + } + Some(bisect_contrast(faded, ink, bg, floor)) +} + fn is_dark(bg: u32) -> bool { relative_luminance(bg) < 0.5 }