diff --git a/crates/noa-app/src/app.rs b/crates/noa-app/src/app.rs index a18d069..02f6efc 100644 --- a/crates/noa-app/src/app.rs +++ b/crates/noa-app/src/app.rs @@ -99,10 +99,13 @@ use quick_terminal::QuickTerminalState; use scratch_terminal::ScratchTerminalState; use state::*; +// `window_created_transparent` belongs to this unconditional group, not the +// macOS one below: every window-creation path records what it asked for +// (`WindowState::created_transparent`) on every platform. use config::{ BackgroundImageRuntime, alpha_blending_mode, apply_palette_overrides, effective_theme_name, font_config_from_noa_config, load_background_image_runtime, resolve_cursor_style, - resolve_grid_padding, + resolve_grid_padding, window_created_transparent, }; #[cfg(target_os = "macos")] use config::{apply_macos_titlebar_style, macos_option_as_alt, needs_macos_titlebar_backdrop}; diff --git a/crates/noa-app/src/app/config.rs b/crates/noa-app/src/app/config.rs index 1f680e0..5e36b48 100644 --- a/crates/noa-app/src/app/config.rs +++ b/crates/noa-app/src/app/config.rs @@ -68,6 +68,16 @@ pub struct AppConfig { /// `background-blur-radius` in points (`0..=64`, 0 = off). Applied as a /// native macOS window background blur; a no-op on other platforms. pub background_blur_radius: u16, + /// The pair as configured, before `glassmorphism` took it over + /// (`noa_config::StartupConfig::configured_background_*`) — equal to the + /// effective values whenever the toggle is off. Carried so the Settings + /// panel's Undo restores what the user had rather than the derived pair. + pub configured_background_opacity: f32, + pub configured_background_blur_radius: u16, + /// `glassmorphism`: translucent frosted chrome (sidebar / tab overview) + /// instead of opaque. Default off, and off installs the byte-identical + /// opaque chrome palette — no extra draw work, no extra state. + pub glassmorphism: bool, /// `background-image`: path to a PNG laid behind the terminal grid, or the /// reserved value `noa` for the bundled wallpaper directory. `None` leaves /// the background image disabled. Missing or undecodable paths log a @@ -270,6 +280,9 @@ impl AppConfig { cursor_stop_blinking_after_secs: config.cursor_stop_blinking_after_secs, background_opacity: config.background_opacity, background_blur_radius: config.background_blur_radius, + configured_background_opacity: config.configured_background_opacity, + configured_background_blur_radius: config.configured_background_blur_radius, + glassmorphism: config.glassmorphism, background_image: config.background_image, background_image_opacity: config.background_image_opacity, background_image_position: config.background_image_position, @@ -1050,13 +1063,36 @@ pub(super) fn apply_macos_titlebar_style( } } +/// Whether a window created under `background_opacity` gets AppKit's +/// transparent treatment. The single source for both the creation attribute +/// (`with_transparent`) and the `WindowState::created_transparent` record of +/// what was asked for, so the two can never drift apart. +pub(super) fn window_created_transparent(background_opacity: f32) -> bool { + background_opacity < 1.0 +} + +/// Whether the native titlebar/tab-bar strip needs one of noa's backdrop +/// views behind it (`macos_window::install_titlebar_backdrop`). +/// +/// The `Transparent` + background-image carve-out exists because a +/// full-size content view already supplies defined pixels up there, making +/// the opaque backdrop redundant. That reasoning does not extend to +/// `glass`: its backdrop is not a fallback for undefined pixels but the tab +/// bar's *appearance* — the vibrancy view is what makes the strip frosted +/// instead of a solid bar. Dropping it because a wallpaper happens to be +/// visible would leave the titlebar as the one un-frosted surface on +/// screen, so glass keeps its backdrop whenever the window is see-through +/// at all. pub(super) fn needs_macos_titlebar_backdrop( style: noa_config::MacosTitlebarStyle, background_opacity: f32, has_visible_background_image: bool, + glass: bool, ) -> bool { background_opacity < 1.0 - && (style != noa_config::MacosTitlebarStyle::Transparent || !has_visible_background_image) + && (glass + || style != noa_config::MacosTitlebarStyle::Transparent + || !has_visible_background_image) } #[cfg(test)] @@ -1228,25 +1264,55 @@ mod tests { assert!(needs_macos_titlebar_backdrop( noa_config::MacosTitlebarStyle::Native, 0.85, + false, false )); assert!(needs_macos_titlebar_backdrop( noa_config::MacosTitlebarStyle::Transparent, 0.85, + false, false )); assert!(!needs_macos_titlebar_backdrop( noa_config::MacosTitlebarStyle::Transparent, 0.85, - true + true, + false )); assert!(!needs_macos_titlebar_backdrop( noa_config::MacosTitlebarStyle::Native, 1.0, + false, false )); } + // Glassmorphism's backdrop is the tab bar's frosted *appearance*, not a + // fallback for undefined pixels, so the background-image carve-out must + // not take it away — that would leave the titlebar as the one un-frosted + // surface on screen. An opaque window still needs nothing either way. + #[test] + fn glass_titlebar_backdrop_survives_a_visible_background_image() { + assert!(needs_macos_titlebar_backdrop( + noa_config::MacosTitlebarStyle::Transparent, + 0.5, + true, + true + )); + assert!(needs_macos_titlebar_backdrop( + noa_config::MacosTitlebarStyle::Native, + 0.5, + true, + true + )); + assert!(!needs_macos_titlebar_backdrop( + noa_config::MacosTitlebarStyle::Transparent, + 1.0, + true, + true + )); + } + #[test] fn background_image_runtime_visibility_tracks_alpha_and_payload() { let visible = noa_render::BackgroundImage { diff --git a/crates/noa-app/src/app/config_reload.rs b/crates/noa-app/src/app/config_reload.rs index 7d62c5d..f642d48 100644 --- a/crates/noa-app/src/app/config_reload.rs +++ b/crates/noa-app/src/app/config_reload.rs @@ -186,6 +186,17 @@ impl App { previous.background_image_interval_secs != applied.background_image_interval_secs; let opacity_changed = previous.background_opacity != applied.background_opacity; let blur_changed = previous.background_blur_radius != applied.background_blur_radius; + // `glassmorphism` swaps the chrome palette between its opaque and + // frosted variants; `apply_reloaded_theme` is the one path that + // re-selects that palette *and* drops the cached chrome textures + // painted with the old one, so route the toggle through it. This + // only fires for an external edit of the config file, though: the + // Settings panel's own toggle mirrors the new value into + // `self.config` at commit time (`sync_config_from_committed_live_rows`) + // and drives the same palette/texture/native-backdrop refresh + // directly from `App::commit_theme_settings`, so `previous` and + // `applied` already agree by the time this reload sees them. + let glassmorphism_changed = previous.glassmorphism != applied.glassmorphism; let terminal_policy_changed = terminal_policy_inputs_changed(&previous, &applied); let sidebar_preview_changed = previous.sidebar_preview_lines != applied.sidebar_preview_lines; @@ -213,7 +224,7 @@ impl App { } } - if theme_changed { + if theme_changed || glassmorphism_changed { self.apply_reloaded_theme(); } if background_image_changed { @@ -307,7 +318,12 @@ impl App { || sidebar_font_size_changed { self.relayout_all_windows(); - } else if theme_changed || background_image_changed || opacity_changed || blur_changed { + } else if theme_changed + || glassmorphism_changed + || background_image_changed + || opacity_changed + || blur_changed + { self.request_all_windows_redraw(); } } @@ -416,6 +432,7 @@ impl App { fn apply_reloaded_theme(&mut self) { let overrides = theme_overrides_for_config(&self.config); let palette_overrides = self.config.palette.clone(); + let glassmorphism = self.config.glassmorphism; let Some(gpu) = self.gpu.as_mut() else { return; }; @@ -424,7 +441,7 @@ impl App { &overrides, ); gpu.preview_theme = None; - crate::chrome::select_palette(gpu.theme.is_light()); + crate::chrome::select_palette(gpu.theme.is_light(), glassmorphism); gpu.chrome_textures.reset(); let default_fg = gpu.theme.default_fg; @@ -460,11 +477,33 @@ impl App { self.refresh_macos_window_backgrounds(); } - fn apply_reloaded_background_opacity(&mut self) { + /// Apply `self.config.background_opacity` to every window *completely*: + /// the swapchain's alpha mode, the renderer, and the macOS window + /// background. `pub(in crate::app)` because the Settings panel's Undo + /// needs the same full application — it writes the reverted opacity + /// straight into `self.config`, so the watcher's reload-diff sees no + /// change and never runs this itself. + pub(in crate::app) fn apply_reloaded_background_opacity(&mut self) { + self.apply_background_opacity_to_windows(self.config.background_opacity); + } + + /// Apply `opacity` to every window completely — swapchain alpha mode, + /// renderer uniform, macOS window background — for an explicit value + /// that may not be in `self.config` yet (the Settings panel's live + /// preview). + /// + /// The alpha mode is the part that cannot be skipped: an `Opaque` + /// swapchain discards the alpha the renderer writes, so a preview that + /// only moved the uniform shows nothing. That case is reachable whenever + /// a see-through window is currently resolved to `1.0` — exactly what + /// turning `glassmorphism` off does — and nothing downstream would + /// correct it: the commit mirrors the same value into `self.config`, so + /// the watcher's reload-diff then sees no change at all. + pub(in crate::app) fn apply_background_opacity_to_windows(&mut self, opacity: f32) { let Some(gpu) = self.gpu.as_mut() else { return; }; - let transparent = self.config.background_opacity < 1.0; + let transparent = opacity < 1.0; for state in self.windows.values_mut() { let caps = state.surface.get_capabilities(&gpu.adapter); let alpha_mode = preferred_surface_alpha_mode(&caps, transparent); @@ -477,11 +516,9 @@ impl App { state.occluded, ); } - state - .renderer - .set_background_opacity(self.config.background_opacity); + state.renderer.set_background_opacity(opacity); } - self.refresh_macos_window_backgrounds(); + self.refresh_macos_window_backgrounds_at(opacity); } fn apply_reloaded_background_blur(&self) { @@ -542,22 +579,48 @@ impl App { #[cfg(target_os = "macos")] fn refresh_macos_window_backgrounds(&self) { + self.refresh_macos_window_backgrounds_at(self.config.background_opacity); + } + + /// As [`Self::refresh_macos_window_backgrounds`], for an opacity that is + /// not (yet) the one in `self.config`. Two callers rely on that: the + /// Settings panel's live preview, which must move the native window + /// background with the swapchain or the two disagree until the commit + /// lands, and (`pub(in crate::app)`, hence) `App::commit_theme_settings`'s + /// `glassmorphism` commit — that mirrors the toggle straight into + /// `self.config` for its own immediate `chrome::select_palette` call + /// (needed there so the palette sees the fresh value), which leaves + /// `self.config.background_opacity` stale (unmirrored) and + /// `config_reload.rs`'s `glassmorphism_changed` diff with nothing to + /// react to on the watcher's next poll. `commit_theme_settings` derives + /// the *effective* opacity locally (`noa_config::resolved_background_opacity`, + /// the same rule `apply_glassmorphism_defaults` uses) and passes it here + /// directly, rather than through the no-arg wrapper above, so the native + /// background/backdrop lands on the value the reload is about to + /// converge on instead of flashing through the stale one. + #[cfg(target_os = "macos")] + pub(in crate::app) fn refresh_macos_window_backgrounds_at(&self, opacity: f32) { let Some(gpu) = self.gpu.as_ref() else { return; }; let needs_titlebar_backdrop = needs_macos_titlebar_backdrop( self.config.macos_titlebar_style, - self.config.background_opacity, + opacity, self.background_image.has_visible_image(), + self.config.glassmorphism, ); for state in self.windows.values() { crate::macos_window::set_window_background_color( &state.window, gpu.theme.default_bg, - self.config.background_opacity, + opacity, ); if needs_titlebar_backdrop { - crate::macos_window::install_titlebar_backdrop(&state.window, gpu.theme.default_bg); + crate::macos_window::install_titlebar_backdrop( + &state.window, + gpu.theme.default_bg, + self.config.glassmorphism, + ); } else { crate::macos_window::remove_titlebar_backdrop(&state.window); } @@ -566,6 +629,9 @@ impl App { #[cfg(not(target_os = "macos"))] fn refresh_macos_window_backgrounds(&self) {} + + #[cfg(not(target_os = "macos"))] + pub(in crate::app) fn refresh_macos_window_backgrounds_at(&self, _opacity: f32) {} } fn theme_inputs_changed(previous: &AppConfig, next: &AppConfig) -> bool { @@ -727,6 +793,60 @@ mod tests { assert!(!terminal_policy_inputs_changed(&base, &image)); } + /// Regression lock for the stale-titlebar-backdrop bug (P2): pins the + /// exact mechanism that makes `apply_reloaded_config`'s + /// `glassmorphism_changed` diff useless right after a Settings-panel + /// commit, so a future change can't quietly bring the bug back by + /// routing the toggle through this diff again instead of + /// `App::commit_theme_settings`'s direct `refresh_macos_window_backgrounds_at` + /// call. + /// + /// `theme_settings.rs`'s `sync_config_from_committed_live_rows` mirrors + /// a committed `glassmorphism` row straight into `self.config` (needed + /// so the same commit's live `chrome::select_palette` call sees the new + /// value immediately). That mirror *is* the bug's precondition: by the + /// time `ConfigWatcher` reloads the just-written file, `previous` + /// (`self.config`, already mirrored) and `applied` (the re-parsed file) + /// agree, so `glassmorphism_changed` — computed exactly like the second + /// half of this test — comes back `false` and `apply_reloaded_theme` + /// (the only caller of the native titlebar-backdrop refresh) never + /// runs. The first half is the sanity check this isn't vacuous: without + /// the mirror, the same before/after pair *would* be diffed. + #[test] + fn glassmorphism_reload_diff_goes_silent_once_a_panel_commit_has_mirrored_it() { + let base = AppConfig::from_startup( + noa_config::StartupConfig::default(), + false, + noa_config::ConfigOverrides::default(), + ); + assert!( + !base.glassmorphism, + "test assumes the documented default-off start" + ); + + // An external edit of the config file: `self.config` (previous) + // still holds the old value when the file (applied) changes under + // it, so the diff fires — this is the case + // `glassmorphism_changed`/`apply_reloaded_theme` still legitimately + // serve. + let mut applied = base.clone(); + applied.glassmorphism = true; + assert_ne!(base.glassmorphism, applied.glassmorphism); + + // The panel commit's mirror collapses `previous` onto the same + // value before the next reload ever runs — reproducing + // `self.config.glassmorphism = *v` in + // `sync_config_from_committed_live_rows` followed by `self.config + // = applied` at the top of the *next* `apply_reloaded_config` call. + let previous_after_panel_mirror = applied.clone(); + assert_eq!( + previous_after_panel_mirror.glassmorphism, applied.glassmorphism, + "the panel's mirror must erase the diff — this is exactly why \ + App::commit_theme_settings cannot rely on ConfigWatcher's reload \ + pass and must call refresh_macos_window_backgrounds_at itself" + ); + } + // R-9/Addendum D-1's FM-01 test clause: `scrollback-limit`, // `cursor-style-blink`, and `minimum-contrast` are each picked up by a // reload-diff function (so `ConfigWatcher`'s 500ms poll re-applies them diff --git a/crates/noa-app/src/app/input_ops/layout.rs b/crates/noa-app/src/app/input_ops/layout.rs index 4ed5cec..50cb7c1 100644 --- a/crates/noa-app/src/app/input_ops/layout.rs +++ b/crates/noa-app/src/app/input_ops/layout.rs @@ -66,8 +66,13 @@ impl App { self.config.macos_titlebar_style, self.config.background_opacity, has_visible_background_image, + self.config.glassmorphism, ) { - crate::macos_window::install_titlebar_backdrop(&state.window, gpu.theme.default_bg); + crate::macos_window::install_titlebar_backdrop( + &state.window, + gpu.theme.default_bg, + self.config.glassmorphism, + ); } } // AppKit re-derives a tab button's label from its window's `.title` diff --git a/crates/noa-app/src/app/input_ops/theme_settings.rs b/crates/noa-app/src/app/input_ops/theme_settings.rs index 6257b42..84b726b 100644 --- a/crates/noa-app/src/app/input_ops/theme_settings.rs +++ b/crates/noa-app/src/app/input_ops/theme_settings.rs @@ -205,6 +205,14 @@ impl App { cursor_style, background_opacity: self.config.background_opacity, background_blur_radius: self.config.background_blur_radius, + configured_background_opacity: self.config.configured_background_opacity, + configured_background_blur_radius: self.config.configured_background_blur_radius, + // The window's own creation-time capability, not a value derived + // from the live config — see the field's doc comment. + window_created_transparent: self + .windows + .get(&window_id) + .is_some_and(|state| state.created_transparent), background_image, background_image_opacity: self.config.background_image_opacity, background_image_position: self.config.background_image_position, @@ -218,6 +226,7 @@ impl App { sidebar_width: self.config.sidebar_width, sidebar_font_size: self.config.sidebar_font_size, quick_terminal_size: quick_terminal_height_fraction(self.config.quick_terminal_size), + glassmorphism: self.config.glassmorphism, confirm_quit: self.config.confirm_quit, send_selection_send_enter: self.config.send_selection_send_enter, font_family, @@ -821,13 +830,56 @@ impl App { // reloads immediately instead of the user waiting out the interval. self.expedite_config_watch(); self.sync_config_from_committed_live_rows(session.state.rows()); + let glassmorphism = self.config.glassmorphism; if let Some(gpu) = self.gpu.as_mut() { let new_theme = active_theme(&gpu.theme, &gpu.preview_theme).clone(); gpu.theme = new_theme; gpu.preview_theme = None; - crate::chrome::select_palette(gpu.theme.is_light()); + crate::chrome::select_palette(gpu.theme.is_light(), glassmorphism); gpu.chrome_textures.reset(); } + // `glassmorphism` has no continuous live preview (unlike + // `background-opacity`/`-blur-radius`, which are already applied to + // every window frame-by-frame as the user adjusts them) and its own + // `config_reload.rs` reload-diff (`glassmorphism_changed`) never + // fires for this commit — the mirror two lines up already moved + // `self.config.glassmorphism` to the new value, for + // `select_palette` above, so `previous == applied` by the time the + // watcher reloads the just-written file. Drive the one remaining + // piece `apply_reloaded_theme` would otherwise have done — the + // native macOS titlebar backdrop — directly, now that `gpu.theme` + // and the chrome palette/textures above already reflect the new + // value. Idempotent (recomputes install-vs-remove from current + // state) and cheap, so unconditional here is simpler than gating it + // on the `Glassmorphism` row specifically having been touched. + // + // Deliberately *not* the no-arg `refresh_macos_window_backgrounds()` + // wrapper: that reads `self.config.background_opacity` as-is, but + // this commit never mirrors that field for a glass-only toggle (only + // `BackgroundOpacity`-row edits do, and `snap_glass_managed_rows` + // keeps that row untouched while glass is on) — it stays at its + // pre-toggle value until the reload re-derives it from the file. + // `noa_config::resolved_background_opacity` reproduces that + // derivation (`apply_glassmorphism_defaults`'s exact rule) locally + // so the native backdrop/background color land on the value the + // reload is about to converge on anyway, instead of flashing + // through whatever the pre-toggle opacity happened to be — visible + // whenever the configured opacity isn't already + // `noa_config::GLASS_BACKGROUND_OPACITY` (0.50). + // + // `background-blur-radius` needs no equivalent call here: unlike + // `glassmorphism`, it is never mirrored into `self.config` by this + // commit (there is no live-reader forcing it, the way + // `select_palette` above forces the opacity mirror), so + // `config_reload.rs`'s `blur_changed`/`opacity_changed` diff stays + // intact and `apply_reloaded_background_blur` still fires on the + // very next (expedited) reload tick, exactly as it did before this + // fix — no gap was introduced. + let effective_background_opacity = noa_config::resolved_background_opacity( + self.config.glassmorphism, + self.config.configured_background_opacity, + ); + self.refresh_macos_window_backgrounds_at(effective_background_opacity); if updates.iter().any(|(key, _)| key == "theme") { // R-34/ADR-4 in-memory counterpart: a pair config's committed // `updates` value is the whole `"light:X,dark:Y"` string (not a @@ -949,13 +1001,22 @@ impl App { let overrides = self.theme_overrides(); let reverted_theme_name = (!payload.revert.theme_name.is_empty()).then(|| payload.revert.theme_name.clone()); + // The *reverted* value, not `self.config.glassmorphism`: the commit + // has already been applied to `self.config`, and the field is only + // restored further down (`sync_reverted_confirm_quit_and_...`). + // Selecting from the committed value would re-install the palette + // this undo exists to drop — and nothing downstream would correct + // it: by the time the watcher reloads, file and memory agree, so its + // reload-diff sees no `glassmorphism` change and never re-selects. + // The chrome and overlay alphas would stay post-commit for good. + let glassmorphism = payload.revert.glassmorphism; if let Some(gpu) = self.gpu.as_mut() { gpu.theme = crate::theme::resolve_theme_with_overrides( reverted_theme_name.as_deref(), &overrides, ); gpu.preview_theme = None; - crate::chrome::select_palette(gpu.theme.is_light()); + crate::chrome::select_palette(gpu.theme.is_light(), glassmorphism); gpu.chrome_textures.reset(); } match &payload.theme_pair { @@ -974,6 +1035,16 @@ impl App { self.config.font_size = payload.revert.font_size; self.config.background_opacity = payload.revert.background_opacity; self.config.background_blur_radius = payload.revert.background_blur_radius; + // The `configured_*` twins too, symmetrically with the commit path's + // `mirror_committed_background_*`: those move the twin with the + // effective value, so an undo that restored only the effective one + // would leave the pair disagreeing until the next reload — and + // `commit_theme_settings` derives the effective opacity from the + // twin (`noa_config::resolved_background_opacity`), so a glass + // toggle in that window would resolve against the undone value. + self.config.configured_background_opacity = payload.revert.configured_background_opacity; + self.config.configured_background_blur_radius = + payload.revert.configured_background_blur_radius; self.config.background_image = (!payload.revert.background_image.is_empty()) .then(|| PathBuf::from(&payload.revert.background_image)); self.config.background_image_opacity = payload.revert.background_image_opacity; @@ -985,7 +1056,15 @@ impl App { sync_reverted_confirm_quit_and_quick_terminal_size(&mut self.config, &payload.revert); self.apply_runtime_font_size(window_id, payload.revert.font_size); self.apply_live_cursor_style(payload.revert.cursor_style); - self.apply_live_background_opacity(payload.revert.background_opacity); + // The *full* apply, not `apply_live_background_opacity`: that one + // only moves the renderer's uniform, and an undo can cross the + // opaque/translucent boundary (glassmorphism off resolves the + // opacity back to 1.0 and leaves the surface Opaque). The swapchain + // would then discard the restored alpha and the glass would not come + // back. `self.config` already holds the reverted value here, and the + // watcher cannot rescue this: its reload-diff compares against that + // same updated config and sees nothing to do. + self.apply_reloaded_background_opacity(); self.apply_live_background_blur( payload.revert.background_blur_radius, payload.revert.background_opacity, @@ -1030,11 +1109,25 @@ impl App { } match &row.draft { RowDraft::FontSize(v) => self.config.font_size = *v, + // The `configured_*` twin moves with the effective value: + // it means "what the config file asked for", and the commit + // just wrote this row to the file. Keeping it in sync is + // what lets `commit_theme_settings` derive the effective + // opacity through `noa_config::resolved_background_opacity` + // — with a stale twin that derivation hands back the + // *previous* opacity and undoes the live preview at save + // time, which the reload can't repair (the mirror above + // has already flattened `opacity_changed`). Only reached + // for a row the user actually touched, and `glassmorphism` + // holds these two at `touched = false` while it owns them + // (`snap_glass_managed_rows`), so turning glass back off + // still restores the user's own value rather than the + // glass one. RowDraft::BackgroundOpacity(v) => { - self.config.background_opacity = *v; + mirror_committed_background_opacity(&mut self.config, *v); } RowDraft::BackgroundBlurRadius(v) => { - self.config.background_blur_radius = *v; + mirror_committed_background_blur_radius(&mut self.config, *v); } RowDraft::BackgroundImage(v) => { self.config.background_image = @@ -1074,6 +1167,24 @@ impl App { self.apply_live_sidebar_font_size(*v); } RowDraft::QuickTerminalHeight(_) => {} + // Mirrored (unlike the reload-applied group below) because + // `commit_theme_settings`'s own `chrome::select_palette` + // call, right after this loop returns, reads + // `self.config.glassmorphism` and needs the new value + // immediately — a P2-confirmed bug once left this as the + // *only* apply, though: mirroring here makes + // `config_reload.rs`'s `glassmorphism_changed` diff go false + // before the watcher's next poll ever sees it, so nothing + // downstream removed a stale native titlebar backdrop when + // the toggle went back off. `commit_theme_settings` now + // drives that refresh directly (see its + // `refresh_macos_window_backgrounds_at` call, given the + // opacity `noa_config::resolved_background_opacity` derives + // rather than the stale `self.config.background_opacity`) + // instead of depending on the reload path picking it up. + RowDraft::Glassmorphism(v) => { + self.config.glassmorphism = *v; + } RowDraft::ConfirmQuit(v) => { self.config.confirm_quit = *v; } @@ -1195,8 +1306,15 @@ impl App { /// true — `adjust`/`revert` only report this effect for a /// transparent-started session. fn apply_live_background_opacity(&mut self, opacity: f32) { - for state in self.windows.values_mut() { - state.renderer.set_background_opacity(opacity); + // The full apply, not just the renderer's uniform: an `Opaque` + // swapchain throws the alpha away, and a see-through window sits at + // exactly that setting whenever its opacity currently resolves to + // `1.0` (what turning `glassmorphism` off does). Previewing a lower + // value would then change nothing on screen, and the commit would + // mirror the same value into `self.config`, leaving the watcher's + // reload-diff with nothing to fix. + self.apply_background_opacity_to_windows(opacity); + for state in self.windows.values() { state.window.request_redraw(); } } @@ -1302,12 +1420,42 @@ fn sync_reverted_confirm_quit_and_quick_terminal_size( config: &mut AppConfig, revert: &crate::theme_settings::RevertValues, ) { + config.glassmorphism = revert.glassmorphism; config.confirm_quit = revert.confirm_quit; config.send_selection_send_enter = revert.send_selection_send_enter; config.quick_terminal_size = quick_terminal_size_from_height_fraction(revert.quick_terminal_size); } +/// Mirror a committed `background-opacity` row into `config`, moving the +/// `configured_*` twin with it. +/// +/// The twin means "what the config file asked for", and the commit that +/// reaches here has just written this row to the file — so the two move +/// together. This is not bookkeeping: `commit_theme_settings` derives the +/// opacity it refreshes the macOS window background and titlebar backdrop +/// at through [`noa_config::resolved_background_opacity`], which reads the +/// twin. Left stale, that derivation hands back the *previous* opacity and +/// reverts, at save time, the background the live preview already got +/// right — and the reload cannot repair it, because mirroring the +/// effective value above has already flattened `config_reload.rs`'s +/// `opacity_changed` diff. +/// +/// Only reached for a row the user actually touched, and `glassmorphism` +/// holds this row at `touched = false` for as long as it owns it +/// (`ThemeSettings::snap_glass_managed_rows`), so the twin keeps the user's +/// own value across a glass toggle rather than picking up the glass one. +fn mirror_committed_background_opacity(config: &mut AppConfig, opacity: f32) { + config.background_opacity = opacity; + config.configured_background_opacity = opacity; +} + +/// As [`mirror_committed_background_opacity`], for `background-blur-radius`. +fn mirror_committed_background_blur_radius(config: &mut AppConfig, radius: u16) { + config.background_blur_radius = radius; + config.configured_background_blur_radius = radius; +} + fn sync_quick_terminal_size_from_committed_rows( config: &mut AppConfig, rows: &[SettingsRow; SettingsRowKind::COUNT], @@ -1507,6 +1655,59 @@ mod commit_theme_settings_tests { let _ = std::fs::remove_file(&path); } + // Regression (P1): committing a `background-opacity` change with + // `glassmorphism` off used to leave `configured_background_opacity` at + // the previous value, so `commit_theme_settings`'s + // `resolved_background_opacity` derivation refreshed the macOS window + // background and titlebar backdrop at the *old* opacity — undoing at + // save time what the live preview had already applied, with no reload + // able to repair it (mirroring the effective value flattens + // `opacity_changed`). Deliberately uses two values that differ from + // each other and from `GLASS_BACKGROUND_OPACITY`, so neither a stale + // twin nor the glass takeover can pass by coincidence. + #[test] + fn committing_background_opacity_moves_the_configured_twin_with_it() { + let mut config = AppConfig::from_startup( + noa_config::StartupConfig::default(), + false, + noa_config::ConfigOverrides::default(), + ); + mirror_committed_background_opacity(&mut config, 1.0); + mirror_committed_background_blur_radius(&mut config, 0); + + mirror_committed_background_opacity(&mut config, 0.8); + mirror_committed_background_blur_radius(&mut config, 12); + + assert_eq!(config.background_opacity, 0.8); + assert_eq!(config.background_blur_radius, 12); + assert_eq!( + config.configured_background_opacity, 0.8, + "the twin must move with the committed value, not lag a commit behind" + ); + assert_eq!(config.configured_background_blur_radius, 12); + + // The derivation `commit_theme_settings` actually performs: with + // glass off it must reproduce what the user just saved, which is + // what the native background gets refreshed at. + assert_eq!( + noa_config::resolved_background_opacity(false, config.configured_background_opacity), + 0.8, + "a stale twin here is what reverted the live-previewed background on save" + ); + assert_eq!( + noa_config::resolved_background_blur_radius( + false, + config.configured_background_blur_radius + ), + 12 + ); + // With glass on the takeover still wins, unchanged by this fix. + assert_eq!( + noa_config::resolved_background_opacity(true, config.configured_background_opacity), + noa_config::GLASS_BACKGROUND_OPACITY + ); + } + #[test] fn quick_terminal_size_syncs_from_committed_row_into_app_config() { let mut settings = ThemeSettings::open(ThemeSettingsInit { @@ -1516,6 +1717,9 @@ mod commit_theme_settings_tests { cursor_style: noa_config::CursorShape::Block, background_opacity: 1.0, background_blur_radius: 0, + configured_background_opacity: 1.0, + configured_background_blur_radius: 0, + window_created_transparent: false, background_image: String::new(), background_image_opacity: 1.0, background_image_position: noa_config::BackgroundImagePosition::Center, @@ -1529,6 +1733,7 @@ mod commit_theme_settings_tests { sidebar_width: noa_config::DEFAULT_SIDEBAR_WIDTH, sidebar_font_size: noa_config::DEFAULT_SIDEBAR_FONT_SIZE, quick_terminal_size: 0.4, + glassmorphism: false, confirm_quit: true, send_selection_send_enter: false, font_family: "Menlo".to_string(), @@ -1598,6 +1803,8 @@ mod commit_theme_settings_tests { cursor_style: noa_config::CursorShape::Block, background_opacity: 1.0, background_blur_radius: 0, + configured_background_opacity: 1.0, + configured_background_blur_radius: 0, background_image: String::new(), background_image_opacity: 1.0, background_image_position: noa_config::BackgroundImagePosition::Center, @@ -1611,6 +1818,7 @@ mod commit_theme_settings_tests { window_padding_x: 2.0, window_padding_y: 2.0, macos_titlebar_style: noa_config::MacosTitlebarStyle::Native, + glassmorphism: false, confirm_quit: true, send_selection_send_enter: false, font_family: "Menlo".to_string(), @@ -1661,6 +1869,9 @@ mod commit_theme_settings_tests { cursor_style: noa_config::CursorShape::Block, background_opacity: 1.0, background_blur_radius: 0, + configured_background_opacity: 1.0, + configured_background_blur_radius: 0, + window_created_transparent: false, background_image: "/tmp/wall.png".to_string(), background_image_opacity: 1.0, background_image_position: noa_config::BackgroundImagePosition::Center, @@ -1674,6 +1885,7 @@ mod commit_theme_settings_tests { sidebar_width: noa_config::DEFAULT_SIDEBAR_WIDTH, sidebar_font_size: noa_config::DEFAULT_SIDEBAR_FONT_SIZE, quick_terminal_size: 0.4, + glassmorphism: false, confirm_quit: true, send_selection_send_enter: false, font_family: "Menlo".to_string(), diff --git a/crates/noa-app/src/app/lifecycle.rs b/crates/noa-app/src/app/lifecycle.rs index 5aa8f00..4a5b318 100644 --- a/crates/noa-app/src/app/lifecycle.rs +++ b/crates/noa-app/src/app/lifecycle.rs @@ -266,7 +266,13 @@ impl App { ); // Chrome (sidebar/overview) polarity follows the terminal // theme: a light theme gets light chrome. - crate::chrome::select_palette(theme.is_light()); + // `glassmorphism = true` resolves `background-opacity` / + // `background-blur-radius` to the recommended glass pair + // (`noa_config::apply_glassmorphism_defaults`), so the window + // this palette draws into is always translucent and blurred — + // there is no "frosted chrome over an opaque window" case left + // to warn about here. + crate::chrome::select_palette(theme.is_light(), self.config.glassmorphism); let caps = surface.get_capabilities(&adapter); let alpha_blending = alpha_blending_mode(&self.config.font); @@ -339,8 +345,13 @@ impl App { self.config.macos_titlebar_style, self.config.background_opacity, self.background_image.has_visible_image(), + self.config.glassmorphism, ) { - crate::macos_window::install_titlebar_backdrop(&window, theme.default_bg); + crate::macos_window::install_titlebar_backdrop( + &window, + theme.default_bg, + self.config.glassmorphism, + ); } } match surface.get_current_texture() { @@ -429,6 +440,7 @@ impl App { palette_card: None, palette_padding: noa_core::GridPadding::ZERO, palette_scrim: None, + palette_shadow_source: None, }); (surface, Some(surface_config)) }; @@ -505,8 +517,13 @@ impl App { self.config.macos_titlebar_style, self.config.background_opacity, self.background_image.has_visible_image(), + self.config.glassmorphism, ) { - crate::macos_window::install_titlebar_backdrop(&window, bg); + crate::macos_window::install_titlebar_backdrop( + &window, + bg, + self.config.glassmorphism, + ); } } @@ -553,6 +570,7 @@ impl App { self.windows.insert( window_id, WindowState { + created_transparent: window_created_transparent(self.config.background_opacity), window: window.clone(), group, surface, @@ -657,7 +675,7 @@ impl App { // A transparent window is required for `background-opacity` to // reveal anything behind it; the surface alpha mode and the // renderer's clear alpha carry the actual opacity. - .with_transparent(self.config.background_opacity < 1.0); + .with_transparent(window_created_transparent(self.config.background_opacity)); #[cfg(target_os = "macos")] { // Tabs in the same group share a `tabbingIdentifier`, so AppKit diff --git a/crates/noa-app/src/app/overview/render.rs b/crates/noa-app/src/app/overview/render.rs index bd3800e..1ca28f5 100644 --- a/crates/noa-app/src/app/overview/render.rs +++ b/crates/noa-app/src/app/overview/render.rs @@ -1,18 +1,23 @@ use super::super::*; impl App { - pub(in crate::app) fn ensure_overview_thumbnails(&mut self, layout: &OverviewLayout) { + /// Returns whether the tile textures were (re)allocated, which the caller + /// must treat as "every tile's content is gone": a fresh allocation is + /// only cleared to the card color, so a tile that was clean keeps its + /// place in the grid as an empty card until something else happens to + /// dirty it — pty output on that tab, or reopening the Overview. + pub(in crate::app) fn ensure_overview_thumbnails(&mut self, layout: &OverviewLayout) -> bool { let Some(host_config) = self.overview_host_surface_config() else { - return; + return false; }; let Some(metrics) = self.overview_metrics() else { - return; + return false; }; let Some(gpu) = self.gpu.as_ref() else { - return; + return false; }; let Some(overview) = self.overview_window.as_mut() else { - return; + return false; }; // Placeholder tiles (REQ-OV-10) are the same uniform size as live @@ -22,7 +27,7 @@ impl App { let tile_count = layout.tiles.len() + layout.placeholders.len(); if tile_count == 0 { overview.thumbnails = None; - return; + return false; } let tile_size = PixelSize { w: layout.tiles[0].w.max(1), @@ -34,10 +39,21 @@ impl App { }; let format = host_config.format; + // The card color is part of staleness, not just the geometry: tile + // textures are cleared to it once, at allocation, and nothing + // re-clears them afterward. A runtime palette swap (theme polarity, + // `glassmorphism`) therefore has to rebuild them, or an Overview + // opened before the swap keeps compositing tiles that carry the old + // face — opaque under a frosted palette, translucent after it is + // turned back off — until the tile size or count happens to change. + let card_color = overview_card_color(); + let card_blend = overview_card_blend(); let stale = overview.thumbnails.as_ref().is_none_or(|thumbnails| { thumbnails.format() != format || thumbnails.tile_size() != tile_size || thumbnails.tile_count() != tile_count + || thumbnails.card_color() != card_color + || thumbnails.card_blend() != card_blend }); if stale { overview.thumbnails = Some(OverviewThumbnailResources::new( @@ -48,9 +64,11 @@ impl App { tile_size, tile_count, metrics.title_bar_h, - overview_card_color(), + card_color, + card_blend, )); } + stale } /// Render each due tile's source pane into the shared scratch texture and @@ -217,14 +235,16 @@ impl App { let Some(overview) = self.overview_window.as_mut() else { return; }; + let glass = crate::chrome::palette().is_glass(); let stale = overview .chrome_card .as_ref() - .is_none_or(|chrome| chrome.format != format); + .is_none_or(|chrome| chrome.format != format || chrome.glass != glass); if stale { overview.chrome_card = Some(OverviewChromeCardPipeline { format, - pipeline: CardPipeline::new(&gpu.device, format, wgpu::BlendState::ALPHA_BLENDING), + glass, + pipeline: CardPipeline::new(&gpu.device, format, overview_card_blend()), }); } } @@ -477,6 +497,7 @@ impl App { live_tile_count, page, rect, + chrome_pill: pill_color_key(overview_chrome_pill_color()), }; if let Some(hit) = self .overview_window @@ -561,6 +582,7 @@ impl App { live_tile_count, page, rect, + chrome_pill: pill_color_key(overview_chrome_pill_color()), }; if let Some(hit) = self .overview_window @@ -1152,9 +1174,19 @@ impl App { .min(page_view.slice.len().saturating_sub(1)); } let now = Instant::now(); + // Resource allocation runs *before* the due-tile selection, not after: + // a rebuild leaves every tile texture holding nothing but the card + // color, so the tiles it wiped have to be dirty by the time this + // frame picks its work. Selecting first would render the old dirty + // set into fresh textures and present the rest as empty cards until + // pty output happened to dirty them (a live theme-polarity or + // `glassmorphism` change is exactly that case — nothing else about + // the grid changes, so nothing else would ever redraw them). + if self.ensure_overview_thumbnails(&layout) { + self.mark_all_overview_tiles_dirty(); + } let due_tile_ids = self.due_overview_tile_ids(&page_view.slice, now); - self.ensure_overview_thumbnails(&layout); self.render_due_overview_tiles(&due_tile_ids, &page_view.slice); self.render_due_overview_title_bands(&due_tile_ids, &page_view.slice, &layout); self.render_overview_placeholder_labels(&page_view.slice, &layout); @@ -1199,6 +1231,38 @@ fn overview_attention_ring_visible( attention && (emphasized || (index != selected && hovered != Some(index))) } +/// The blend every Overview composite — tiles and chrome alike — is built +/// with. +/// +/// Under an opaque palette this is plain alpha blending, and the surface it +/// draws onto is opaque anyway. Under `glassmorphism` nothing on this +/// surface is opaque: the backdrop clears to `backdrop_alpha`, cards and +/// pills carry `surface_alpha`/`pill_alpha`, and live tiles carry the +/// window's own opacity. Blending would then *add* alpha at every layer — a +/// card face would land at `0.16 + 0.18·(1 - 0.16)`, and each extra pass a +/// hover ring, attention ring, or zoom draws over the same tile would push +/// it further — so the glass would visibly thicken with interaction state. +/// `ALPHA_REPLACE` writes each surface's own alpha instead, leaving the +/// density a property of the palette rather than of what the pointer is +/// doing. (Its trade-off, a coverage-faded corner writing a lower alpha than +/// the backdrop instead of blending into it, is a ~1px rim on rounded +/// tiles — the same trade the sidebar band takes.) +fn overview_card_blend() -> wgpu::BlendState { + if crate::chrome::palette().is_glass() { + CardPipeline::ALPHA_REPLACE + } else { + wgpu::BlendState::ALPHA_BLENDING + } +} + +/// The chrome-color half of [`OverviewPillKey`]: raw `f32` bits, so a color +/// the palette can swap at runtime (theme polarity, `glassmorphism`) takes +/// part in an `Eq` key. Bit equality is the right test — these are the same +/// constants re-read, not the result of arithmetic that could round. +fn pill_color_key(color: [f32; 4]) -> [u32; 4] { + color.map(f32::to_bits) +} + /// Hit/miss rule for the search/hint pill cache /// (`render_overview_search_texture` / `render_overview_hint_texture`): the /// cached value is reusable only if its key matches the current call's @@ -1226,12 +1290,21 @@ mod tests { assert!(!overview_attention_ring_visible(false, true, 2, 0, None)); } + /// A fixed pill color, never `overview_chrome_pill_color()`: that reads + /// the process-global chrome palette, which `chrome`'s own tests swap + /// between dark/light/glass under a lock private to that module. Two + /// `pill_key` calls straddling such a swap would differ despite + /// identical inputs, and these cache tests are about the key's *rules*, + /// not about which color is installed. + const TEST_PILL_COLOR: [f32; 4] = [0.13, 0.14, 0.21, 1.0]; + fn pill_key(query: &str, live_tile_count: usize) -> OverviewPillKey { OverviewPillKey { query: query.to_string(), live_tile_count, page: 0, rect: PaneRectApp::new(0, 0, 200, 32), + chrome_pill: pill_color_key(TEST_PILL_COLOR), } } @@ -1256,6 +1329,34 @@ mod tests { assert_eq!(hit, None); } + // The chrome palette is swappable at runtime (theme polarity, a + // `glassmorphism` toggle), and nothing else invalidates these caches — + // `ChromeTextures::reset` owns only the sidebar's textures. Without the + // color in the key, a pill rasterized before the swap keeps compositing + // afterward: opaque on a frosted surface, or frosted after the toggle is + // turned back off, until the query, count, page, or rect happens to move. + #[test] + fn pill_cache_misses_when_the_chrome_pill_color_changes() { + let cached = Some((pill_key("noa", 3), "pill-texture")); + let repainted = OverviewPillKey { + chrome_pill: pill_color_key([0.1, 0.2, 0.3, 0.4]), + ..pill_key("noa", 3) + }; + let hit = overview_pill_cache_hit(cached.as_ref(), &repainted); + assert_eq!(hit, None); + } + + // The glass variants differ from their opaque counterparts in the alpha + // channel alone, so an RGB-only key would miss exactly the case this + // exists for. + #[test] + fn pill_color_key_separates_colors_that_differ_only_in_alpha() { + let opaque = pill_color_key([0.13, 0.14, 0.21, 1.0]); + let frosted = pill_color_key([0.13, 0.14, 0.21, 0.34]); + assert_ne!(opaque, frosted); + assert_eq!(opaque, pill_color_key([0.13, 0.14, 0.21, 1.0])); + } + #[test] fn pill_cache_misses_when_rect_changes() { let cached = Some((pill_key("noa", 3), "pill-texture")); diff --git a/crates/noa-app/src/app/quick_terminal.rs b/crates/noa-app/src/app/quick_terminal.rs index fba6ccc..1dc324e 100644 --- a/crates/noa-app/src/app/quick_terminal.rs +++ b/crates/noa-app/src/app/quick_terminal.rs @@ -704,7 +704,7 @@ impl App { .with_decorations(false) .with_inner_size(PhysicalSize::new(geometry.width, geometry.height)) .with_position(PhysicalPosition::new(geometry.hidden_x, geometry.hidden_y)) - .with_transparent(self.config.background_opacity < 1.0) + .with_transparent(window_created_transparent(self.config.background_opacity)) // Never on screen until the show path explicitly reveals it // (RC1): avoids ordering an unpainted window front. .with_visible(false); @@ -803,6 +803,7 @@ impl App { self.windows.insert( window_id, WindowState { + created_transparent: window_created_transparent(self.config.background_opacity), window, group, surface, diff --git a/crates/noa-app/src/app/render.rs b/crates/noa-app/src/app/render.rs index 4794ec6..3d0154d 100644 --- a/crates/noa-app/src/app/render.rs +++ b/crates/noa-app/src/app/render.rs @@ -470,8 +470,13 @@ impl App { self.config.macos_titlebar_style, self.config.background_opacity, has_visible_background_image, + self.config.glassmorphism, ) { - crate::macos_window::install_titlebar_backdrop(&state.window, gpu.theme.default_bg); + crate::macos_window::install_titlebar_backdrop( + &state.window, + gpu.theme.default_bg, + self.config.glassmorphism, + ); } } if state.occluded { diff --git a/crates/noa-app/src/app/scratch_terminal.rs b/crates/noa-app/src/app/scratch_terminal.rs index ab07be7..20e16af 100644 --- a/crates/noa-app/src/app/scratch_terminal.rs +++ b/crates/noa-app/src/app/scratch_terminal.rs @@ -276,7 +276,7 @@ impl App { .with_decorations(false) .with_inner_size(PhysicalSize::new(width, height)) .with_position(PhysicalPosition::new(origin_x, origin_y)) - .with_transparent(self.config.background_opacity < 1.0) + .with_transparent(window_created_transparent(self.config.background_opacity)) .with_visible(false); #[cfg(target_os = "macos")] let attrs = attrs.with_option_as_alt(macos_option_as_alt(self.config.macos_option_as_alt)); @@ -378,6 +378,7 @@ impl App { self.windows.insert( window_id, WindowState { + created_transparent: window_created_transparent(self.config.background_opacity), window: window.clone(), group, surface, diff --git a/crates/noa-app/src/app/sidebar.rs b/crates/noa-app/src/app/sidebar.rs index 3e6af91..597ed87 100644 --- a/crates/noa-app/src/app/sidebar.rs +++ b/crates/noa-app/src/app/sidebar.rs @@ -291,7 +291,7 @@ fn status_indicator(dot: StatusDot) -> (&'static str, Rgb) { /// OSC 9/777 means a notification exists, not necessarily that a program is /// blocked on user input. Keep the label accurate until a dedicated /// response-required protocol exists. -const ATTENTION_LABEL: &str = "通知あり"; +const ATTENTION_LABEL: &str = "notification"; fn rgb_to_rgba(color: Rgb) -> [f32; 4] { [ diff --git a/crates/noa-app/src/app/sidebar/palette.rs b/crates/noa-app/src/app/sidebar/palette.rs index aa72900..bae4369 100644 --- a/crates/noa-app/src/app/sidebar/palette.rs +++ b/crates/noa-app/src/app/sidebar/palette.rs @@ -74,6 +74,10 @@ fn ensure_card_pipeline(gpu: &mut GpuState, surface_format: wgpu::TextureFormat) { gpu.palette_card = Some(OverviewChromeCardPipeline { format: surface_format, + // Overlay cards float over the terminal grid and blend with it; + // their translucency comes from `OverlayStyle`'s surface alpha, + // not from rewriting the window's alpha channel. + glass: false, pipeline: CardPipeline::new( &gpu.device, surface_format, @@ -148,7 +152,7 @@ fn rgb_from_rgba(c: [f32; 4]) -> Rgb { } /// Ensure the shared 1x1 scrim texture exists (its alpha carries the modal -/// scrim opacity). +/// scrim opacity), plus the drop-shadow source every card composite needs. fn ensure_scrim(gpu: &mut GpuState) { let GpuState { device, @@ -165,15 +169,51 @@ fn ensure_scrim(gpu: &mut GpuState) { "noa-command-palette-scrim", [0, 0, 0, PALETTE_SCRIM_ALPHA], ); + ensure_shadow_source(gpu); +} + +/// Ensure the fully transparent 1x1 source the drop-shadow pass samples +/// exists (see [`GpuState::palette_shadow_source`]). Separate from +/// [`ensure_scrim`] because the toast card needs it without wanting the +/// modal scrim — it dims nothing. +fn ensure_shadow_source(gpu: &mut GpuState) { + let GpuState { + device, + queue, + palette_shadow_source, + .. + } = gpu; + let _ = ensure_tint_texture( + device, + queue, + palette_shadow_source, + "noa-command-palette-shadow-source", + [0, 0, 0, 0], + ); } /// Composite the already-rasterized `palette_scratch` block as a modal card /// over the pane: a translucent scrim dimming the whole pane, then a soft -/// black drop shadow, then the elevated surface with a themed 1px border — -/// two card-pipeline passes (shadow+fill, then fill+border) over the same -/// texture. Shared by the command palette and the confirm dialog so every -/// modal carries identical chrome. `opacity` scales all three passes (the -/// open fade-in); 1.0 is fully settled. +/// black drop shadow, then the elevated surface with a themed 1px border. +/// Shared by the command palette and the confirm dialog so every modal +/// carries identical chrome. `opacity` scales every pass (the open +/// fade-in); 1.0 is fully settled. +/// +/// The card's *fill* is drawn by exactly one of these passes. The shadow +/// pass samples a fully transparent 1x1 source instead of the scratch, so it +/// contributes only the glow outside the card shape (`card.wgsl` returns the +/// glow before it ever samples the texture) — drawing the real fill in both +/// passes would blend it over itself, which is invisible while the surface +/// is opaque but drives a frosted one from `0.68` to `0.90` and defeats the +/// glass. +/// +/// The glass alpha itself lives in the scratch's *clear color*, which is the +/// only stage that touches the fill alone: glyphs blend over it opaque +/// (`a_src + a_dst·(1-a_src)` = 1 for a covered pixel), non-default cell +/// backgrounds draw their own opaque quads, and `card.wgsl` gives the border +/// stroke the border color's alpha. Applying it here instead — as this +/// pass's `opacity` — would scale the *whole* sampled texture, taking the +/// text and the stroke down with the surface. #[allow(clippy::too_many_arguments)] fn composite_modal_card( gpu: &GpuState, @@ -242,15 +282,27 @@ fn composite_modal_card( }], opacity, ); + // Glow only: the transparent source contributes nothing inside the card + // shape, leaving the fill to the border pass below. card.overlay_texture_cards_with_opacity( &gpu.device, &gpu.queue, view, surface_size, &shadow_style, - &[placement(true)], + &[CardTexturePlacement { + texture_view: &gpu.palette_shadow_source.as_ref().unwrap().1, + x, + y, + w: block_px.w, + h: block_px.h, + selected: true, + }], opacity, ); + // The one pass that draws the surface: fill + border. The glass alpha is + // *not* applied here — `card.wgsl` multiplies `u.opacity` into the whole + // sampled texture, which would take the glyphs down with the fill. card.overlay_texture_cards_with_opacity( &gpu.device, &gpu.queue, @@ -342,6 +394,7 @@ pub(in crate::app) fn draw_command_palette_card( if gpu.palette_renderer.is_none() || gpu.palette_card.is_none() || gpu.chrome_textures.palette_scratch.is_none() + || gpu.palette_shadow_source.is_none() { return; } @@ -365,12 +418,15 @@ pub(in crate::app) fn draw_command_palette_card( let scratch_view = &gpu.chrome_textures.palette_scratch.as_ref().unwrap().2; let renderer = gpu.palette_renderer.as_mut().unwrap(); renderer.resize(block_px); - renderer.set_clear_color(style.surface_bg()); renderer.rebuild_cells( &snapshot, &mut gpu.font, active_theme(&gpu.theme, &gpu.preview_theme), ); + // After `rebuild_cells` (which resets clear_color from the snapshot's + // opaque bg) so the scratch's fill actually carries the palette's + // translucent surface alpha under glassmorphism. + renderer.set_clear_color(style.surface_bg()); renderer.sync_atlas(&gpu.device, &gpu.queue, &mut gpu.font); renderer.draw(&gpu.device, &gpu.queue, scratch_view); } @@ -429,6 +485,7 @@ pub(in crate::app) fn draw_confirm_dialog_card( if gpu.palette_renderer.is_none() || gpu.palette_card.is_none() || gpu.chrome_textures.palette_scratch.is_none() + || gpu.palette_shadow_source.is_none() { return; } @@ -442,12 +499,15 @@ pub(in crate::app) fn draw_confirm_dialog_card( let scratch_view = &gpu.chrome_textures.palette_scratch.as_ref().unwrap().2; let renderer = gpu.palette_renderer.as_mut().unwrap(); renderer.resize(block_px); - renderer.set_clear_color(style.surface_bg()); renderer.rebuild_cells( &snapshot, &mut gpu.font, active_theme(&gpu.theme, &gpu.preview_theme), ); + // After `rebuild_cells` (which resets clear_color from the snapshot's + // opaque bg) so the scratch's fill actually carries the palette's + // translucent surface alpha under glassmorphism. + renderer.set_clear_color(style.surface_bg()); renderer.sync_atlas(&gpu.device, &gpu.queue, &mut gpu.font); renderer.draw(&gpu.device, &gpu.queue, scratch_view); } @@ -487,6 +547,7 @@ pub(in crate::app) fn draw_toast_card( let metrics = gpu.font.metrics(); let (interior, block_px) = modal_block_geometry(metrics, cols, 1); ensure_overlay_card_gpu(gpu, surface_format, interior); + ensure_shadow_source(gpu); if ensure_scratch( &mut gpu.chrome_textures.palette_scratch, &gpu.device, @@ -500,6 +561,7 @@ pub(in crate::app) fn draw_toast_card( if gpu.palette_renderer.is_none() || gpu.palette_card.is_none() || gpu.chrome_textures.palette_scratch.is_none() + || gpu.palette_shadow_source.is_none() { return; } @@ -523,12 +585,15 @@ pub(in crate::app) fn draw_toast_card( let scratch_view = &gpu.chrome_textures.palette_scratch.as_ref().unwrap().2; let renderer = gpu.palette_renderer.as_mut().unwrap(); renderer.resize(block_px); - renderer.set_clear_color(style.surface_bg()); renderer.rebuild_cells( &snapshot, &mut gpu.font, active_theme(&gpu.theme, &gpu.preview_theme), ); + // After `rebuild_cells` (which resets clear_color from the snapshot's + // opaque bg) so the scratch's fill actually carries the palette's + // translucent surface alpha under glassmorphism. + renderer.set_clear_color(style.surface_bg()); renderer.sync_atlas(&gpu.device, &gpu.queue, &mut gpu.font); renderer.draw(&gpu.device, &gpu.queue, scratch_view); } @@ -554,22 +619,27 @@ pub(in crate::app) fn draw_toast_card( focus_width: 1.0 * scale, focus_glow_width: 0.0, }; - let placement = |selected| CardTexturePlacement { - texture_view: &gpu.chrome_textures.palette_scratch.as_ref().unwrap().2, - x, - y, - w: block_px.w, - h: block_px.h, - selected, - }; let card = &gpu.palette_card.as_ref().unwrap().pipeline; + // Same one-fill discipline as `composite_modal_card`: the shadow pass + // draws the glow from a fully transparent source, so the surface is + // composited exactly once. Compositing the real scratch in both passes + // blends the fill over itself — invisible while it is opaque, but a + // frosted `glassmorphism` toast would climb from 0.68 to ~0.90 and end + // up the one near-solid surface on screen. card.overlay_texture_cards( &gpu.device, &gpu.queue, view, surface_size, &shadow_style, - &[placement(true)], + &[CardTexturePlacement { + texture_view: &gpu.palette_shadow_source.as_ref().unwrap().1, + x, + y, + w: block_px.w, + h: block_px.h, + selected: true, + }], ); card.overlay_texture_cards( &gpu.device, @@ -577,7 +647,14 @@ pub(in crate::app) fn draw_toast_card( view, surface_size, &border_style, - &[placement(false)], + &[CardTexturePlacement { + texture_view: &gpu.chrome_textures.palette_scratch.as_ref().unwrap().2, + x, + y, + w: block_px.w, + h: block_px.h, + selected: false, + }], ); } @@ -648,6 +725,7 @@ pub(in crate::app) fn draw_theme_settings_card( if gpu.palette_renderer.is_none() || gpu.palette_card.is_none() || gpu.chrome_textures.palette_scratch.is_none() + || gpu.palette_shadow_source.is_none() { return; } @@ -674,8 +752,11 @@ pub(in crate::app) fn draw_theme_settings_card( let scratch_view = &gpu.chrome_textures.palette_scratch.as_ref().unwrap().2; let renderer = gpu.palette_renderer.as_mut().unwrap(); renderer.resize(block_px); - renderer.set_clear_color(style.surface_bg()); renderer.rebuild_cells(&snapshot, &mut gpu.font, theme); + // After `rebuild_cells` (which resets clear_color from the snapshot's + // opaque bg) so the scratch's fill actually carries the palette's + // translucent surface alpha under glassmorphism. + renderer.set_clear_color(style.surface_bg()); renderer.sync_atlas(&gpu.device, &gpu.queue, &mut gpu.font); renderer.draw(&gpu.device, &gpu.queue, scratch_view); } @@ -745,6 +826,7 @@ pub(in crate::app) fn draw_process_monitor_card( if gpu.palette_renderer.is_none() || gpu.palette_card.is_none() || gpu.chrome_textures.palette_scratch.is_none() + || gpu.palette_shadow_source.is_none() { return; } @@ -770,8 +852,11 @@ pub(in crate::app) fn draw_process_monitor_card( let scratch_view = &gpu.chrome_textures.palette_scratch.as_ref().unwrap().2; let renderer = gpu.palette_renderer.as_mut().unwrap(); renderer.resize(block_px); - renderer.set_clear_color(style.surface_bg()); renderer.rebuild_cells(&snapshot, &mut gpu.font, theme); + // After `rebuild_cells` (which resets clear_color from the snapshot's + // opaque bg) so the scratch's fill actually carries the palette's + // translucent surface alpha under glassmorphism. + renderer.set_clear_color(style.surface_bg()); renderer.sync_atlas(&gpu.device, &gpu.queue, &mut gpu.font); renderer.draw(&gpu.device, &gpu.queue, scratch_view); } diff --git a/crates/noa-app/src/app/sidebar/render.rs b/crates/noa-app/src/app/sidebar/render.rs index 0cfc850..e151ee2 100644 --- a/crates/noa-app/src/app/sidebar/render.rs +++ b/crates/noa-app/src/app/sidebar/render.rs @@ -65,6 +65,19 @@ fn sidebar_card_fill_opacity(flash_fill: bool) -> f32 { } } +/// Scale a chrome raster's clear alpha by the palette alpha of its surface +/// class. +/// +/// The sidebar composites through `overlay_texture_cards`, which ignores +/// `CardStyle::background` entirely — `card.wgsl` takes its output alpha from +/// the sampled texture (`tex.a`). A surface is therefore exactly as +/// translucent as the alpha it was *rasterized* with, and a glass palette +/// only reaches the screen if it is applied here, at raster time. Opaque +/// palettes carry `1.0`, so every call is a no-op with `glassmorphism` off. +fn chrome_raster_alpha(base: f32, palette_alpha: f32) -> f32 { + (base * palette_alpha).clamp(0.0, 1.0) +} + /// Resolve categorical status-rail geometry. Activity is deliberately broken /// into three equal-looking segments so it cannot be mistaken for a 100% /// progress bar; bell is a centered notch; attention is a solid rail. @@ -366,7 +379,7 @@ fn composite_sidebar_band_cache( model: &SidebarDrawModel, ) { let flat_style = CardStyle { - background: rgb_to_rgba(active_theme(&gpu.theme, &gpu.preview_theme).default_bg), + background: chrome().backdrop_rgba(active_theme(&gpu.theme, &gpu.preview_theme).default_bg), border_color: [0.0; 4], focus_color: [0.0, 0.0, 0.0, 1.0], corner_radius: 0.0, @@ -516,6 +529,10 @@ fn ensure_sidebar_pipelines( { gpu.chrome_textures.sidebar_card = Some(OverviewChromeCardPipeline { format: surface_format, + // Cards composite *into the band texture*, not onto the window, + // so they never need to rewrite the window's alpha — this one + // stays alpha-blending in both modes. + glass: false, // Static sidebar cards now render as transparent text layers over a // seamless band; alpha blending preserves already-drawn chrome where // those layers have no fill. @@ -528,22 +545,37 @@ fn ensure_sidebar_pipelines( #[cfg(debug_assertions)] gpu.chrome_textures.record_rebuild(); } + let band_glass = chrome().is_glass(); if gpu .chrome_textures .sidebar_band_card .as_ref() - .is_none_or(|card| card.format != surface_format) + .is_none_or(|card| card.format != surface_format || card.glass != band_glass) { gpu.chrome_textures.sidebar_band_card = Some(OverviewChromeCardPipeline { format: surface_format, - // The band backdrop is transparent outside its text runs; plain - // alpha blending leaves the pane pass's clear color + background - // image untouched there, so the band background is pixel-identical - // to the panes'. + glass: band_glass, + // Opaque palette: the band backdrop is transparent outside its + // text runs, and plain alpha blending leaves the pane pass's + // clear color + background image untouched there, so the band + // background is pixel-identical to the panes'. + // + // Glass palette: the band carries its own `backdrop_alpha` (see + // `draw_sidebar_band_runs`) and has to *lower* the window alpha + // the pane pass already wrote — blending can only drive alpha up + // (`src + dst·(1-src)`), so the panel could never end up more + // see-through than the panes it sits beside. `ALPHA_REPLACE` + // writes the band's alpha instead of accumulating it. Safe here + // because the band is a plain rectangle (`corner_radius = 0`), so + // no coverage-faded corner can punch a hole in the window. pipeline: CardPipeline::new( &gpu.device, surface_format, - wgpu::BlendState::ALPHA_BLENDING, + if band_glass { + CardPipeline::ALPHA_REPLACE + } else { + wgpu::BlendState::ALPHA_BLENDING + }, ), }); #[cfg(debug_assertions)] @@ -641,11 +673,36 @@ fn draw_sidebar_band_runs(gpu: &mut GpuState, model: &SidebarDrawModel, band_siz band_size, model.grid, base_bg, - 0.0, + // Opaque palette: a fully transparent backdrop, so the band shows the + // pane pass's own pixels (clear color + background image) and the two + // surfaces match exactly. + // + // Glass palette: the band is a pane of its own, tinted with the theme + // background at `backdrop_alpha`. This is the only place that alpha + // can enter the picture — the composite reads it from the sampled + // texture (`card.wgsl`: `coverage * tex.a`) and ignores + // `CardStyle::background` entirely — and it is paired with the + // alpha-replacing blend selected in `ensure_sidebar_pipelines`, so + // the sidebar ends up *more* see-through than the panes rather than + // less. + sidebar_band_backdrop_alpha(chrome()), &model.runs, ); } +/// The band's clear alpha: `0.0` under an opaque palette (show the panes' +/// own pixels), the palette's `backdrop_alpha` under a glass one (the band +/// becomes its own frosted pane). Not a multiplication — `0.0` scaled by any +/// alpha is still `0.0`, which is exactly how the glass backdrop went +/// missing before. +fn sidebar_band_backdrop_alpha(palette: crate::chrome::ChromePalette) -> f32 { + if palette.is_glass() { + palette.backdrop_alpha + } else { + 0.0 + } +} + /// Pass 1b — hairline divider over the band's rightmost pixel(s): a solid /// `chrome().divider` strip that gives the seam a crisp edge against the pane /// background (the terminal keeps its own theme, so the two surfaces otherwise @@ -780,11 +837,12 @@ fn draw_sidebar_new_button( btn_size, GridSize { cols: 1, rows: 1 }, chrome().card, - model.background_opacity, + chrome_raster_alpha(model.background_opacity, chrome().surface_alpha), &[], ); + let palette = chrome(); let button_style = CardStyle { - background: rgb_to_rgba(chrome().card), + background: palette.surface_rgba(palette.card), border_color: [0.0; 4], focus_color: [0.0; 4], corner_radius: TOOLBAR_BUTTON_RADIUS * model.scale, @@ -914,7 +972,7 @@ fn draw_sidebar_cards( } let panel_bg = active_theme(&gpu.theme, &gpu.preview_theme).default_bg; let card_style = CardStyle { - background: rgb_to_rgba(sidebar_card_bg(panel_bg)), + background: chrome().surface_rgba(sidebar_card_bg(panel_bg)), border_color: [0.0; 4], focus_color: [0.0; 4], corner_radius: 0.0, @@ -1259,7 +1317,7 @@ fn draw_sidebar_drag( }; let panel_bg = active_theme(&gpu.theme, &gpu.preview_theme).default_bg; let card_style = CardStyle { - background: rgb_to_rgba(sidebar_card_bg(panel_bg)), + background: chrome().surface_rgba(sidebar_card_bg(panel_bg)), border_color: [0.0; 4], focus_color: [0.0; 4], corner_radius: 0.0, @@ -1294,7 +1352,8 @@ fn draw_sidebar_drag( }, drag.grid, drag.bg, - model.background_opacity, + // The floating drag ghost is a card, so it frosts like one. + chrome_raster_alpha(model.background_opacity, chrome().surface_alpha), &drag.runs, ); card.pipeline.overlay_texture_cards( @@ -1361,12 +1420,16 @@ fn draw_sidebar_menu(gpu: &mut GpuState, model: &SidebarDrawModel, band_size: Pi }, menu.grid, chrome().pill, - 1.0, + // The one chrome surface that used to rasterize fully opaque: with a + // glass palette installed, a solid menu popup was the only opaque + // plane left on the sidebar. + chrome_raster_alpha(1.0, chrome().pill_alpha), &menu.runs, ); + let palette = chrome(); let menu_style = CardStyle { - background: rgb_to_rgba(chrome().pill), - border_color: rgb_to_rgba(chrome().border), + background: palette.pill_rgba(palette.pill), + border_color: rgb_to_rgba(palette.border), focus_color: [0.0; 4], corner_radius: crate::chrome::RADIUS_SM * model.scale, border_width: 1.0 * model.scale, @@ -1473,6 +1536,63 @@ mod tests { assert_eq!(sidebar_card_fill_opacity(true), 1.0); } + // The composite takes a surface's alpha from the texture it sampled + // (`card.wgsl`: `coverage * tex.a`), never from `CardStyle::background` — + // so the palette alpha has to be folded into the raster clear. Opaque + // palettes must leave every raster exactly as it was. + #[test] + fn chrome_raster_alpha_is_a_no_op_under_an_opaque_palette() { + for base in [0.0, 0.5, 0.85, 1.0] { + assert_eq!(chrome_raster_alpha(base, 1.0), base); + } + let opaque = crate::chrome::CHROME_DARK; + assert_eq!(chrome_raster_alpha(1.0, opaque.pill_alpha), 1.0); + assert_eq!(chrome_raster_alpha(0.85, opaque.surface_alpha), 0.85); + } + + #[test] + fn chrome_raster_alpha_frosts_under_a_glass_palette() { + let glass = crate::chrome::glassify(crate::chrome::CHROME_DARK); + // The menu popup: opaque raster, frosted by the pill alpha. + assert_eq!(chrome_raster_alpha(1.0, glass.pill_alpha), glass.pill_alpha); + assert!(chrome_raster_alpha(1.0, glass.pill_alpha) < 1.0); + // A window-opacity-scaled surface frosts on top of that scaling, and + // the product can never leave the unit range. + let scaled = chrome_raster_alpha(0.5, glass.surface_alpha); + assert!(scaled < 0.5 && scaled > 0.0); + assert_eq!( + chrome_raster_alpha(2.0, glass.surface_alpha), + 1.0_f32.min(2.0 * glass.surface_alpha) + ); + assert!(chrome_raster_alpha(10.0, glass.pill_alpha) <= 1.0); + } + + // The band is the sidebar's own pane: transparent under an opaque + // palette (so it shows the panes' pixels — clear color and background + // image — and matches them exactly), its own frosted plane under a glass + // one. A multiply can't express this: the opaque case is `0.0`, and + // `0.0 * backdrop_alpha` is still `0.0` — which is how the glass backdrop + // reached nothing at all before. + #[test] + fn sidebar_band_stays_pane_transparent_under_an_opaque_palette() { + for opaque in [crate::chrome::CHROME_DARK, crate::chrome::CHROME_LIGHT] { + assert_eq!(sidebar_band_backdrop_alpha(opaque), 0.0); + } + } + + #[test] + fn sidebar_band_carries_the_backdrop_alpha_under_a_glass_palette() { + for base in [crate::chrome::CHROME_DARK, crate::chrome::CHROME_LIGHT] { + let glass = crate::chrome::glassify(base); + let alpha = sidebar_band_backdrop_alpha(glass); + assert_eq!(alpha, glass.backdrop_alpha); + // Strictly between "invisible" and "opaque" — the band has to be + // a real, tinted pane for the alpha-replacing composite to be + // worth anything. + assert!(alpha > 0.0 && alpha < 1.0, "alpha={alpha}"); + } + } + #[test] fn status_rail_geometry_distinguishes_activity_bell_and_attention() { let card = SidebarRect::new(8, 20, 216, 100); diff --git a/crates/noa-app/src/app/state.rs b/crates/noa-app/src/app/state.rs index 117b171..91096ed 100644 --- a/crates/noa-app/src/app/state.rs +++ b/crates/noa-app/src/app/state.rs @@ -57,6 +57,13 @@ pub(super) struct GpuState { /// 1x1 translucent-black texture drawn as a full-pane card behind the /// palette; the modal scrim dimming the pane underneath. pub(super) palette_scrim: Option<(wgpu::Texture, wgpu::TextureView)>, + /// 1x1 fully transparent texture, the source for the modal card's + /// drop-shadow pass. The shadow pass exists only for the glow *outside* + /// the card shape, and `card.wgsl` returns that glow before it samples + /// the source at all — so a transparent source makes the pass contribute + /// nothing inside the card, leaving the fill to the single pass that + /// carries it (see `sidebar::palette::composite_modal_card`). + pub(super) palette_shadow_source: Option<(wgpu::Texture, wgpu::TextureView)>, } /// The single chokepoint every draw-path theme read must go through @@ -257,6 +264,18 @@ pub(super) struct WindowState { pub(super) last_mouse_physical_position: Option>, pub(super) active_split_drag: Option, pub(super) occluded: bool, + /// Whether this window was *created* with `with_transparent(true)`. + /// AppKit fixes a window's opacity at creation — a window built opaque + /// can never become see-through in place, and one built transparent + /// stays capable of it — so this, not the current effective + /// `background-opacity`, is what decides whether a transparency change + /// can preview live (R-11's gate, `ThemeSettings::opaque_at_startup`). + /// Recomputing it from the live opacity would misreport both directions: + /// a `glassmorphism = true` arriving by config reload lowers the opacity + /// without making an opaque window transparent, and turning it back off + /// raises the opacity in a window that is still perfectly capable of + /// transparency. + pub(super) created_transparent: bool, pub(super) title: String, /// A user-set tab title (tab-title REQ-TTL-2/5). While `Some`, it masks /// the shell-driven title on the native tab label and overview tile; @@ -593,6 +612,15 @@ pub(super) struct OverviewPillKey { /// cached pill texture. pub(super) page: usize, pub(super) rect: PaneRectApp, + /// The pill face color the cached texture was rasterized with, as raw + /// `f32` bits (this key is `Eq`, and bit equality is exactly the + /// "same color" test wanted here). Folded in because the chrome palette + /// is swappable at runtime — a theme polarity flip or a `glassmorphism` + /// toggle changes this color, and neither `query`/`count`/`page`/`rect` + /// nor `ChromeTextures::reset` (which owns only the sidebar's textures) + /// would otherwise invalidate the pill, leaving an opaque pill on a + /// frosted surface until the window happened to resize. + pub(super) chrome_pill: [u32; 4], } /// The unfiltered TAB order and focused pane per tab that @@ -617,6 +645,12 @@ pub(super) struct OverviewZoomAnim { pub(super) struct OverviewChromeCardPipeline { pub(super) format: wgpu::TextureFormat, + /// Whether `pipeline` was built with the glass (alpha-replacing) blend. + /// A pipeline's blend state is fixed at creation, so this is part of the + /// cache key: a `glassmorphism` toggle has to rebuild, and keying on it + /// explicitly means the rebuild does not depend on + /// `ChromeTextures::reset` happening to run on that path. + pub(super) glass: bool, pub(super) pipeline: CardPipeline, } @@ -719,6 +753,9 @@ mod theme_settings_session_tests { cursor_style: noa_config::CursorShape::Block, background_opacity: 1.0, background_blur_radius: 0, + configured_background_opacity: 1.0, + configured_background_blur_radius: 0, + window_created_transparent: false, background_image: String::new(), background_image_opacity: 1.0, background_image_position: noa_config::BackgroundImagePosition::Center, @@ -732,6 +769,7 @@ mod theme_settings_session_tests { sidebar_width: noa_config::DEFAULT_SIDEBAR_WIDTH, sidebar_font_size: noa_config::DEFAULT_SIDEBAR_FONT_SIZE, quick_terminal_size: 0.4, + glassmorphism: false, confirm_quit: true, send_selection_send_enter: false, font_family: "Menlo".to_string(), diff --git a/crates/noa-app/src/chrome.rs b/crates/noa-app/src/chrome.rs index 414e7a7..7ec5d8b 100644 --- a/crates/noa-app/src/chrome.rs +++ b/crates/noa-app/src/chrome.rs @@ -83,6 +83,99 @@ pub struct ChromePalette { pub dot_green: Rgb, pub dot_yellow: Rgb, pub dot_red: Rgb, + /// Alpha for the chrome backdrop (sidebar panel fill, overview surface + /// clear). `1.0` in both opaque palettes; below `1.0` only under + /// [`glassify`] (`glassmorphism = true`). + pub backdrop_alpha: f32, + /// Alpha for large chrome surfaces — sidebar/overview cards and title + /// bands. `1.0` in both opaque palettes. + pub surface_alpha: f32, + /// Alpha for small transient chrome — search/hint pills, menu popups. + /// Kept above [`Self::surface_alpha`] so short text on a pill stays + /// legible against whatever shows through. `1.0` in both opaque palettes. + pub pill_alpha: f32, +} + +impl ChromePalette { + /// Whether this palette is a glass variant. Most alpha-aware call sites + /// multiply unconditionally rather than branching on this; the ones that + /// do branch are the two the multiply cannot express — the sidebar band's + /// clear alpha (`0.0` opaque, `backdrop_alpha` glass: scaling `0.0` would + /// stay `0.0`) and the blend state its composite pipeline is built with, + /// which is fixed at pipeline creation. + pub fn is_glass(&self) -> bool { + self.surface_alpha < 1.0 + } + + /// Straight display-space RGBA for a backdrop fill. + pub fn backdrop_rgba(&self, color: Rgb) -> [f32; 4] { + with_alpha(color, self.backdrop_alpha) + } + + /// Straight display-space RGBA for a card / band face. + pub fn surface_rgba(&self, color: Rgb) -> [f32; 4] { + with_alpha(color, self.surface_alpha) + } + + /// Straight display-space RGBA for a pill / popup face. + pub fn pill_rgba(&self, color: Rgb) -> [f32; 4] { + with_alpha(color, self.pill_alpha) + } +} + +/// Backdrop alpha under `glassmorphism = true`. The chrome composites with +/// `CardPipeline::ALPHA_REPLACE`, so this *is* the final window alpha over +/// the chrome's area rather than a factor on top of `background-opacity` — +/// most of the blurred desktop reads straight through the panel. At this +/// level the face is barely a tint and the pane is held together by its rim +/// and its text — which is the look, not a compromise on the way to it. +const GLASS_BACKDROP_ALPHA: f32 = 0.18; +/// Card / band alpha under `glassmorphism = true`. +const GLASS_SURFACE_ALPHA: f32 = 0.16; +/// Pill / popup alpha under `glassmorphism = true` — deliberately the most +/// opaque of the three; pills carry the smallest text. +const GLASS_PILL_ALPHA: f32 = 0.34; +/// Alpha for the shared overlay surfaces — command palette, search prompt, +/// confirm dialogs — under `glassmorphism = true`. Higher than the chrome +/// alphas above because these cards float over the *terminal grid* rather +/// than over the desktop: they blend with running output, so they keep more +/// weight than the chrome faces above — enough that the text under a dialog +/// reads as texture behind glass rather than as competing content. +const GLASS_OVERLAY_ALPHA: f32 = 0.68; +/// How far the frosted rim pulls the border tokens toward [`ChromePalette::fg`]. +/// A translucent face loses the face-vs-backdrop luminance step that normally +/// draws the card edge, so the edge has to be carried by the stroke instead — +/// and the more transparent the face, the more of that job the rim inherits. +const GLASS_RIM_MIX: f32 = 0.70; + +/// Derive the frosted-glass variant of an opaque palette: translucent faces +/// plus a brightened rim so each surface still reads as a distinct plane once +/// the face alone no longer separates it from the backdrop. Hues are +/// untouched, so a glass palette keeps its light/dark polarity. +pub fn glassify(base: ChromePalette) -> ChromePalette { + ChromePalette { + border: mix(base.border, base.fg, GLASS_RIM_MIX), + pill_border: mix(base.pill_border, base.fg, GLASS_RIM_MIX), + backdrop_alpha: GLASS_BACKDROP_ALPHA, + surface_alpha: GLASS_SURFACE_ALPHA, + pill_alpha: GLASS_PILL_ALPHA, + ..base + } +} + +/// Linear channel mix (`t` = 0 → `a`, 1 → `b`). +fn mix(a: Rgb, b: Rgb, t: f32) -> Rgb { + let ch = |a: u8, b: u8| (a as f32 + (b as f32 - a as f32) * t).round() as u8; + Rgb::new(ch(a.r, b.r), ch(a.g, b.g), ch(a.b, b.b)) +} + +fn with_alpha(color: Rgb, alpha: f32) -> [f32; 4] { + [ + color.r as f32 / 255.0, + color.g as f32 / 255.0, + color.b as f32 / 255.0, + alpha, + ] } /// The original dark chrome — byte-identical to the `CHROME_*` constants. @@ -102,6 +195,9 @@ pub const CHROME_DARK: ChromePalette = ChromePalette { dot_green: CHROME_DOT_GREEN, dot_yellow: CHROME_DOT_YELLOW, dot_red: CHROME_DOT_RED, + backdrop_alpha: 1.0, + surface_alpha: 1.0, + pill_alpha: 1.0, }; /// Light-polarity chrome for light terminal themes: the same relationships as @@ -124,6 +220,9 @@ pub const CHROME_LIGHT: ChromePalette = ChromePalette { dot_green: Rgb::new(0x2c, 0x9e, 0x50), dot_yellow: Rgb::new(0xb9, 0x8a, 0x1e), dot_red: Rgb::new(0xe0, 0x31, 0x31), + backdrop_alpha: 1.0, + surface_alpha: 1.0, + pill_alpha: 1.0, }; /// The chrome polarity chosen from the resolved terminal theme, set at @@ -138,12 +237,17 @@ static ACTIVE_PALETTE: parking_lot::RwLock> = parking_lot: /// confirm swapping in a newly resolved theme, or a second window reusing /// the shared GPU) now replaces it rather than no-op'ing — see /// [`swap_palette`] to install an already-built [`ChromePalette`] directly. -pub fn select_palette(theme_is_light: bool) { - swap_palette(if theme_is_light { +/// `glass` installs the [`glassify`]'d variant of the chosen polarity +/// (`glassmorphism = true`); `false` installs the byte-identical opaque +/// palette this function has always installed, so the default path is +/// unchanged. +pub fn select_palette(theme_is_light: bool, glass: bool) { + let base = if theme_is_light { CHROME_LIGHT } else { CHROME_DARK - }); + }; + swap_palette(if glass { glassify(base) } else { base }); } /// Replace the active chrome palette in place (theme-settings-ui R-13's @@ -151,6 +255,17 @@ pub fn select_palette(theme_is_light: bool) { /// call; no GPU/renderer state lives here, so this alone never needs a /// texture rebuild (that is [`super::state::ChromeTextures::reset`]'s job). pub fn swap_palette(new: ChromePalette) { + // The overlay surfaces (command palette, prompts, dialogs) are painted + // from `noa_render::OverlayStyle`, not from this palette, but they are + // the same UI language and must frost together — so the one place that + // installs a palette also installs their alpha. Doing it here rather + // than at each `select_palette` call site means no path can install a + // glass palette and leave the overlays opaque. + noa_render::set_overlay_surface_alpha(if new.is_glass() { + GLASS_OVERLAY_ALPHA + } else { + 1.0 + }); *ACTIVE_PALETTE.write() = Some(new); } @@ -232,6 +347,114 @@ mod tests { swap_palette(CHROME_DARK); } + // The whole point of the default-off contract: `glassmorphism = false` + // must install exactly the palette that existed before the flag did, so + // every alpha-aware call site multiplies by 1.0 and every color is + // untouched. A regression here is a silent visual/perf change for users + // who never opted in. + #[test] + fn opaque_palettes_are_fully_opaque_and_unmodified() { + for base in [CHROME_DARK, CHROME_LIGHT] { + assert_eq!(base.backdrop_alpha, 1.0); + assert_eq!(base.surface_alpha, 1.0); + assert_eq!(base.pill_alpha, 1.0); + assert!(!base.is_glass()); + assert_eq!(base.surface_rgba(base.card), rgba(base.card)); + assert_eq!(base.backdrop_rgba(base.bg), rgba(base.bg)); + assert_eq!(base.pill_rgba(base.pill), rgba(base.pill)); + } + } + + #[test] + fn select_palette_off_installs_the_opaque_palette() { + let _guard = PALETTE_TEST_LOCK.lock(); + select_palette(false, false); + assert_eq!(palette(), CHROME_DARK); + select_palette(true, false); + assert_eq!(palette(), CHROME_LIGHT); + swap_palette(CHROME_DARK); + } + + #[test] + fn select_palette_on_installs_the_glass_palette() { + let _guard = PALETTE_TEST_LOCK.lock(); + select_palette(false, true); + let dark_glass = palette(); + assert_eq!(dark_glass, glassify(CHROME_DARK)); + assert!(dark_glass.is_glass()); + select_palette(true, true); + assert_eq!(palette(), glassify(CHROME_LIGHT)); + swap_palette(CHROME_DARK); + } + + // Glass changes alpha and the rim, never the face hues — so a glass + // palette keeps its light/dark polarity and every hue-derived cue (status + // dots, accent ring, text) stays exactly where the opaque palette put it. + #[test] + fn glassify_preserves_hues_and_only_lightens_the_rim() { + for base in [CHROME_DARK, CHROME_LIGHT] { + let glass = glassify(base); + assert_eq!(glass.bg, base.bg); + assert_eq!(glass.card, base.card); + assert_eq!(glass.card_selected, base.card_selected); + assert_eq!(glass.band, base.band); + assert_eq!(glass.accent, base.accent); + assert_eq!(glass.fg, base.fg); + assert_eq!(glass.dim_fg, base.dim_fg); + assert_eq!(glass.dot_red, base.dot_red); + assert_ne!(glass.border, base.border); + assert_ne!(glass.pill_border, base.pill_border); + assert!(glass.backdrop_alpha < 1.0); + assert!(glass.surface_alpha < 1.0); + // Pills carry the smallest text, so they stay the most opaque. + assert!(glass.pill_alpha > glass.surface_alpha); + } + } + + // The overlay surfaces frost with the chrome, from the same install: + // a glass palette installs the overlay alpha, and the opaque palettes + // put it back to 1.0 so turning `glassmorphism` off leaves no + // half-translucent command palette behind. + #[test] + fn swapping_a_palette_installs_the_matching_overlay_alpha() { + let _guard = PALETTE_TEST_LOCK.lock(); + swap_palette(glassify(CHROME_DARK)); + assert_eq!(noa_render::overlay_surface_alpha(), GLASS_OVERLAY_ALPHA); + + swap_palette(CHROME_DARK); + assert_eq!(noa_render::overlay_surface_alpha(), 1.0); + + select_palette(true, true); + assert_eq!(noa_render::overlay_surface_alpha(), GLASS_OVERLAY_ALPHA); + select_palette(true, false); + assert_eq!(noa_render::overlay_surface_alpha(), 1.0); + swap_palette(CHROME_DARK); + } + + // The overlay cards float over the terminal grid rather than over the + // desktop, so they must keep more weight than the chrome surfaces that + // sit directly on the blurred background — while still being glass. + #[test] + fn overlay_alpha_stays_above_the_chrome_surface_alphas() { + let _guard = PALETTE_TEST_LOCK.lock(); + swap_palette(glassify(CHROME_DARK)); + let glass = palette(); + let overlay = noa_render::overlay_surface_alpha(); + assert!(overlay > glass.pill_alpha, "overlay={overlay}"); + assert!(overlay < 1.0, "overlay={overlay}"); + swap_palette(CHROME_DARK); + } + + #[test] + fn glass_alphas_reach_the_rgba_helpers() { + let glass = glassify(CHROME_DARK); + assert_eq!(glass.surface_rgba(glass.card)[3], glass.surface_alpha); + assert_eq!(glass.backdrop_rgba(glass.bg)[3], glass.backdrop_alpha); + assert_eq!(glass.pill_rgba(glass.pill)[3], glass.pill_alpha); + // RGB is untouched by the alpha helpers. + assert_eq!(glass.surface_rgba(glass.card)[..3], rgba(glass.card)[..3]); + } + // Deadlock regression: `palette()` must copy the value out and drop its // read guard before returning, so a caller can safely call `palette()` // again from inside a closure that already "holds" a previous read diff --git a/crates/noa-app/src/cli.rs b/crates/noa-app/src/cli.rs index ed0b3d2..bb1ec6e 100644 --- a/crates/noa-app/src/cli.rs +++ b/crates/noa-app/src/cli.rs @@ -344,6 +344,7 @@ fn show_config_output(config: &StartupConfig) -> String { "background-blur-radius", &config.background_blur_radius.to_string(), ); + push_line(&mut out, "glassmorphism", &config.glassmorphism.to_string()); push_line( &mut out, "scrollback-limit", diff --git a/crates/noa-app/src/localtime.rs b/crates/noa-app/src/localtime.rs index ab3123f..1a71b40 100644 --- a/crates/noa-app/src/localtime.rs +++ b/crates/noa-app/src/localtime.rs @@ -5,7 +5,7 @@ //! Unix-epoch second count before decomposing it. Isolated here because the //! only portable source is platform-specific (Foundation on macOS); everywhere //! else it degrades to UTC (offset 0), which keeps the dominant relative forms -//! ("3分前", "2時間前") exact and only shifts the absolute "昨日 HH:MM" clock. +//! (`3m ago`, `2h ago`) exact and only shifts the absolute `Yday HH:MM` clock. //! //! Queried once per sidebar publish (a cheap Foundation call), not cached, so a //! DST transition or timezone change is picked up without app restart. diff --git a/crates/noa-app/src/macos_overlay/tests.rs b/crates/noa-app/src/macos_overlay/tests.rs index 919ab78..56b4445 100644 --- a/crates/noa-app/src/macos_overlay/tests.rs +++ b/crates/noa-app/src/macos_overlay/tests.rs @@ -16,6 +16,9 @@ fn settings_init() -> ThemeSettingsInit { // below uses `transparent_settings_init` instead. background_opacity: 1.0, background_blur_radius: 0, + configured_background_opacity: 1.0, + configured_background_blur_radius: 0, + window_created_transparent: false, background_image: String::new(), background_image_opacity: 1.0, background_image_position: noa_config::BackgroundImagePosition::Center, @@ -29,6 +32,7 @@ fn settings_init() -> ThemeSettingsInit { sidebar_width: noa_config::DEFAULT_SIDEBAR_WIDTH, sidebar_font_size: noa_config::DEFAULT_SIDEBAR_FONT_SIZE, quick_terminal_size: 0.4, + glassmorphism: false, confirm_quit: true, send_selection_send_enter: false, font_family: "Menlo".to_string(), @@ -57,6 +61,10 @@ fn settings_init() -> ThemeSettingsInit { fn transparent_settings_init() -> ThemeSettingsInit { ThemeSettingsInit { background_opacity: 0.9, + configured_background_opacity: 0.9, + // R-11's gate reads the window's creation-time capability now, not + // this opacity — a session over a see-through window. + window_created_transparent: true, ..settings_init() } } @@ -174,6 +182,9 @@ fn test_theme_settings_init() -> ThemeSettingsInit { cursor_style: noa_config::CursorShape::Block, background_opacity: 1.0, background_blur_radius: 0, + configured_background_opacity: 1.0, + configured_background_blur_radius: 0, + window_created_transparent: false, background_image: String::new(), background_image_opacity: 1.0, background_image_position: noa_config::BackgroundImagePosition::Center, @@ -187,6 +198,7 @@ fn test_theme_settings_init() -> ThemeSettingsInit { sidebar_width: noa_config::DEFAULT_SIDEBAR_WIDTH, sidebar_font_size: noa_config::DEFAULT_SIDEBAR_FONT_SIZE, quick_terminal_size: 0.4, + glassmorphism: false, confirm_quit: true, send_selection_send_enter: false, font_family: "Menlo".to_string(), diff --git a/crates/noa-app/src/macos_window.rs b/crates/noa-app/src/macos_window.rs index 93be50f..eccae31 100644 --- a/crates/noa-app/src/macos_window.rs +++ b/crates/noa-app/src/macos_window.rs @@ -672,38 +672,59 @@ fn top_chrome_inset_px_impl(_window: &Window) -> Option { /// call finds and refreshes the existing view instead of stacking a second. #[cfg(target_os = "macos")] const TITLEBAR_BACKDROP_ID: &str = "noa.titlebar.opaque-backdrop"; - -/// Install (or refresh) an opaque, `bg`-colored view filling the native -/// titlebar + tab-bar strip. +/// The frosted variant's identifier. Deliberately distinct from +/// [`TITLEBAR_BACKDROP_ID`] so a `glassmorphism` toggle can tell the two +/// apart and swap them: the views are different AppKit classes, so the +/// color-refresh reuse path can't convert one into the other. +const TITLEBAR_GLASS_BACKDROP_ID: &str = "noa.titlebar.glass-backdrop"; + +/// Install (or refresh) the view filling the native titlebar + tab-bar strip: +/// an opaque `bg`-colored layer, or — under `glass` — an `NSVisualEffectView` +/// that blurs the desktop behind the window. /// /// Meaningful for translucent normal windows (`background-opacity < 1.0`) with /// visible AppKit titlebar/tab chrome: AppKit composites its tab chrome — the /// lazily-allocated hover highlight `NSVisualEffectView` especially — against /// undefined semi-transparent underlay pixels, which surfaces as magenta -/// diagonal-stripe garbage on some machines. Backing the strip with an opaque -/// layer (the iTerm2/Ghostty approach) gives that chrome defined content to -/// composite over. No-op off macOS or when the AppKit hierarchy can't be -/// reached. +/// diagonal-stripe garbage on some machines. Both variants exist to give that +/// chrome defined content to composite over; the opaque one (the +/// iTerm2/Ghostty approach) is the default, and the frosted one keeps that +/// guarantee — a vibrancy view's own backing store is AppKit-managed and +/// always defined — while letting the tab strip read as glass instead of a +/// solid bar, which is the whole point of `glassmorphism`. No-op off macOS or +/// when the AppKit hierarchy can't be reached. /// -/// Idempotent via [`TITLEBAR_BACKDROP_ID`]; a repeat call (e.g. a theme -/// reload) updates the existing view's color rather than adding another. -pub(crate) fn install_titlebar_backdrop(window: &Window, bg: noa_core::Rgb) { - install_titlebar_backdrop_impl(window, bg); +/// Idempotent per variant; a repeat call (e.g. a theme reload) updates the +/// existing view rather than adding another, and a call in the *other* mode +/// removes the stale variant first. +pub(crate) fn install_titlebar_backdrop(window: &Window, bg: noa_core::Rgb, glass: bool) { + install_titlebar_backdrop_impl(window, bg, glass); } -/// Remove Noa's titlebar backdrop view when a full-size content view can supply -/// defined pixels itself, such as a visible terminal background image. +/// Remove Noa's titlebar backdrop view (either variant) when a full-size +/// content view can supply defined pixels itself, such as a visible terminal +/// background image. pub(crate) fn remove_titlebar_backdrop(window: &Window) { remove_titlebar_backdrop_impl(window); } #[cfg(target_os = "macos")] -fn install_titlebar_backdrop_impl(window: &Window, bg: noa_core::Rgb) { +fn install_titlebar_backdrop_impl(window: &Window, bg: noa_core::Rgb, glass: bool) { use objc2::msg_send; use objc2::runtime::{AnyClass, AnyObject}; use objc2_foundation::{NSRect, NSString}; use raw_window_handle::{HasWindowHandle, RawWindowHandle}; + // NSVisualEffectMaterial.headerView — the material AppKit itself uses for + // titlebar/toolbar strips, so the tab bar keeps its native weighting. + const NS_VISUAL_EFFECT_MATERIAL_HEADER_VIEW: isize = 10; + // NSVisualEffectBlendingMode.behindWindow — blur what is *behind the + // window* (the desktop), not the window's own content below the strip. + const NS_VISUAL_EFFECT_BLENDING_BEHIND_WINDOW: isize = 0; + // NSVisualEffectState.active — stay frosted even when the window is not + // key, matching how the rest of noa's chrome ignores activation state. + const NS_VISUAL_EFFECT_STATE_ACTIVE: isize = 1; + // NSWindowOrderingMode::Below — order the backdrop behind its siblings so // the tab bar and title controls keep drawing on top of it. const NS_WINDOW_BELOW: isize = -1; @@ -721,7 +742,17 @@ fn install_titlebar_backdrop_impl(window: &Window, bg: noa_core::Rgb) { return; }; let ns_view = appkit.ns_view.as_ptr().cast::(); - let identifier = NSString::from_str(TITLEBAR_BACKDROP_ID); + let identifier = NSString::from_str(if glass { + TITLEBAR_GLASS_BACKDROP_ID + } else { + TITLEBAR_BACKDROP_ID + }); + // The variant this call replaces, if a previous one installed it. + let stale_identifier = NSString::from_str(if glass { + TITLEBAR_BACKDROP_ID + } else { + TITLEBAR_GLASS_BACKDROP_ID + }); // SAFETY: `ns_view` is winit's live AppKit `NSView` for this window and we // are on the main (window-owning) thread. Every selector below is @@ -793,31 +824,53 @@ fn install_titlebar_backdrop_impl(window: &Window, bg: noa_core::Rgb) { // keeps objc2's debug-mode encoding verification satisfied. let cg_color: *mut crate::macos_overlay::cg::CGColor = msg_send![color, CGColor]; - // Idempotency: reuse an existing backdrop, just refreshing its color. + // Idempotency: reuse this variant's existing backdrop (refreshing the + // opaque one's color — the frosted one has no theme-derived state), + // and drop the other variant's if a `glassmorphism` toggle left it + // behind. Both passes walk the same snapshot; the reuse `return` is + // taken only after the stale removal, so the two never coexist. let container_subviews: *mut AnyObject = msg_send![container, subviews]; if !container_subviews.is_null() { let n: usize = msg_send![container_subviews, count]; + let mut reusable: *mut AnyObject = std::ptr::null_mut(); for i in 0..n { let view: *mut AnyObject = msg_send![container_subviews, objectAtIndex: i]; if view.is_null() { continue; } let ident: *mut AnyObject = msg_send![view, identifier]; - if !ident.is_null() { - let same: bool = msg_send![ident, isEqualToString: &*identifier]; - if same { - let layer: *mut AnyObject = msg_send![view, layer]; - if !layer.is_null() { - let _: () = msg_send![layer, setBackgroundColor: cg_color]; - } - return; + if ident.is_null() { + continue; + } + let same: bool = msg_send![ident, isEqualToString: &*identifier]; + if same { + reusable = view; + continue; + } + let stale: bool = msg_send![ident, isEqualToString: &*stale_identifier]; + if stale { + let _: () = msg_send![view, removeFromSuperview]; + } + } + if !reusable.is_null() { + if !glass { + let layer: *mut AnyObject = msg_send![reusable, layer]; + if !layer.is_null() { + let _: () = msg_send![layer, setBackgroundColor: cg_color]; } } + return; } } - // Create the opaque, layer-backed backdrop sized to the container. - let Some(view_class) = AnyClass::get(c"NSView") else { + // Create the backdrop sized to the container: a vibrancy view under + // `glass`, else the opaque layer-backed one. + let class_name = if glass { + c"NSVisualEffectView" + } else { + c"NSView" + }; + let Some(view_class) = AnyClass::get(class_name) else { return; }; let bounds: NSRect = msg_send![container, bounds]; @@ -827,13 +880,19 @@ fn install_titlebar_backdrop_impl(window: &Window, bg: noa_core::Rgb) { return; } let _: () = msg_send![view, setIdentifier: &*identifier]; - let _: () = msg_send![view, setWantsLayer: true]; let _: () = msg_send![view, setAutoresizingMask: NS_VIEW_WIDTH_SIZABLE | NS_VIEW_HEIGHT_SIZABLE]; - let layer: *mut AnyObject = msg_send![view, layer]; - if !layer.is_null() { - let _: () = msg_send![layer, setBackgroundColor: cg_color]; - let _: () = msg_send![layer, setOpaque: true]; + if glass { + let _: () = msg_send![view, setMaterial: NS_VISUAL_EFFECT_MATERIAL_HEADER_VIEW]; + let _: () = msg_send![view, setBlendingMode: NS_VISUAL_EFFECT_BLENDING_BEHIND_WINDOW]; + let _: () = msg_send![view, setState: NS_VISUAL_EFFECT_STATE_ACTIVE]; + } else { + let _: () = msg_send![view, setWantsLayer: true]; + let layer: *mut AnyObject = msg_send![view, layer]; + if !layer.is_null() { + let _: () = msg_send![layer, setBackgroundColor: cg_color]; + let _: () = msg_send![layer, setOpaque: true]; + } } // Positioned below all existing subviews so tab-bar chrome stays on top. let _: () = msg_send![ @@ -842,11 +901,17 @@ fn install_titlebar_backdrop_impl(window: &Window, bg: noa_core::Rgb) { positioned: NS_WINDOW_BELOW, relativeTo: std::ptr::null_mut::(), ]; + // `alloc`/`initWithFrame:` handed us a +1 reference and `addSubview:` + // took its own; balance ours here, or the view outlives every later + // `removeFromSuperview` (which only drops the container's) and each + // `glassmorphism` toggle strands another view — plus, for the + // vibrancy variant, its backing store — per window. + let _: () = msg_send![view, release]; } } #[cfg(not(target_os = "macos"))] -fn install_titlebar_backdrop_impl(_window: &Window, _bg: noa_core::Rgb) {} +fn install_titlebar_backdrop_impl(_window: &Window, _bg: noa_core::Rgb, _glass: bool) {} #[cfg(target_os = "macos")] fn remove_titlebar_backdrop_impl(window: &Window) { @@ -864,7 +929,10 @@ fn remove_titlebar_backdrop_impl(window: &Window) { return; }; let ns_view = appkit.ns_view.as_ptr().cast::(); - let identifier = NSString::from_str(TITLEBAR_BACKDROP_ID); + let identifiers = [ + NSString::from_str(TITLEBAR_BACKDROP_ID), + NSString::from_str(TITLEBAR_GLASS_BACKDROP_ID), + ]; unsafe { let ns_window: *mut AnyObject = msg_send![ns_view, window]; @@ -925,10 +993,12 @@ fn remove_titlebar_backdrop_impl(window: &Window) { if ident.is_null() { continue; } - let same: bool = msg_send![ident, isEqualToString: &*identifier]; - if same { - let _: () = msg_send![view, removeFromSuperview]; - return; + for identifier in &identifiers { + let same: bool = msg_send![ident, isEqualToString: &**identifier]; + if same { + let _: () = msg_send![view, removeFromSuperview]; + break; + } } } } diff --git a/crates/noa-app/src/session_overview/metrics.rs b/crates/noa-app/src/session_overview/metrics.rs index 86ab78d..0b85b05 100644 --- a/crates/noa-app/src/session_overview/metrics.rs +++ b/crates/noa-app/src/session_overview/metrics.rs @@ -38,25 +38,30 @@ pub const OVERVIEW_SEARCH_BAND_H: u32 = 64; /// (REQ-OV-17). Compile-time constant. pub const OVERVIEW_HINT_BAND_H: u32 = 54; -/// Mockup-parity chrome palette (REQ-OV-12/14, v2) — no config knob (⚠G -/// precedent), but the light/dark polarity follows the terminal theme via the -/// shared [`crate::chrome`] palette (selected once at startup), so the -/// overview and the session sidebar stay visually unified. Returned as +/// Mockup-parity chrome palette (REQ-OV-12/14, v2) — no per-color config knob +/// (⚠G precedent), but the light/dark polarity follows the terminal theme via +/// the shared [`crate::chrome`] palette (selected once at startup), so the +/// overview and the session sidebar stay visually unified. The alpha comes +/// from that same palette too: `1.0` unless `glassmorphism = true` installed a +/// frosted variant, so the opaque default path is byte-identical. Returned as /// straight display-space RGBA because the Overview surface uses a /// **non-sRGB** format (`Bgra8Unorm`, see `preferred_surface_format`), so /// these are written to the target unchanged (no gamma re-encode). /// /// Backdrop behind every card (mockup: "暗色の背景"). pub fn overview_bg_color() -> [f32; 4] { - crate::chrome::rgba(crate::chrome::palette().bg) + let p = crate::chrome::palette(); + p.backdrop_rgba(p.bg) } /// Card face — one step lighter than [`overview_bg_color`] (mockup: "一段明るいカード面"). pub fn overview_card_color() -> [f32; 4] { - crate::chrome::rgba(crate::chrome::palette().card) + let p = crate::chrome::palette(); + p.surface_rgba(p.card) } /// Title-bar band — distinguishable from the card face (mockup: "区別可能な帯"). pub fn overview_title_bar_color() -> [f32; 4] { - crate::chrome::rgba(crate::chrome::palette().band) + let p = crate::chrome::palette(); + p.surface_rgba(p.band) } /// Thin resting card border. pub fn overview_border_color() -> [f32; 4] { @@ -68,7 +73,8 @@ pub fn overview_focus_ring_color() -> [f32; 4] { } /// Search / hint pill face in the overview chrome. pub fn overview_chrome_pill_color() -> [f32; 4] { - crate::chrome::rgba(crate::chrome::palette().pill) + let p = crate::chrome::palette(); + p.pill_rgba(p.pill) } /// Thin border around search and hint pills. pub fn overview_chrome_border_color() -> [f32; 4] { diff --git a/crates/noa-app/src/session_store.rs b/crates/noa-app/src/session_store.rs index 14eafc2..1944479 100644 --- a/crates/noa-app/src/session_store.rs +++ b/crates/noa-app/src/session_store.rs @@ -838,12 +838,24 @@ pub fn civil_from_unix_secs(secs: i64) -> WallClock { } } +/// Three-letter English month abbreviations, indexed by `month - 1`. Used by +/// [`format_relative_time`]'s older-than-yesterday branch; a fixed table (not +/// a locale lookup) because every other noa UI string is fixed English too. +const MONTH_ABBREV: [&str; 12] = [ + "Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec", +]; + /// Format a wall-clock timestamp relative to `now` (FR-10). `now` is a /// parameter (no `Instant::now()` inside) so the formatter is pure and its /// boundaries are directly testable. Rules, keyed off the calendar-day gap: -/// - same day: `たった今` / `N分前` / `N時間前` -/// - yesterday: `昨日 HH:MM` -/// - older: `M月D日` +/// - same day: `just now` / `Nm ago` / `Nh ago` +/// - yesterday: `Yday HH:MM` +/// - older: `Mon D` +/// +/// Every form is kept within the ~11 cells the right-aligned updated-time +/// column affords (`CARD_UPDATED_W`, sidebar.rs) — hence `Yday` rather than +/// the spelled-out `Yesterday HH:MM`, which would overrun into the card's +/// name column. pub fn format_relative_time(now: WallClock, updated: WallClock) -> String { let day_diff = days_from_civil(now.year, now.month, now.day) - days_from_civil(updated.year, updated.month, updated.day); @@ -854,16 +866,23 @@ pub fn format_relative_time(now: WallClock, updated: WallClock) -> String { let updated_min = (updated.hour * 60 + updated.minute) as i64; let elapsed = (now_min - updated_min).max(0); if elapsed < 1 { - "たった今".to_string() + "just now".to_string() } else if elapsed < 60 { - format!("{elapsed}分前") + format!("{elapsed}m ago") } else { - format!("{}時間前", elapsed / 60) + format!("{}h ago", elapsed / 60) } } else if day_diff == 1 { - format!("昨日 {:02}:{:02}", updated.hour, updated.minute) + format!("Yday {:02}:{:02}", updated.hour, updated.minute) } else { - format!("{}月{}日", updated.month, updated.day) + // `month` comes from `civil_from_days`, which yields 1..=12; the + // fallback keeps the formatter total rather than panicking on a + // corrupted timestamp. + let month = MONTH_ABBREV + .get(updated.month.saturating_sub(1) as usize) + .copied() + .unwrap_or("???"); + format!("{month} {}", updated.day) } } @@ -1287,11 +1306,11 @@ mod tests { let now = wall(10, 3); // Same day, 3 minutes earlier. - assert_eq!(format_relative_time(now, wall(10, 0)), "3分前"); + assert_eq!(format_relative_time(now, wall(10, 0)), "3m ago"); // Same day, exact same minute. - assert_eq!(format_relative_time(now, wall(10, 3)), "たった今"); + assert_eq!(format_relative_time(now, wall(10, 3)), "just now"); // Same day, 2 hours earlier. - assert_eq!(format_relative_time(wall(12, 0), wall(10, 0)), "2時間前"); + assert_eq!(format_relative_time(wall(12, 0), wall(10, 0)), "2h ago"); // Yesterday at 23:47. let yesterday = WallClock { @@ -1300,7 +1319,7 @@ mod tests { minute: 47, ..now }; - assert_eq!(format_relative_time(now, yesterday), "昨日 23:47"); + assert_eq!(format_relative_time(now, yesterday), "Yday 23:47"); // Older than yesterday → date form. let older = WallClock { @@ -1309,7 +1328,57 @@ mod tests { minute: 15, ..now }; - assert_eq!(format_relative_time(now, older), "7月1日"); + assert_eq!(format_relative_time(now, older), "Jul 1"); + } + + // FR-10: every form has to fit the right-aligned updated-time column + // (`CARD_UPDATED_W`, ~11 cells at the sidebar font) — the whole reason the + // strings are abbreviated (`Yday`, `Nm ago`) rather than spelled out. An + // overlong form is drawn last and silently eats into the card's name, so + // pin the widest value each branch can produce. + #[test] + fn every_relative_time_form_fits_the_updated_column() { + const MAX_CELLS: usize = 11; + let now = wall(10, 3); + let at = |day: u32, hour: u32, minute: u32| WallClock { + day, + hour, + minute, + ..now + }; + + let widest = [ + // Same day: "just now", then the longest minute/hour counts. + format_relative_time(now, now), + format_relative_time(wall(10, 59), wall(10, 0)), + format_relative_time(wall(23, 59), wall(0, 0)), + // Yesterday, with two-digit hour and minute. + format_relative_time(now, at(4, 23, 47)), + // Older, with the longest month abbreviation and a two-digit day. + format_relative_time( + now, + WallClock { + month: 12, + day: 31, + hour: 8, + minute: 15, + ..now + }, + ), + ]; + + for value in widest { + // Pure ASCII by construction, so `chars()` is the cell count. + assert!( + value.chars().count() <= MAX_CELLS, + "{value:?} is {} cells, over the {MAX_CELLS}-cell column", + value.chars().count() + ); + assert!( + value.is_ascii(), + "{value:?} must stay ASCII — a wide glyph would double its cell cost" + ); + } } #[test] diff --git a/crates/noa-app/src/sidebar.rs b/crates/noa-app/src/sidebar.rs index 41f448b..38dbda6 100644 --- a/crates/noa-app/src/sidebar.rs +++ b/crates/noa-app/src/sidebar.rs @@ -64,7 +64,8 @@ const CARD_MENU_W: u32 = 22; const CARD_LINE_H: u32 = 15; const CARD_NAME_H: u32 = 18; /// Width of the right-aligned updated-time region on the name row (fits -/// `昨日 23:47` in the sidebar's small font). +/// `Yday 23:47`, the widest form `format_relative_time` emits, in the +/// sidebar's small font). const CARD_UPDATED_W: u32 = 78; // Card interior row baselines (top-relative): the name row (dot, icon, name, @@ -1017,7 +1018,7 @@ mod tests { lines.updated, format_relative_time(wall(10, 3), wall(10, 0)) ); - assert_eq!(lines.updated, "3分前"); + assert_eq!(lines.updated, "3m ago"); // The running-process row shows the detected foreground process. assert_eq!(lines.process, "cargo"); diff --git a/crates/noa-app/src/theme_settings/rows.rs b/crates/noa-app/src/theme_settings/rows.rs index ecf2e3c..fe76ef6 100644 --- a/crates/noa-app/src/theme_settings/rows.rs +++ b/crates/noa-app/src/theme_settings/rows.rs @@ -63,6 +63,21 @@ pub(crate) enum SettingsRowKind { SidebarWidth, SidebarFontSize, QuickTerminalHeight, + /// `glassmorphism`. Reload-exempt like `ConfirmQuit`: `Liveness::OnSave`, + /// no continuous live preview while the row is being edited. Unlike + /// `ConfirmQuit`, though, its commit-time apply is *not* left to + /// `ConfigWatcher` picking up the write — `App::commit_theme_settings` + /// mirrors the new value into `self.config` for its own immediate + /// `chrome::select_palette` call, which leaves `app/config_reload.rs`'s + /// `glassmorphism_changed` diff with nothing to see on the next poll, so + /// `commit_theme_settings` re-selects the chrome palette, drops the + /// textures painted with the old one, and refreshes the native macOS + /// titlebar backdrop directly instead. Switching it on also takes over + /// `BackgroundOpacity`/`BackgroundBlurRadius` (see + /// `ThemeSettings::row_is_glass_managed`), and — in a session that + /// started opaque — carries `RestartReason::OpaqueStartup`, since a + /// window created opaque cannot become see-through in place. + Glassmorphism, ConfirmQuit, /// `send-selection-send-enter`. Same reload-exempt classification as /// `ConfirmQuit`: no live-preview path, but `commit_theme_settings` @@ -147,7 +162,7 @@ pub(crate) enum SettingsRowKind { } impl SettingsRowKind { - pub(crate) const COUNT: usize = 32; + pub(crate) const COUNT: usize = 33; pub(crate) const ALL: [SettingsRowKind; Self::COUNT] = [ Self::FontSize, Self::BackgroundOpacity, @@ -166,6 +181,7 @@ impl SettingsRowKind { Self::SidebarWidth, Self::SidebarFontSize, Self::QuickTerminalHeight, + Self::Glassmorphism, Self::ConfirmQuit, Self::SendSelectionSendEnter, Self::ScrollbackLimit, @@ -229,6 +245,7 @@ impl SettingsRowKind { Self::SidebarWidth => "Sidebar Width", Self::SidebarFontSize => "Sidebar Font Size", Self::QuickTerminalHeight => "Quick Terminal Height", + Self::Glassmorphism => "Glassmorphism", Self::ConfirmQuit => "Confirm Quit", Self::SendSelectionSendEnter => "Send Selection Enter", Self::ScrollbackLimit => "Scrollback Limit", @@ -253,10 +270,10 @@ impl SettingsRowKind { match self { Self::FontSize => "Terminal text point size. Applies live.", Self::BackgroundOpacity => { - "Window background transparency, from 0 (clear) to 1 (opaque)." + "Window background transparency, from 0 (clear) to 1 (opaque). Managed by Glassmorphism while that is on." } Self::BackgroundBlurRadius => { - "macOS background blur strength behind a transparent window." + "macOS background blur strength behind a transparent window. Managed by Glassmorphism while that is on." } Self::BackgroundImage => "Path to an image, or a directory of images, behind the grid.", Self::BackgroundImageOpacity => { @@ -278,6 +295,9 @@ impl SettingsRowKind { Self::QuickTerminalHeight => { "Drop-down quick terminal's height as a fraction of the screen." } + Self::Glassmorphism => { + "Frosted translucent sidebar and tab-overview chrome. Takes over window opacity and blur with its own recommended pair. Applies on save." + } Self::ConfirmQuit => "Ask for confirmation before quitting the app.", Self::SendSelectionSendEnter => { "Send Enter after the send-selection picker pastes. Applies on save." @@ -403,6 +423,7 @@ pub(crate) enum RowDraft { SidebarWidth(f32), SidebarFontSize(f32), QuickTerminalHeight(f32), + Glassmorphism(bool), ConfirmQuit(bool), SendSelectionSendEnter(bool), ScrollbackLimit(usize), @@ -499,6 +520,13 @@ impl RowDraft { RowDraft::SidebarWidth(w) => format!("{w:.0}"), RowDraft::SidebarFontSize(v) => format!("{v:.1}"), RowDraft::QuickTerminalHeight(size) => format!("{:.0}%", size * 100.0), + RowDraft::Glassmorphism(on) => { + if *on { + "On".to_string() + } else { + "Off".to_string() + } + } RowDraft::ConfirmQuit(confirm) => { if *confirm { "On".to_string() @@ -612,6 +640,7 @@ impl RowDraft { }; RowDraft::QuickTerminalHeight(fraction) } + SettingsRowKind::Glassmorphism => RowDraft::Glassmorphism(d.glassmorphism), SettingsRowKind::ConfirmQuit => RowDraft::ConfirmQuit(d.confirm_quit), SettingsRowKind::SendSelectionSendEnter => { RowDraft::SendSelectionSendEnter(d.send_selection_send_enter) @@ -729,6 +758,13 @@ pub(crate) struct RevertValues { pub(crate) cursor_style: CursorShape, pub(crate) background_opacity: f32, pub(crate) background_blur_radius: u16, + /// The pair as *configured*, before `glassmorphism` took it over + /// (`noa_config::StartupConfig::configured_background_*`). Equal to the + /// two above whenever the toggle is off. `revert_updates` writes these, + /// never the effective ones — undoing must restore what the user had, + /// not the values the toggle derives. + pub(crate) configured_background_opacity: f32, + pub(crate) configured_background_blur_radius: u16, pub(crate) background_image: String, pub(crate) background_image_opacity: f32, pub(crate) background_image_position: BackgroundImagePosition, @@ -746,6 +782,7 @@ pub(crate) struct RevertValues { pub(crate) window_padding_x: f32, pub(crate) window_padding_y: f32, pub(crate) macos_titlebar_style: MacosTitlebarStyle, + pub(crate) glassmorphism: bool, pub(crate) confirm_quit: bool, pub(crate) send_selection_send_enter: bool, pub(crate) font_family: String, @@ -796,6 +833,13 @@ pub(crate) struct ThemeSettingsCarryover { pub(crate) rows: [SettingsRow; SettingsRowKind::COUNT], pub(crate) snapshot: RevertValues, pub(crate) opaque_at_startup: bool, + /// The `BackgroundOpacity`/`BackgroundBlurRadius` rows as they stood + /// before `glassmorphism` took them over in this session, if it did. + /// Carried for the same reason `rows` is: a Tab hop is one editing task, + /// so the restore point has to survive it — rebuilding it on reopen + /// would capture the already-snapped glass pair as if it were the + /// user's own values. + pub(crate) pre_glass_rows: Option<(SettingsRow, SettingsRow)>, } /// Everything `App` must supply to open the overlay — the session's live @@ -825,6 +869,19 @@ pub(crate) struct ThemeSettingsInit { pub(crate) cursor_style: CursorShape, pub(crate) background_opacity: f32, pub(crate) background_blur_radius: u16, + /// The pair as *configured*, before `glassmorphism` took it over + /// (`noa_config::StartupConfig::configured_background_*`). Equal to the + /// two above whenever the toggle is off. Carried into the session's + /// `RevertValues` snapshot so Undo can restore them. + pub(crate) configured_background_opacity: f32, + pub(crate) configured_background_blur_radius: u16, + /// Whether the window this session opens over was *created* with AppKit + /// transparency (`WindowState::created_transparent`). Drives R-11's + /// live-preview gate: a window's opacity capability is fixed at + /// creation, so the current effective `background-opacity` is the wrong + /// question — a reload can move it in either direction without changing + /// what the window can actually do. + pub(crate) window_created_transparent: bool, pub(crate) background_image: String, pub(crate) background_image_opacity: f32, pub(crate) background_image_position: BackgroundImagePosition, @@ -838,6 +895,7 @@ pub(crate) struct ThemeSettingsInit { pub(crate) sidebar_width: f32, pub(crate) sidebar_font_size: f32, pub(crate) quick_terminal_size: f32, + pub(crate) glassmorphism: bool, pub(crate) confirm_quit: bool, pub(crate) send_selection_send_enter: bool, pub(crate) font_family: String, diff --git a/crates/noa-app/src/theme_settings/state.rs b/crates/noa-app/src/theme_settings/state.rs index d59108d..7f85000 100644 --- a/crates/noa-app/src/theme_settings/state.rs +++ b/crates/noa-app/src/theme_settings/state.rs @@ -184,6 +184,13 @@ pub(crate) struct ThemeSettings { highlight_moved: bool, selected_row: usize, rows: [SettingsRow; SettingsRowKind::COUNT], + /// The `BackgroundOpacity` / `BackgroundBlurRadius` rows exactly as they + /// stood when `glassmorphism` was switched on in this session and took + /// them over. `None` until that happens (and again once they are handed + /// back), so a session that merely *opened* under glassmorphism has + /// nothing to restore. See [`Self::snap_glass_managed_rows`] / + /// [`Self::restore_glass_managed_rows`]. + pre_glass_rows: Option<(SettingsRow, SettingsRow)>, snapshot: RevertValues, font_size_debounce: Debouncer, /// Accumulates digit keystrokes typed directly into the focused @@ -274,15 +281,19 @@ impl ThemeSettings { /// active theme (SHAPE), every settings row seeded from `init`'s live /// values with `touched = false`. pub(crate) fn open(init: ThemeSettingsInit) -> Self { - let (snapshot, rows, opaque_at_startup) = match &init.carryover { + let (snapshot, rows, opaque_at_startup, pre_glass_rows) = match &init.carryover { // R-25/FM-04: a Tab reopen carries the whole-editing-task // snapshot/rows/opacity-gate forward untouched rather than // re-deriving them from `init`'s live values — see - // `ThemeSettingsCarryover`'s doc comment for why. + // `ThemeSettingsCarryover`'s doc comment for why. The glass + // restore point travels with the rows for the same reason: the + // rows arrive already snapped, so a rebuilt restore point would + // capture the glass pair instead of the user's own values. Some(carry) => ( carry.snapshot.clone(), carry.rows.clone(), carry.opaque_at_startup, + carry.pre_glass_rows.clone(), ), None => ( RevertValues { @@ -291,6 +302,8 @@ impl ThemeSettings { cursor_style: init.cursor_style, background_opacity: init.background_opacity, background_blur_radius: init.background_blur_radius, + configured_background_opacity: init.configured_background_opacity, + configured_background_blur_radius: init.configured_background_blur_radius, background_image: init.background_image.clone(), background_image_opacity: init.background_image_opacity, background_image_position: init.background_image_position, @@ -304,6 +317,7 @@ impl ThemeSettings { window_padding_x: init.window_padding_x, window_padding_y: init.window_padding_y, macos_titlebar_style: init.macos_titlebar_style, + glassmorphism: init.glassmorphism, confirm_quit: init.confirm_quit, send_selection_send_enter: init.send_selection_send_enter, font_family: init.font_family.clone(), @@ -382,6 +396,10 @@ impl ThemeSettings { draft: RowDraft::QuickTerminalHeight(init.quick_terminal_size), touched: false, }, + SettingsRow { + draft: RowDraft::Glassmorphism(init.glassmorphism), + touched: false, + }, SettingsRow { draft: RowDraft::ConfirmQuit(init.confirm_quit), touched: false, @@ -446,7 +464,10 @@ impl ThemeSettings { touched: false, }, ], - init.background_opacity >= 1.0, + !init.window_created_transparent, + // A fresh session has nothing to restore: `glassmorphism` + // has not been switched on *within* it yet. + None, ), }; let filter = init @@ -468,6 +489,7 @@ impl ThemeSettings { highlight_moved: false, selected_row, rows, + pre_glass_rows, snapshot, font_size_debounce: Debouncer::new(FONT_SIZE_DEBOUNCE_WINDOW), font_size_digits: None, @@ -495,6 +517,22 @@ impl ThemeSettings { attribute_filter: None, wheel_accum: 0.0, }; + // A session opened with `glassmorphism` already on shows the values + // the resolver installs for it, not whatever `background-opacity` / + // `background-blur-radius` happen to sit in the config file (the + // running config already has the glass pair — this keeps the panel + // honest even if it were opened from a staler snapshot). + // + // The restore point has to be seeded from the *configured* pair + // first, though: the rows already hold the derived values here, so + // letting `snap_glass_managed_rows` stash them would make the glass + // pair its own restore point — turning the toggle off would then + // show `0.50 / 64` instead of the user's fallback `0.9 / 5`, and the + // next adjustment would overwrite that fallback from the wrong base. + if settings.glass_draft() { + settings.seed_glass_restore_point_from_configured(); + settings.snap_glass_managed_rows(); + } settings.recompute_filtered(); match &init.carryover { // R-25 (AC-34): restore the carried highlight rather than @@ -548,6 +586,7 @@ impl ThemeSettings { rows: self.rows.clone(), snapshot: self.snapshot.clone(), opaque_at_startup: self.opaque_at_startup, + pre_glass_rows: self.pre_glass_rows.clone(), } } @@ -611,36 +650,150 @@ impl ThemeSettings { self.opaque_at_startup } + /// The `glassmorphism` row's current draft — `false` if the panel was + /// somehow built with a mismatched draft variant (unreachable: `rows[i]` + /// always holds `SettingsRowKind::ALL[i]`'s variant). + fn glass_draft(&self) -> bool { + matches!( + self.rows[row_index(SettingsRowKind::Glassmorphism)], + SettingsRow { + draft: RowDraft::Glassmorphism(true), + .. + } + ) + } + + /// Rows `glassmorphism` takes over: while it is on, `background-opacity` + /// and `background-blur-radius` resolve to the recommended glass pair + /// (`noa_config::apply_glassmorphism_defaults`) no matter what this panel + /// or the config file says, so editing them here would show a value the + /// next reload throws away. They are displayed (snapped to the values + /// that will actually apply) but not adjustable. + fn row_is_glass_managed(&self, row: SettingsRowKind) -> bool { + self.glass_draft() + && matches!( + row, + SettingsRowKind::BackgroundOpacity | SettingsRowKind::BackgroundBlurRadius + ) + } + + /// Pull the two managed rows onto the values `glassmorphism = true` + /// resolves to, so the panel never displays an opacity/blur the running + /// config will not have. Left `touched = false`: the toggle itself is + /// what gets written, and the resolver derives these from it — writing + /// them too would bake a redundant pair of keys into the config file + /// that goes stale the moment glassmorphism is turned back off. + fn snap_glass_managed_rows(&mut self) { + let opacity = row_index(SettingsRowKind::BackgroundOpacity); + let blur = row_index(SettingsRowKind::BackgroundBlurRadius); + // Stash what the two rows held so switching glassmorphism back off in + // the same session can hand them back (`restore_glass_managed_rows`). + // Only the first snap stashes: a second one would capture the glass + // pair itself and lose the user's values. Cloning the whole + // `SettingsRow` carries `touched` too — an edit made before the + // toggle must still commit if the toggle is undone. + if self.pre_glass_rows.is_none() { + self.pre_glass_rows = Some((self.rows[opacity].clone(), self.rows[blur].clone())); + } + self.rows[opacity] = SettingsRow { + draft: RowDraft::BackgroundOpacity(noa_config::GLASS_BACKGROUND_OPACITY), + touched: false, + }; + self.rows[blur] = SettingsRow { + draft: RowDraft::BackgroundBlurRadius(noa_config::GLASS_BACKGROUND_BLUR_RADIUS), + touched: false, + }; + } + + /// Build the glass restore point from the *configured* pair — the values + /// the config asked for before `glassmorphism` took the two keys over + /// (`noa_config::StartupConfig::configured_background_*`, carried here + /// through [`RevertValues`]). Used only when a session opens with the + /// toggle already on, where the rows arrive holding the derived pair and + /// so cannot supply a restore point themselves. + /// + /// Untouched: these are what the config file already says, so switching + /// the toggle off and saving writes nothing for them. A later edit marks + /// them the usual way. + /// + /// Never overwrites an existing restore point — a Tab hop carries one + /// forward, and it holds edits this session made before the toggle. + fn seed_glass_restore_point_from_configured(&mut self) { + if self.pre_glass_rows.is_some() { + return; + } + self.pre_glass_rows = Some(( + SettingsRow { + draft: RowDraft::BackgroundOpacity(self.snapshot.configured_background_opacity), + touched: false, + }, + SettingsRow { + draft: RowDraft::BackgroundBlurRadius( + self.snapshot.configured_background_blur_radius, + ), + touched: false, + }, + )); + } + + /// Undo [`Self::snap_glass_managed_rows`] when the toggle goes back off: + /// the two rows return to the drafts (and `touched` flags) they held + /// before glassmorphism took them over. Without this, turning the toggle + /// on and off again would leave the glass pair behind as if the user had + /// chosen it — a later ← / → step would move off `0.50` instead of their + /// own value, and even an untouched save would show a panel that + /// disagrees with the config the next reload resolves. + /// + /// The restore point comes from one of two places, and both are the + /// user's own values: rows stashed when the toggle was switched on in + /// this session, or — for a session that *opened* with it on — the + /// configured pair ([`Self::seed_glass_restore_point_from_configured`]). + /// A no-op only when the toggle has never been on at all, where there is + /// nothing to hand back. + fn restore_glass_managed_rows(&mut self) { + let Some((opacity_row, blur_row)) = self.pre_glass_rows.take() else { + return; + }; + self.rows[row_index(SettingsRowKind::BackgroundOpacity)] = opacity_row; + self.rows[row_index(SettingsRowKind::BackgroundBlurRadius)] = blur_row; + } + + /// Rows whose effect needs a see-through window: the two transparency + /// keys themselves, and `glassmorphism` once its draft is on (frosted + /// chrome over an opaque window shows nothing through, and a window + /// created opaque cannot become translucent in place — R-11's original + /// constraint, now reachable through the toggle too). + fn row_needs_a_transparent_window(&self, row: SettingsRowKind) -> bool { + match row { + SettingsRowKind::BackgroundOpacity | SettingsRowKind::BackgroundBlurRadius => true, + SettingsRowKind::Glassmorphism => self.glass_draft(), + _ => false, + } + } + /// R-1/R-11: why `row` should show the "applies after restart" note - /// instead of a live preview right now. Two independent cases: a *live* - /// opacity/blur row whose session started opaque (R-11's original - /// case — `FontSize`/`CursorStyle` always apply live regardless), or - /// any *commit-only* row (`FontFamily`/`WindowPadding`/ - /// `MacosTitlebarStyle`) the user has actually edited — those have - /// no runtime-apply path at all (`App::commit_theme_settings`), so a - /// touched edit persists to config but only takes effect on the next - /// launch. The two cases carry distinct [`RestartReason`] variants so - /// the UI can explain *why* (AC-1/AC-2) instead of one blanket note. + /// instead of a live preview right now. Two independent cases: a row that + /// needs a see-through window in a session that started opaque (R-11's + /// original opacity/blur case, plus `Glassmorphism` switched on — see + /// [`Self::row_needs_a_transparent_window`]; `FontSize`/`CursorStyle` + /// always apply live regardless), or any *commit-only* row + /// (`FontFamily`/`WindowPadding`/`MacosTitlebarStyle`) the user has + /// actually edited — those have no runtime-apply path at all + /// (`App::commit_theme_settings`), so a touched edit persists to config + /// but only takes effect on the next launch. The two cases carry distinct + /// [`RestartReason`] variants so the UI can explain *why* (AC-1/AC-2) + /// instead of one blanket note. pub(crate) fn restart_reason(&self, row: SettingsRowKind) -> RestartReason { + if self.opaque_at_startup && self.row_needs_a_transparent_window(row) { + return RestartReason::OpaqueStartup; + } if row.is_live() { - return if self.opaque_at_startup - && matches!( - row, - SettingsRowKind::BackgroundOpacity | SettingsRowKind::BackgroundBlurRadius - ) { - RestartReason::OpaqueStartup - } else { - RestartReason::None - }; + return RestartReason::None; } if is_reload_exempt(row) { return RestartReason::None; } - let index = SettingsRowKind::ALL - .iter() - .position(|kind| *kind == row) - .expect("SettingsRowKind::ALL contains every variant"); - if self.rows[index].touched { + if self.rows[row_index(row)].touched { RestartReason::CommitOnly } else { RestartReason::None @@ -670,12 +823,10 @@ impl ThemeSettings { /// `WindowPadding`/`MacosTitlebarStyle`), which persists to config but /// changes nothing this session. pub(crate) fn liveness(&self, row: SettingsRowKind) -> Liveness { - if row.is_live() { - if self.restart_reason(row) == RestartReason::OpaqueStartup { - Liveness::OnLaunch - } else { - Liveness::Live - } + if self.restart_reason(row) == RestartReason::OpaqueStartup { + Liveness::OnLaunch + } else if row.is_live() { + Liveness::Live } else if is_reload_exempt(row) { Liveness::OnSave } else { @@ -1012,6 +1163,9 @@ impl ThemeSettings { return RowEffect::None; } let idx = self.selected_row; + if self.row_is_glass_managed(SettingsRowKind::ALL[idx]) { + return RowEffect::None; + } match SettingsRowKind::ALL[idx] { SettingsRowKind::FontSize => { let RowDraft::FontSize(current) = self.rows[idx].draft else { @@ -1240,6 +1394,20 @@ impl ThemeSettings { } RowEffect::None } + SettingsRowKind::Glassmorphism => { + let RowDraft::Glassmorphism(current) = self.rows[idx].draft else { + return RowEffect::None; + }; + let on = !current; + self.rows[idx].draft = RowDraft::Glassmorphism(on); + self.rows[idx].touched = true; + if on { + self.snap_glass_managed_rows(); + } else { + self.restore_glass_managed_rows(); + } + RowEffect::None + } SettingsRowKind::ConfirmQuit => { let RowDraft::ConfirmQuit(current) = self.rows[idx].draft else { return RowEffect::None; @@ -1567,9 +1735,24 @@ impl ThemeSettings { ) { return RowEffect::None; } + // Same reason `adjust` refuses these: while `glassmorphism` owns the + // two transparency keys, resetting one to its default would display + // — and, being `touched`, write — a value the next reload discards. + if self.row_is_glass_managed(kind) { + return RowEffect::None; + } let default = RowDraft::default_for(kind); + let glass_was_on = self.glass_draft(); self.rows[idx].draft = default.clone(); self.rows[idx].touched = true; + // Reset is the other way `glassmorphism` can go off (its default is + // `false`), and it has to hand the two managed rows back exactly as + // the toggle does — otherwise the restore point stays stashed while + // the rows keep displaying the glass pair, and the next adjustment + // would edit *that* instead of the user's own value. + if kind == SettingsRowKind::Glassmorphism && glass_was_on && !self.glass_draft() { + self.restore_glass_managed_rows(); + } self.clear_row_input_state(); // G1: `FontFamily`'s default is always the empty string (fix F2), // which `commit_updates()` deliberately never writes (noa-config's @@ -1897,7 +2080,20 @@ impl ThemeSettings { None => updates.push(("theme".to_string(), name.to_string())), } } - for row in &self.rows { + // Rows `glassmorphism` took over mid-session are stashed rather than + // edited from here on, and the stash keeps whatever the user had + // already typed into them. Those edits still have to reach disk: with + // the toggle on they are the *fallback* appearance — what the window + // returns to the moment glassmorphism is turned back off — so + // dropping them would silently discard a deliberate setting. The + // stashed rows commit exactly like any other touched row; the live + // rows they were swapped out for hold derived values and are + // untouched, so they contribute nothing here. + let stashed = self + .pre_glass_rows + .iter() + .flat_map(|(opacity, blur)| [opacity, blur]); + for row in self.rows.iter().chain(stashed) { if !row.touched { continue; } @@ -1980,6 +2176,9 @@ impl ThemeSettings { RowDraft::QuickTerminalHeight(size) => { updates.push(("quick-terminal-size".to_string(), format!("{size:.2}"))); } + RowDraft::Glassmorphism(on) => { + updates.push(("glassmorphism".to_string(), on.to_string())); + } RowDraft::ConfirmQuit(confirm) => { updates.push(("confirm-quit".to_string(), confirm.to_string())); } @@ -2191,13 +2390,20 @@ pub(crate) fn revert_updates( } } updates.push(("font-size".to_string(), format!("{}", revert.font_size))); + // The *configured* pair, never the effective one: while `glassmorphism` + // is on the effective values are derived + // (`noa_config::apply_glassmorphism_defaults`), so writing them back + // would overwrite what the user actually had — an unset key would gain + // `0.50 / 64`, an explicit `0.9 / 5` would be destroyed — silently + // changing the appearance the moment glassmorphism is turned off. With + // the toggle off the two are equal, so this is the same write as before. updates.push(( "background-opacity".to_string(), - format!("{:.2}", revert.background_opacity), + format!("{:.2}", revert.configured_background_opacity), )); updates.push(( "background-blur-radius".to_string(), - revert.background_blur_radius.to_string(), + revert.configured_background_blur_radius.to_string(), )); updates.push(( "background-image".to_string(), @@ -2258,6 +2464,10 @@ pub(crate) fn revert_updates( "macos-titlebar-style".to_string(), macos_titlebar_style_config_value(revert.macos_titlebar_style).to_string(), )); + updates.push(( + "glassmorphism".to_string(), + revert.glassmorphism.to_string(), + )); updates.push(("confirm-quit".to_string(), revert.confirm_quit.to_string())); updates.push(( "send-selection-send-enter".to_string(), @@ -2291,6 +2501,16 @@ fn normalize_scratch_terminal_key(chord: &str) -> String { } } +/// `row`'s index into [`SettingsRowKind::ALL`] — the same index its draft +/// occupies in [`ThemeSettings::rows`] (the two arrays are kept in lockstep +/// order by construction). +fn row_index(row: SettingsRowKind) -> usize { + SettingsRowKind::ALL + .iter() + .position(|kind| *kind == row) + .expect("SettingsRowKind::ALL contains every variant") +} + fn is_reload_exempt(row: SettingsRowKind) -> bool { matches!( row, @@ -2300,6 +2520,7 @@ fn is_reload_exempt(row: SettingsRowKind) -> bool { | SettingsRowKind::BackgroundImageFit | SettingsRowKind::BackgroundImageRepeat | SettingsRowKind::BackgroundImageInterval + | SettingsRowKind::Glassmorphism | SettingsRowKind::ConfirmQuit | SettingsRowKind::SendSelectionSendEnter | SettingsRowKind::QuickTerminalHeight @@ -2390,6 +2611,7 @@ fn hash_row_draft_value(draft: &RowDraft, hasher: &mut impl Hasher) { } RowDraft::BackgroundImageFit(fit) => background_image_fit_value(*fit).hash(hasher), RowDraft::BackgroundImageRepeat(v) + | RowDraft::Glassmorphism(v) | RowDraft::ConfirmQuit(v) | RowDraft::SendSelectionSendEnter(v) => v.hash(hasher), RowDraft::BackgroundImageInterval(v) => v.hash(hasher), diff --git a/crates/noa-app/src/theme_settings/tests.rs b/crates/noa-app/src/theme_settings/tests.rs index 225d805..0854f33 100644 --- a/crates/noa-app/src/theme_settings/tests.rs +++ b/crates/noa-app/src/theme_settings/tests.rs @@ -19,6 +19,9 @@ fn init() -> ThemeSettingsInit { cursor_style: CursorShape::Block, background_opacity: 1.0, background_blur_radius: 0, + configured_background_opacity: 1.0, + configured_background_blur_radius: 0, + window_created_transparent: false, background_image: String::new(), background_image_opacity: 1.0, background_image_position: BackgroundImagePosition::Center, @@ -35,6 +38,7 @@ fn init() -> ThemeSettingsInit { // this row only ever edits a plain fraction (see // `quick_terminal_height_fraction` at the `App` layer). quick_terminal_size: 0.4, + glassmorphism: false, confirm_quit: true, send_selection_send_enter: false, font_family: "Menlo".to_string(), @@ -93,6 +97,10 @@ fn settings_init() -> ThemeSettingsInit { fn transparent_init() -> ThemeSettingsInit { ThemeSettingsInit { background_opacity: 0.9, + configured_background_opacity: 0.9, + // The gate is now the window's creation-time capability, not a value + // derived from this opacity — a session over a see-through window. + window_created_transparent: true, ..settings_init() } } @@ -1109,6 +1117,387 @@ fn send_selection_send_enter_row_toggles_and_commits_without_restart_note() { ); } +// The `glassmorphism` row is a plain On/Off toggle that badges `ON SAVE` +// (not `ON LAUNCH`) in a session that can already show it: it has no +// continuous live preview while being edited, but `App::commit_theme_settings` +// re-selects the chrome palette (and, since the P2 stale-titlebar-backdrop +// fix, refreshes the native macOS backdrop) directly the moment the row is +// saved — see `SettingsRowKind::Glassmorphism`'s doc comment for why that +// can no longer be left to `ConfigWatcher`'s reload-diff pass. +#[test] +fn glassmorphism_row_toggles_and_commits_on_save_without_restart_note() { + let mut settings = ThemeSettings::open(transparent_init()); + let idx = row_index(SettingsRowKind::Glassmorphism); + // The panel opens showing the default-off value. + assert_eq!(settings.rows()[idx].draft, RowDraft::Glassmorphism(false)); + + move_to_row(&mut settings, SettingsRowKind::Glassmorphism); + assert_eq!(settings.adjust(1, Instant::now()), RowEffect::None); + assert_eq!(settings.rows()[idx].draft, RowDraft::Glassmorphism(true)); + assert!(!settings.restart_note(SettingsRowKind::Glassmorphism)); + assert_eq!( + settings.liveness(SettingsRowKind::Glassmorphism), + Liveness::OnSave + ); + + let updates = settings.commit_updates(); + assert_eq!( + updates.iter().find(|(k, _)| k == "glassmorphism"), + Some(&("glassmorphism".to_string(), "true".to_string())) + ); +} + +// Switching glassmorphism on in a session that started opaque cannot show +// anything through the frosted chrome until the window is recreated +// see-through — the same R-11 constraint the opacity/blur rows already +// carry, so the row reports it the same way instead of silently doing +// nothing visible (the original "glassmorphism doesn't apply" report). +#[test] +fn glassmorphism_switched_on_reports_opaque_startup_in_an_opaque_session() { + let mut settings = ThemeSettings::open(settings_init()); + assert!(settings.opaque_at_startup()); + // Off, the row says nothing about transparency — it changes nothing + // that needs a see-through window. + assert_eq!( + settings.restart_reason(SettingsRowKind::Glassmorphism), + RestartReason::None + ); + + move_to_row(&mut settings, SettingsRowKind::Glassmorphism); + settings.adjust(1, Instant::now()); + + assert_eq!( + settings.restart_reason(SettingsRowKind::Glassmorphism), + RestartReason::OpaqueStartup + ); + assert_eq!( + settings.liveness(SettingsRowKind::Glassmorphism), + Liveness::OnLaunch + ); +} + +// Turning glassmorphism on snaps the two keys it takes over onto the values +// the resolver will install (`noa_config::apply_glassmorphism_defaults`), so +// the panel never displays an opacity/blur the running config won't have — +// and leaves them untouched, so the commit writes only the toggle. +#[test] +fn glassmorphism_snaps_the_transparency_rows_it_manages_without_writing_them() { + let mut settings = ThemeSettings::open(transparent_init()); + move_to_row(&mut settings, SettingsRowKind::Glassmorphism); + settings.adjust(1, Instant::now()); + + let opacity = row_index(SettingsRowKind::BackgroundOpacity); + let blur = row_index(SettingsRowKind::BackgroundBlurRadius); + assert_eq!( + settings.rows()[opacity].draft, + RowDraft::BackgroundOpacity(noa_config::GLASS_BACKGROUND_OPACITY) + ); + assert_eq!( + settings.rows()[blur].draft, + RowDraft::BackgroundBlurRadius(noa_config::GLASS_BACKGROUND_BLUR_RADIUS) + ); + assert!(!settings.rows()[opacity].touched); + assert!(!settings.rows()[blur].touched); + + let updates = settings.commit_updates(); + assert!(updates.iter().all(|(k, _)| k != "background-opacity")); + assert!(updates.iter().all(|(k, _)| k != "background-blur-radius")); +} + +// Turning glassmorphism back off in the same session hands the two rows +// back exactly as they were — draft *and* `touched`. Otherwise an on/off +// round trip would leave the glass pair behind as if the user had chosen +// it: a later step would move off 0.50 instead of their own value, and an +// untouched save would show a panel disagreeing with the resolved config. +#[test] +fn toggling_glassmorphism_off_restores_the_transparency_rows_it_took_over() { + let mut settings = ThemeSettings::open(transparent_init()); + let opacity = row_index(SettingsRowKind::BackgroundOpacity); + let blur = row_index(SettingsRowKind::BackgroundBlurRadius); + + // A deliberate, non-default edit before the toggle. + move_to_row(&mut settings, SettingsRowKind::BackgroundOpacity); + settings.adjust(-1, Instant::now()); + let edited = settings.rows()[opacity].clone(); + let untouched_blur = settings.rows()[blur].clone(); + assert!(edited.touched); + + move_to_row(&mut settings, SettingsRowKind::Glassmorphism); + settings.adjust(1, Instant::now()); + assert_eq!( + settings.rows()[opacity].draft, + RowDraft::BackgroundOpacity(noa_config::GLASS_BACKGROUND_OPACITY) + ); + + settings.adjust(1, Instant::now()); + assert_eq!(settings.rows()[opacity].draft, edited.draft); + assert!( + settings.rows()[opacity].touched, + "the edit must still commit" + ); + assert_eq!(settings.rows()[blur], untouched_blur); + + // The rows are adjustable again, from the restored value. + move_to_row(&mut settings, SettingsRowKind::BackgroundOpacity); + assert_ne!(settings.adjust(-1, Instant::now()), RowEffect::None); +} + +// R-11's gate is the window's creation-time capability, never the live +// opacity. AppKit fixes opacity at creation, and `glassmorphism` moves the +// effective opacity underneath a window that is already built: an opaque +// window that a config reload gave `0.50` still cannot preview transparency +// (it must not claim `LIVE`), and a see-through window whose glass was just +// turned off resolves back to `1.0` while remaining perfectly capable of it +// (it must not claim `ON LAUNCH`). +#[test] +fn the_live_preview_gate_follows_the_window_not_the_effective_opacity() { + // Opaque window, glass-lowered opacity: still gated. + let opaque_window = ThemeSettings::open(ThemeSettingsInit { + glassmorphism: true, + background_opacity: noa_config::GLASS_BACKGROUND_OPACITY, + background_blur_radius: noa_config::GLASS_BACKGROUND_BLUR_RADIUS, + window_created_transparent: false, + ..settings_init() + }); + assert!(opaque_window.opaque_at_startup()); + assert_eq!( + opaque_window.restart_reason(SettingsRowKind::BackgroundOpacity), + RestartReason::OpaqueStartup + ); + assert_eq!( + opaque_window.liveness(SettingsRowKind::BackgroundOpacity), + Liveness::OnLaunch + ); + + // See-through window back at a fully opaque value: not gated. + let transparent_window = ThemeSettings::open(ThemeSettingsInit { + glassmorphism: false, + background_opacity: 1.0, + configured_background_opacity: 1.0, + window_created_transparent: true, + ..settings_init() + }); + assert!(!transparent_window.opaque_at_startup()); + assert_eq!( + transparent_window.restart_reason(SettingsRowKind::BackgroundOpacity), + RestartReason::None + ); + assert_eq!( + transparent_window.liveness(SettingsRowKind::BackgroundOpacity), + Liveness::Live + ); +} + +// A session that *opens* with glassmorphism on has rows already holding the +// derived pair, so the restore point must come from the configured values +// instead. Otherwise the glass pair becomes its own restore point: turning +// the toggle off would show 0.50 / 64 rather than the user's fallback +// 0.9 / 5, and the next adjustment would overwrite that fallback from the +// wrong base. +#[test] +fn a_session_opened_under_glassmorphism_restores_the_configured_pair() { + let mut settings = ThemeSettings::open(ThemeSettingsInit { + glassmorphism: true, + // What the resolver installed... + background_opacity: noa_config::GLASS_BACKGROUND_OPACITY, + background_blur_radius: noa_config::GLASS_BACKGROUND_BLUR_RADIUS, + // ...over what the config file actually asks for. + configured_background_opacity: 0.9, + configured_background_blur_radius: 5, + window_created_transparent: true, + ..settings_init() + }); + let opacity = row_index(SettingsRowKind::BackgroundOpacity); + let blur = row_index(SettingsRowKind::BackgroundBlurRadius); + + // While it is on, the panel shows what is actually in effect. + assert_eq!( + settings.rows()[opacity].draft, + RowDraft::BackgroundOpacity(noa_config::GLASS_BACKGROUND_OPACITY) + ); + + move_to_row(&mut settings, SettingsRowKind::Glassmorphism); + settings.adjust(1, Instant::now()); // off + + assert_eq!( + settings.rows()[opacity].draft, + RowDraft::BackgroundOpacity(0.9) + ); + assert_eq!( + settings.rows()[blur].draft, + RowDraft::BackgroundBlurRadius(5) + ); + // Restored, not edited: these are what the file already says, so the + // commit writes only the toggle. + assert!(!settings.rows()[opacity].touched); + assert!(!settings.rows()[blur].touched); + let updates = settings.commit_updates(); + assert!(updates.iter().all(|(k, _)| k != "background-opacity")); + + // And a following adjustment steps from the fallback, not from 0.50. + move_to_row(&mut settings, SettingsRowKind::BackgroundOpacity); + settings.adjust(-1, Instant::now()); + let RowDraft::BackgroundOpacity(stepped) = settings.rows()[opacity].draft else { + unreachable!("the row holds its own draft variant"); + }; + assert!(stepped < 0.9 && stepped > noa_config::GLASS_BACKGROUND_OPACITY); +} + +// An opacity edit made *before* the toggle still has to reach disk when the +// session saves with glassmorphism on: while the toggle is on that value is +// the fallback appearance — what the window returns to when it is turned +// back off — so dropping it would silently discard a deliberate setting. +#[test] +fn edits_made_before_glassmorphism_still_commit_when_saving_with_it_on() { + let mut settings = ThemeSettings::open(transparent_init()); + move_to_row(&mut settings, SettingsRowKind::BackgroundOpacity); + settings.adjust(-1, Instant::now()); + let RowDraft::BackgroundOpacity(edited) = + settings.rows()[row_index(SettingsRowKind::BackgroundOpacity)].draft + else { + unreachable!("the row holds its own draft variant"); + }; + + move_to_row(&mut settings, SettingsRowKind::Glassmorphism); + settings.adjust(1, Instant::now()); + + let updates = settings.commit_updates(); + assert_eq!( + updates.iter().find(|(k, _)| k == "glassmorphism"), + Some(&("glassmorphism".to_string(), "true".to_string())) + ); + assert_eq!( + updates.iter().find(|(k, _)| k == "background-opacity"), + Some(&("background-opacity".to_string(), format!("{edited:.2}"))) + ); + // The untouched sibling contributes nothing — only the real edit does. + assert!(updates.iter().all(|(k, _)| k != "background-blur-radius")); +} + +// A second on/off round trip must still restore the *user's* values, not +// the glass pair captured by the first one. +#[test] +fn repeated_glassmorphism_toggles_never_capture_the_glass_pair_as_the_restore_point() { + let mut settings = ThemeSettings::open(transparent_init()); + let opacity = row_index(SettingsRowKind::BackgroundOpacity); + let original = settings.rows()[opacity].clone(); + + move_to_row(&mut settings, SettingsRowKind::Glassmorphism); + for _ in 0..2 { + settings.adjust(1, Instant::now()); // on + settings.adjust(1, Instant::now()); // off + } + + assert_eq!(settings.rows()[opacity], original); +} + +// Reset is the other route from on to off (the row's default is `false`), +// so it has to hand the managed rows back exactly as the toggle does — +// otherwise the restore point stays stashed while the rows still display the +// glass pair, and the next adjustment edits that instead of the user's value. +#[test] +fn resetting_the_glassmorphism_row_restores_the_transparency_rows_too() { + let mut settings = ThemeSettings::open(transparent_init()); + let opacity = row_index(SettingsRowKind::BackgroundOpacity); + let blur = row_index(SettingsRowKind::BackgroundBlurRadius); + let original_opacity = settings.rows()[opacity].clone(); + let original_blur = settings.rows()[blur].clone(); + + move_to_row(&mut settings, SettingsRowKind::Glassmorphism); + settings.adjust(1, Instant::now()); + assert_eq!( + settings.rows()[opacity].draft, + RowDraft::BackgroundOpacity(noa_config::GLASS_BACKGROUND_OPACITY) + ); + + settings.reset_selected_row(Instant::now()); + + assert_eq!( + settings.rows()[row_index(SettingsRowKind::Glassmorphism)].draft, + RowDraft::Glassmorphism(false) + ); + assert_eq!(settings.rows()[opacity], original_opacity); + assert_eq!(settings.rows()[blur], original_blur); +} + +// R-25: a Tab hop is one editing task, so the glass restore point travels +// with the rows. Without it the reopened session re-snaps from rows that are +// *already* the glass pair, and turning glassmorphism off afterward can +// never get back to what the user had. +#[test] +fn tab_carryover_preserves_the_glass_restore_point_across_the_hop() { + let mut settings = ThemeSettings::open(transparent_init()); + let opacity = row_index(SettingsRowKind::BackgroundOpacity); + move_to_row(&mut settings, SettingsRowKind::BackgroundOpacity); + settings.adjust(-1, Instant::now()); + let edited = settings.rows()[opacity].clone(); + + move_to_row(&mut settings, SettingsRowKind::Glassmorphism); + settings.adjust(1, Instant::now()); + + // Settings -> Theme -> Settings, carrying the session both ways. + let theme = ThemeSettings::open(ThemeSettingsInit { + mode: ThemeSettingsMode::Theme, + carryover: Some(settings.carryover()), + ..transparent_init() + }); + let mut back = ThemeSettings::open(ThemeSettingsInit { + mode: ThemeSettingsMode::Settings, + carryover: Some(theme.carryover()), + ..transparent_init() + }); + + move_to_row(&mut back, SettingsRowKind::Glassmorphism); + back.adjust(1, Instant::now()); // off again + + assert_eq!(back.rows()[opacity], edited); +} + +// While glassmorphism owns them, the two rows are display-only: neither a +// ←→ step nor a reset may move them, since the next reload would discard +// whatever they showed. +#[test] +fn glass_managed_transparency_rows_reject_adjust_and_reset() { + let mut settings = ThemeSettings::open(transparent_init()); + move_to_row(&mut settings, SettingsRowKind::Glassmorphism); + settings.adjust(1, Instant::now()); + + for kind in [ + SettingsRowKind::BackgroundOpacity, + SettingsRowKind::BackgroundBlurRadius, + ] { + let idx = row_index(kind); + let before = settings.rows()[idx].clone(); + move_to_row(&mut settings, kind); + + assert_eq!(settings.adjust(-1, Instant::now()), RowEffect::None); + assert_eq!(settings.rows()[idx], before, "{kind:?} moved on adjust"); + + assert_eq!( + settings.reset_selected_row(Instant::now()), + RowEffect::None, + "{kind:?} reset produced an effect" + ); + assert_eq!(settings.rows()[idx], before, "{kind:?} moved on reset"); + } +} + +// The lock is conditional on the toggle, not permanent: with glassmorphism +// off, both rows step normally (guards against the managed check leaking +// into the default configuration). +#[test] +fn transparency_rows_still_adjust_while_glassmorphism_is_off() { + let mut settings = ThemeSettings::open(transparent_init()); + let idx = row_index(SettingsRowKind::BackgroundOpacity); + let before = settings.rows()[idx].draft.clone(); + + move_to_row(&mut settings, SettingsRowKind::BackgroundOpacity); + settings.adjust(-1, Instant::now()); + + assert_ne!(settings.rows()[idx].draft, before); + assert!(settings.rows()[idx].touched); +} + // R-17/NFR-6, Theme mode: `commit_updates` can only ever contain the // `theme` key now — the settings section doesn't exist in this mode, so no // row can ever become `touched` (an untouched row's draft can equal the @@ -2002,12 +2391,13 @@ fn default_for_maps_every_row_kind_to_its_documented_startup_default() { // (settings-panel-server-status) brings it to 25 (+1), the LAN bind- // address row (server-bind) brings it to 26 (+1), the sidebar-width row // brings it to 27 (+1), the sidebar-font-size row brings it to 28 (+1), -// the send-selection-send-enter row brings it to 29 (+1), and the Remote -// App QR action brings it to 30 (+1). +// the send-selection-send-enter row brings it to 29 (+1), the Remote +// App QR action brings it to 30 (+1), and the `glassmorphism` row brings +// the array to its current length (+1 on top of the scratch-terminal rows). #[test] fn settings_row_kind_count_includes_remote_app_qr_action() { - assert_eq!(SettingsRowKind::COUNT, 32); - assert_eq!(SettingsRowKind::ALL.len(), 32); + assert_eq!(SettingsRowKind::COUNT, 33); + assert_eq!(SettingsRowKind::ALL.len(), 33); } // settings-panel-server-status: the status row is read-only (mirrors @@ -3476,6 +3866,8 @@ fn sample_revert(theme_name: &str) -> RevertValues { cursor_style: CursorShape::Bar, background_opacity: 0.8, background_blur_radius: 5, + configured_background_opacity: 0.8, + configured_background_blur_radius: 5, background_image: "/tmp/wall.png".to_string(), background_image_opacity: 0.5, background_image_position: BackgroundImagePosition::Center, @@ -3489,6 +3881,7 @@ fn sample_revert(theme_name: &str) -> RevertValues { window_padding_x: 2.0, window_padding_y: 2.0, macos_titlebar_style: MacosTitlebarStyle::Native, + glassmorphism: false, confirm_quit: true, send_selection_send_enter: false, font_family: "Menlo".to_string(), @@ -3518,6 +3911,59 @@ fn revert_updates_writes_every_snapshot_field_unconditionally() { ); } +// Undo writes the *configured* pair, never the effective one. Under +// glassmorphism the effective values are derived (0.50 / 64), so writing +// those back would overwrite what the user actually had — an unset key would +// gain a value, an explicit `0.9 / 5` would be destroyed — permanently, and +// invisibly until glassmorphism is turned off and the wrong appearance shows. +#[test] +fn revert_updates_restores_the_configured_pair_not_the_glass_derived_one() { + let revert = RevertValues { + glassmorphism: true, + background_opacity: noa_config::GLASS_BACKGROUND_OPACITY, + background_blur_radius: noa_config::GLASS_BACKGROUND_BLUR_RADIUS, + configured_background_opacity: 0.9, + configured_background_blur_radius: 5, + ..sample_revert("3024 Day") + }; + + let updates = revert_updates(&revert, None); + + assert_eq!( + updates.iter().find(|(k, _)| k == "background-opacity"), + Some(&("background-opacity".to_string(), "0.90".to_string())) + ); + assert_eq!( + updates.iter().find(|(k, _)| k == "background-blur-radius"), + Some(&("background-blur-radius".to_string(), "5".to_string())) + ); + assert_eq!( + updates.iter().find(|(k, _)| k == "glassmorphism"), + Some(&("glassmorphism".to_string(), "true".to_string())) + ); +} + +// With glassmorphism off the configured and effective pairs are equal by +// construction, so this is the same write the undo has always made. +#[test] +fn revert_updates_still_restores_the_transparency_keys_without_glassmorphism() { + let revert = RevertValues { + glassmorphism: false, + ..sample_revert("3024 Day") + }; + + let updates = revert_updates(&revert, None); + + assert_eq!( + updates.iter().find(|(k, _)| k == "background-opacity"), + Some(&("background-opacity".to_string(), "0.80".to_string())) + ); + assert_eq!( + updates.iter().find(|(k, _)| k == "background-blur-radius"), + Some(&("background-blur-radius".to_string(), "5".to_string())) + ); +} + // TSV2-1 (judge, CONFIRMED): the 5 commit-only rows were missing from // `RevertValues`/`revert_updates` entirely, so a commit of any of // font-family / window-padding-x / window-padding-y / macos-titlebar-style diff --git a/crates/noa-config/src/lib.rs b/crates/noa-config/src/lib.rs index e0f1bac..85f821f 100644 --- a/crates/noa-config/src/lib.rs +++ b/crates/noa-config/src/lib.rs @@ -75,6 +75,24 @@ pub const DEFAULT_SIDEBAR_PREVIEW_LINES: usize = 5; /// Largest supported `sidebar-preview-lines` value. Higher values make each /// card too tall for the sidebar's dense session-list use case. pub const MAX_SIDEBAR_PREVIEW_LINES: usize = 20; +/// `background-opacity` installed when `glassmorphism = true`, replacing +/// whatever the config resolved to. Frosted chrome only reads as glass when +/// there is something behind the window to show through, and a window is only +/// see-through below `1.0` — leaving the user's value in place is what made +/// `glassmorphism = true` look like it did nothing at all. Deliberately +/// aggressive — half the window is the desktop behind it — because the point +/// of the toggle is the glass, not a hint of it. What keeps text readable at +/// this level is the companion blur, not the opacity: see +/// [`GLASS_BACKGROUND_BLUR_RADIUS`], which is pinned to its maximum for +/// exactly that reason. Users who want a heavier pane turn `glassmorphism` +/// off and set `background-opacity` themselves. +pub const GLASS_BACKGROUND_OPACITY: f32 = 0.50; +/// `background-blur-radius` installed when `glassmorphism = true`: the +/// maximum the key accepts. At [`GLASS_BACKGROUND_OPACITY`] the desktop is +/// half the pixels on screen, so it has to be blurred past recognition — +/// diffuse color instead of shapes — or wallpaper detail reads as noise +/// under the text. Frosted glass, not clear glass. +pub const GLASS_BACKGROUND_BLUR_RADIUS: u16 = 64; /// `server-port` default (noa-server spec DEC-3: fixed value, no discovery). pub const DEFAULT_SERVER_PORT: u16 = 61771; /// Default bind address for the `noa-server` socket: loopback-only. LAN @@ -524,13 +542,39 @@ pub struct StartupConfig { /// Default [`DEFAULT_CURSOR_STOP_BLINKING_AFTER_SECS`]; set `0` to /// restore Ghostty-parity behavior. pub cursor_stop_blinking_after_secs: u64, - /// `background-opacity`: 0.0..=1.0, clamped. Consumed by the transparency - /// follow-up; plumbed through for now. Default is fully opaque. + /// `background-opacity`: 0.0..=1.0, clamped. Default is fully opaque. + /// **Ignored while `glassmorphism` is on** — that toggle installs + /// [`GLASS_BACKGROUND_OPACITY`] instead (see + /// [`apply_glassmorphism_defaults`]). pub background_opacity: f32, /// `background-blur-radius`: native macOS window background blur radius in /// points, `0..=64` (0 = no blur). Only visible with `background_opacity` - /// below 1.0. No-op on non-macOS. + /// below 1.0. No-op on non-macOS. **Ignored while `glassmorphism` is on** + /// — that toggle installs [`GLASS_BACKGROUND_BLUR_RADIUS`] instead. pub background_blur_radius: u16, + /// What `background-opacity` / `background-blur-radius` resolved to + /// *before* [`apply_glassmorphism_defaults`] took them over — equal to + /// the effective values whenever `glassmorphism` is off. + /// + /// Kept because the effective values are derived while the toggle is on, + /// and anything that writes config back (the Settings panel's Undo) must + /// restore what the user actually had, not the derived pair. Without + /// this, one undo would silently rewrite the fallback appearance that + /// turning `glassmorphism` off is supposed to return to. + pub configured_background_opacity: f32, + pub configured_background_blur_radius: u16, + /// `glassmorphism`: render noa's own chrome (session sidebar, tab + /// overview) as translucent frosted panes instead of opaque ones, so the + /// blurred desktop behind a translucent window shows through — the same + /// visual language the native AppKit overlays already use. Default off; + /// off installs the byte-identical opaque chrome palette, so it costs + /// nothing. On, it *takes over* `background-opacity` and + /// `background-blur-radius` — frosted chrome over an opaque window shows + /// nothing through, so those two keys resolve to + /// [`GLASS_BACKGROUND_OPACITY`] / [`GLASS_BACKGROUND_BLUR_RADIUS`] + /// regardless of what the config set them to (a diagnostic names any + /// value that was overridden). noa-specific key (no Ghostty analog). + pub glassmorphism: bool, /// `background-image`: path to a PNG laid behind the terminal grid, or the /// reserved value `noa` for Noa's bundled wallpaper directory. `None` /// leaves the background as the clear color only. Values are stored @@ -724,6 +768,9 @@ impl Default for StartupConfig { cursor_stop_blinking_after_secs: DEFAULT_CURSOR_STOP_BLINKING_AFTER_SECS, background_opacity: 1.0, background_blur_radius: 0, + configured_background_opacity: 1.0, + configured_background_blur_radius: 0, + glassmorphism: false, background_image: None, background_image_opacity: 1.0, background_image_position: BackgroundImagePosition::default(), @@ -798,6 +845,7 @@ pub struct ConfigOverrides { pub cursor_stop_blinking_after_secs: Option, pub background_opacity: Option, pub background_blur_radius: Option, + pub glassmorphism: Option, pub background_image: Option, pub background_image_opacity: Option, pub background_image_position: Option, @@ -880,6 +928,7 @@ macro_rules! impl_redacted_config_debug { ) .field("background_opacity", &self.background_opacity) .field("background_blur_radius", &self.background_blur_radius) + .field("glassmorphism", &self.glassmorphism) .field("background_image", &self.background_image) .field("background_image_opacity", &self.background_image_opacity) .field("background_image_position", &self.background_image_position) @@ -987,6 +1036,7 @@ impl ConfigOverrides { background_blur_radius: higher_priority .background_blur_radius .or(self.background_blur_radius), + glassmorphism: higher_priority.glassmorphism.or(self.glassmorphism), background_image: higher_priority.background_image.or(self.background_image), background_image_opacity: higher_priority .background_image_opacity @@ -1115,6 +1165,12 @@ impl ConfigOverrides { background_blur_radius: self .background_blur_radius .unwrap_or(base.background_blur_radius), + // Overwritten wholesale by `apply_glassmorphism_defaults` at the + // end of resolution; the values here are placeholders that never + // reach a caller. + configured_background_opacity: base.configured_background_opacity, + configured_background_blur_radius: base.configured_background_blur_radius, + glassmorphism: self.glassmorphism.unwrap_or(base.glassmorphism), background_image: self.background_image.or(base.background_image), background_image_opacity: self .background_image_opacity @@ -1212,8 +1268,9 @@ pub fn load_startup_config( pub fn load_startup_config_without_files( cli: ConfigOverrides, ) -> anyhow::Result<(StartupConfig, Vec)> { + let diagnostics = Vec::from_iter(glass_override_diagnostic(&glass_overridden_keys(&cli))); let config = finalize_startup_config(cli.apply_to(StartupConfig::default()))?; - Ok((config, Vec::new())) + Ok((config, diagnostics)) } pub fn load_startup_config_from( @@ -1237,7 +1294,9 @@ pub fn load_startup_config_from( }); } - let config = finalize_startup_config(file.merge(cli).apply_to(StartupConfig::default()))?; + let merged = file.merge(cli); + diagnostics.extend(glass_override_diagnostic(&glass_overridden_keys(&merged))); + let config = finalize_startup_config(merged.apply_to(StartupConfig::default()))?; Ok((config, diagnostics)) } @@ -1245,10 +1304,96 @@ fn finalize_startup_config(config: StartupConfig) -> anyhow::Result f32 { + if glassmorphism { + GLASS_BACKGROUND_OPACITY + } else { + configured_background_opacity + } +} + +/// As [`resolved_background_opacity`], for `background-blur-radius`. +pub fn resolved_background_blur_radius( + glassmorphism: bool, + configured_background_blur_radius: u16, +) -> u16 { + if glassmorphism { + GLASS_BACKGROUND_BLUR_RADIUS + } else { + configured_background_blur_radius + } +} + +/// Which explicitly configured keys [`apply_glassmorphism_defaults`] is about +/// to override, given the merged overrides that produced the config. Empty +/// unless `glassmorphism` resolves to `true` *and* the user actually set one +/// of them — an untouched key is a default, not something to warn about. +fn glass_overridden_keys(overrides: &ConfigOverrides) -> Vec<&'static str> { + if !overrides.glassmorphism.unwrap_or(false) { + return Vec::new(); + } + let mut keys = Vec::new(); + if overrides.background_opacity.is_some() { + keys.push("background-opacity"); + } + if overrides.background_blur_radius.is_some() { + keys.push("background-blur-radius"); + } + keys +} + +fn glass_override_diagnostic(keys: &[&'static str]) -> Option { + (!keys.is_empty()).then(|| Diagnostic { + message: format!( + "glassmorphism = true overrides {} with the recommended {GLASS_BACKGROUND_OPACITY:.2} \ + / {GLASS_BACKGROUND_BLUR_RADIUS}; unset glassmorphism to control {} yourself", + keys.join(" and "), + if keys.len() == 1 { "it" } else { "them" } + ), + }) +} + fn finalize_startup_config_with_home( mut config: StartupConfig, home: Option<&Path>, ) -> anyhow::Result { + apply_glassmorphism_defaults(&mut config); if config.client_token.is_none() && let Some(path) = config.client_token_file.as_deref() { @@ -1428,6 +1573,9 @@ mod tests { cursor_stop_blinking_after_secs: DEFAULT_CURSOR_STOP_BLINKING_AFTER_SECS, background_opacity: 1.0, background_blur_radius: 0, + configured_background_opacity: 1.0, + configured_background_blur_radius: 0, + glassmorphism: false, background_image: None, background_image_opacity: 1.0, background_image_position: BackgroundImagePosition::default(), @@ -2040,6 +2188,164 @@ font-size = 15.5 fs::remove_dir_all(dir).unwrap(); } + // Regression lock for a stale-titlebar-backdrop bug (P2, noa-app): the + // fix derives the *effective* opacity/blur at a Settings-panel + // `glassmorphism` commit via these two functions instead of + // open-coding the branch a second time. Deliberately uses a configured + // opacity that is NOT `GLASS_BACKGROUND_OPACITY` (0.50) — the bug's + // original repro happened to configure exactly 0.50, which made the + // stale (pre-derivation) value coincidentally correct and hid the bug + // from that one input. A configured value of `1.0` (this test) would + // have caught it: the stale value and the resolved value disagree. + #[test] + fn resolved_background_opacity_and_blur_take_over_only_while_glass_is_on() { + assert_eq!( + resolved_background_opacity(true, 1.0), + GLASS_BACKGROUND_OPACITY + ); + assert_eq!(resolved_background_opacity(false, 1.0), 1.0); + assert_eq!( + resolved_background_blur_radius(true, 0), + GLASS_BACKGROUND_BLUR_RADIUS + ); + assert_eq!(resolved_background_blur_radius(false, 0), 0); + + // `apply_glassmorphism_defaults` must agree with these standalone + // functions — it is written in terms of them, but pin the + // equivalence directly so the two can't silently diverge. + let mut config = StartupConfig { + glassmorphism: true, + background_opacity: 1.0, + background_blur_radius: 0, + ..StartupConfig::default() + }; + apply_glassmorphism_defaults(&mut config); + assert_eq!( + config.background_opacity, + resolved_background_opacity(true, 1.0) + ); + assert_eq!( + config.background_blur_radius, + resolved_background_blur_radius(true, 0) + ); + } + + // `glassmorphism = true` owns the window-transparency keys: an explicit + // `background-opacity = 1.00` (the exact config that made the frosted + // chrome look like it did nothing — an opaque window shows nothing + // through) resolves to the recommended pair instead, and the ignored + // keys are named in a diagnostic rather than silently dropped. + #[test] + fn glassmorphism_replaces_configured_background_opacity_and_blur() { + let dir = unique_temp_dir("glass-overrides"); + let config_path = dir.join("config"); + let legacy_path = dir.join("config.toml"); + let _ = fs::remove_dir_all(&dir); + fs::create_dir_all(&dir).unwrap(); + fs::write( + &config_path, + "glassmorphism = true\nbackground-opacity = 1.00\nbackground-blur-radius = 0\n", + ) + .unwrap(); + + let (config, diagnostics) = + load_startup_config_from(&config_path, &legacy_path, ConfigOverrides::default()) + .unwrap(); + + assert_eq!(config.background_opacity, GLASS_BACKGROUND_OPACITY); + assert_eq!(config.background_blur_radius, GLASS_BACKGROUND_BLUR_RADIUS); + // Below 1.0 is the whole point: that is what makes the window + // transparent at creation, so the frosted panes have something + // behind them. + assert!(config.background_opacity < 1.0); + assert_eq!(diagnostics.len(), 1); + let message = &diagnostics[0].message; + assert!(message.contains("background-opacity"), "{message}"); + assert!(message.contains("background-blur-radius"), "{message}"); + fs::remove_dir_all(dir).unwrap(); + } + + // The override is unconditional, not a floor: a deliberately *more* + // transparent value is replaced too, so "glassmorphism on" always means + // one known-good look. + #[test] + fn glassmorphism_replaces_a_more_transparent_configured_value_too() { + let mut config = StartupConfig { + glassmorphism: true, + background_opacity: 0.4, + background_blur_radius: 5, + ..StartupConfig::default() + }; + apply_glassmorphism_defaults(&mut config); + assert_eq!(config.background_opacity, GLASS_BACKGROUND_OPACITY); + assert_eq!(config.background_blur_radius, GLASS_BACKGROUND_BLUR_RADIUS); + } + + // Default-off contract: with the toggle off the two keys are exactly + // what the config said, and nothing is reported. + #[test] + fn glassmorphism_off_leaves_the_background_keys_alone() { + let dir = unique_temp_dir("glass-off"); + let config_path = dir.join("config"); + let legacy_path = dir.join("config.toml"); + let _ = fs::remove_dir_all(&dir); + fs::create_dir_all(&dir).unwrap(); + fs::write( + &config_path, + "background-opacity = 1.00\nbackground-blur-radius = 20\n", + ) + .unwrap(); + + let (config, diagnostics) = + load_startup_config_from(&config_path, &legacy_path, ConfigOverrides::default()) + .unwrap(); + + assert!(!config.glassmorphism); + assert_eq!(config.background_opacity, 1.0); + assert_eq!(config.background_blur_radius, 20); + assert!(diagnostics.is_empty()); + fs::remove_dir_all(dir).unwrap(); + } + + // Nothing to warn about when the user never set the keys — the values + // being replaced are defaults, not choices. + #[test] + fn glassmorphism_alone_forces_the_pair_without_a_diagnostic() { + let dir = unique_temp_dir("glass-only"); + let config_path = dir.join("config"); + let legacy_path = dir.join("config.toml"); + let _ = fs::remove_dir_all(&dir); + fs::create_dir_all(&dir).unwrap(); + fs::write(&config_path, "glassmorphism = true\n").unwrap(); + + let (config, diagnostics) = + load_startup_config_from(&config_path, &legacy_path, ConfigOverrides::default()) + .unwrap(); + + assert_eq!(config.background_opacity, GLASS_BACKGROUND_OPACITY); + assert_eq!(config.background_blur_radius, GLASS_BACKGROUND_BLUR_RADIUS); + assert!(diagnostics.is_empty()); + fs::remove_dir_all(dir).unwrap(); + } + + // `--config-default-files=false` resolves through a different loader; + // the takeover must not be file-path-specific. + #[test] + fn glassmorphism_forces_the_pair_without_config_files_too() { + let cli = ConfigOverrides { + glassmorphism: Some(true), + background_opacity: Some(1.0), + ..Default::default() + }; + + let (config, diagnostics) = load_startup_config_without_files(cli).unwrap(); + + assert_eq!(config.background_opacity, GLASS_BACKGROUND_OPACITY); + assert_eq!(config.background_blur_radius, GLASS_BACKGROUND_BLUR_RADIUS); + assert_eq!(diagnostics.len(), 1); + assert!(diagnostics[0].message.contains("background-opacity")); + } + #[test] fn config_debug_redacts_server_and_client_tokens() { let startup = StartupConfig { diff --git a/crates/noa-config/src/parser/overrides.rs b/crates/noa-config/src/parser/overrides.rs index b9165b1..ff618bf 100644 --- a/crates/noa-config/src/parser/overrides.rs +++ b/crates/noa-config/src/parser/overrides.rs @@ -43,6 +43,7 @@ pub(crate) fn build_overrides( let mut cursor_stop_blinking_after_secs = None; let mut background_opacity = None; let mut background_blur_radius = None; + let mut glassmorphism = None; let mut background_image = None; let mut background_image_opacity = None; let mut background_image_position = None; @@ -217,6 +218,9 @@ pub(crate) fn build_overrides( "background-blur-radius" => { background_blur_radius = parse_blur_radius(path, directive, &mut diagnostics); } + "glassmorphism" => { + glassmorphism = parse_bool_directive(path, directive, &mut diagnostics); + } "background-image" => { background_image = parse_background_image(directive); } @@ -432,6 +436,7 @@ pub(crate) fn build_overrides( cursor_stop_blinking_after_secs, background_opacity, background_blur_radius, + glassmorphism, background_image, background_image_opacity, background_image_position, @@ -539,6 +544,7 @@ pub(crate) fn is_supported_scalar_key(key: &str) -> bool { | "alpha-blending" | "background-opacity" | "background-blur-radius" + | "glassmorphism" | "background-image" | "background-image-opacity" | "background-image-position" diff --git a/crates/noa-config/src/parser/tests.rs b/crates/noa-config/src/parser/tests.rs index bdf369f..0f2df2c 100644 --- a/crates/noa-config/src/parser/tests.rs +++ b/crates/noa-config/src/parser/tests.rs @@ -599,6 +599,27 @@ fn background_blur_radius_parses_int_bool_and_clamps() { } } +// `glassmorphism` is an opt-in appearance flag: absent from the config it +// must stay `None` so the resolved default (off) wins, and it accepts the same +// truthy spellings as every other bool key. +#[test] +fn glassmorphism_parses_bool_and_defaults_to_unset() { + let (absent, diagnostics) = parse_overrides(path(), "font-size = 13"); + assert_eq!(absent.glassmorphism, None); + assert!(diagnostics.is_empty(), "{diagnostics:?}"); + + for (value, expected) in [("true", true), ("false", false)] { + let (overrides, diagnostics) = parse_overrides(path(), &format!("glassmorphism = {value}")); + assert_eq!(overrides.glassmorphism, Some(expected), "{value:?}"); + assert!(diagnostics.is_empty(), "{value:?}: {diagnostics:?}"); + } + + let (invalid, diagnostics) = parse_overrides(path(), "glassmorphism = frosted"); + assert_eq!(invalid.glassmorphism, None); + assert_eq!(diagnostics.len(), 1); + assert!(diagnostics[0].message.contains("glassmorphism")); +} + #[test] fn background_blur_radius_rejects_non_integer() { let (overrides, diagnostics) = parse_overrides(path(), "background-blur-radius = blurry"); diff --git a/crates/noa-render/src/blit.rs b/crates/noa-render/src/blit.rs index e3cc5af..6db0672 100644 --- a/crates/noa-render/src/blit.rs +++ b/crates/noa-render/src/blit.rs @@ -282,6 +282,13 @@ impl BlitPipeline { /// knob); this struct just carries them across the crate boundary. #[derive(Clone, Copy, Debug)] pub struct CardStyle { + /// The card *fill*, used only by the paths that clear their target with + /// it (`composite_cards`). The texture-card overlays + /// (`overlay_texture_cards*`) never read it: `card.wgsl` builds its + /// output from the sampled texture and the border stroke, and takes its + /// alpha from `tex.a` alone. A translucent surface on those paths must + /// therefore be *rasterized* translucent — setting an alpha here does + /// nothing. pub background: [f32; 4], pub border_color: [f32; 4], pub focus_color: [f32; 4], @@ -360,6 +367,11 @@ const CARD_POOL_CAP: usize = 32; pub struct CardPipeline { pipeline: wgpu::RenderPipeline, + /// Glow/focus-ring-only pipeline (`fs_glow`), drawn separately from + /// `pipeline` so it can use `GLOW_PRESERVE_DST_ALPHA` regardless of + /// which blend `pipeline` was constructed with — see that constant's + /// doc comment. + glow_pipeline: wgpu::RenderPipeline, bind_group_layout: wgpu::BindGroupLayout, sampler: wgpu::Sampler, /// Per-card uniform buffer + bind group, reused across frames when the @@ -396,6 +408,30 @@ impl CardPipeline { }, }; + /// Blend for the glow/focus-ring pass (`fs_glow`, see its doc comment in + /// `card.wgsl`). Color is a normal over-blend, same as `ALPHA_REPLACE`'s + /// and `ALPHA_BLENDING`'s color factors — only the alpha channel differs: + /// the destination's existing alpha is kept as-is (`dst·1`) rather than + /// replaced or accumulated (`src·0`), so a glow drawn under + /// `ALPHA_REPLACE` tints color without eroding whatever alpha the face + /// pass (or an earlier composite) already wrote there. Under an opaque + /// backdrop (`ALPHA_BLENDING` mode, dst alpha already 1) this is + /// numerically identical to the old combined-shader behavior, since 1 + /// held unchanged equals 1 accumulated toward — the split only changes + /// output where dst alpha is < 1, i.e. under glassmorphism. + pub const GLOW_PRESERVE_DST_ALPHA: wgpu::BlendState = wgpu::BlendState { + color: wgpu::BlendComponent { + src_factor: wgpu::BlendFactor::SrcAlpha, + dst_factor: wgpu::BlendFactor::OneMinusSrcAlpha, + operation: wgpu::BlendOperation::Add, + }, + alpha: wgpu::BlendComponent { + src_factor: wgpu::BlendFactor::Zero, + dst_factor: wgpu::BlendFactor::One, + operation: wgpu::BlendOperation::Add, + }, + }; + pub fn new( device: &wgpu::Device, format: wgpu::TextureFormat, @@ -485,6 +521,43 @@ impl CardPipeline { cache: None, }); + // Same layout/vertex stage as `pipeline`, but draws only the glow + // ring (`fs_glow`) with `GLOW_PRESERVE_DST_ALPHA` instead of the + // caller-selected face blend — see that constant's doc comment. + let glow_pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor { + label: Some("noa-overview-card-glow-pipeline"), + layout: Some(&pipeline_layout), + vertex: wgpu::VertexState { + module: &shader, + entry_point: Some("vs_main"), + buffers: &[], + compilation_options: wgpu::PipelineCompilationOptions::default(), + }, + fragment: Some(wgpu::FragmentState { + module: &shader, + entry_point: Some("fs_glow"), + targets: &[Some(wgpu::ColorTargetState { + format, + blend: Some(Self::GLOW_PRESERVE_DST_ALPHA), + write_mask: wgpu::ColorWrites::ALL, + })], + compilation_options: wgpu::PipelineCompilationOptions::default(), + }), + primitive: wgpu::PrimitiveState { + topology: wgpu::PrimitiveTopology::TriangleList, + strip_index_format: None, + front_face: wgpu::FrontFace::Ccw, + cull_mode: None, + unclipped_depth: false, + polygon_mode: wgpu::PolygonMode::Fill, + conservative: false, + }, + depth_stencil: None, + multisample: wgpu::MultisampleState::default(), + multiview: None, + cache: None, + }); + let sampler = device.create_sampler(&wgpu::SamplerDescriptor { label: Some("noa-overview-card-sampler"), address_mode_u: wgpu::AddressMode::ClampToEdge, @@ -498,6 +571,7 @@ impl CardPipeline { Self { pipeline, + glow_pipeline, bind_group_layout, sampler, pool: RefCell::new(HashMap::new()), @@ -639,6 +713,12 @@ impl CardPipeline { }) .collect(); + // Parallel to `placement_keys`: whether this placement's glow ring + // is actually visible, so the render pass below skips the extra + // `glow_pipeline` draw call for the common (unselected) case instead + // of issuing one that's guaranteed to discard every fragment. + let mut glow_flags = Vec::::with_capacity(placements.len()); + let mut pool = self.pool.borrow_mut(); for (placement, pool_key) in placements.iter().zip(&placement_keys) { let (border_color, border_width, glow_width) = if placement.selected { @@ -648,6 +728,7 @@ impl CardPipeline { }; let mut glow_color = style.focus_color; glow_color[3] = if placement.selected { 0.45 } else { 0.0 }; + glow_flags.push(glow_width > 0.0 && glow_color[3] > 0.0); let uniforms = CardUniformsRaw { rect: [ placement.x as f32, @@ -743,12 +824,23 @@ impl CardPipeline { timestamp_writes: None, occlusion_query_set: None, }); - pass.set_pipeline(&self.pipeline); - for pool_key in &placement_keys { + // Per placement: glow ring first, then the card face — the same + // relative order the old single-shader draw produced (both + // regions came out of one `pass.draw` per placement, in + // placement order). The two pipelines' fragments never overlap + // (`fs_main` only covers `coverage > 0`, `fs_glow` only + // `coverage <= 0`), so this split changes blend math, not + // layering. + for (pool_key, has_glow) in placement_keys.iter().zip(&glow_flags) { let pooled = pool .get(pool_key) .expect("pool entry inserted or refreshed above for every placement"); pass.set_bind_group(0, &pooled.bind_group, &[]); + if *has_glow { + pass.set_pipeline(&self.glow_pipeline); + pass.draw(0..6, 0..1); + } + pass.set_pipeline(&self.pipeline); pass.draw(0..6, 0..1); } } @@ -802,6 +894,11 @@ pub struct OverviewThumbnailResources { tile_size: PixelSize, title_bar_h: u32, card_color: [f32; 4], + /// The blend `card` was built with. A pipeline's blend state is fixed at + /// creation, so a caller whose compositing model can change at runtime + /// (`glassmorphism`) has to treat this as part of staleness — see + /// [`Self::card_blend`]. + card_blend: wgpu::BlendState, } impl OverviewThumbnailResources { @@ -815,9 +912,10 @@ impl OverviewThumbnailResources { tile_count: usize, title_bar_h: u32, card_color: [f32; 4], + card_blend: wgpu::BlendState, ) -> Self { let blit = BlitPipeline::new(device, format); - let card = CardPipeline::new(device, format, wgpu::BlendState::ALPHA_BLENDING); + let card = CardPipeline::new(device, format, card_blend); let scratch = OverviewScratchTexture::new(device, format, scratch_size); let tiles = (0..tile_count) .map(|_| OverviewTileTexture::new(device, format, tile_size)) @@ -840,6 +938,7 @@ impl OverviewThumbnailResources { tile_size, title_bar_h, card_color, + card_blend, }; // Freshly allocated tile textures hold uninitialized memory; a tile // that never receives its first mirror would otherwise be composited @@ -859,6 +958,7 @@ impl OverviewThumbnailResources { tile_count: usize, title_bar_h: u32, card_color: [f32; 4], + card_blend: wgpu::BlendState, ) -> Self { Self::new( device, @@ -869,6 +969,7 @@ impl OverviewThumbnailResources { tile_count, title_bar_h, card_color, + card_blend, ) } @@ -898,6 +999,20 @@ impl OverviewThumbnailResources { self.tiles.len() } + /// The blend the tile composite was built with (see the field). + pub fn card_blend(&self) -> wgpu::BlendState { + self.card_blend + } + + /// The card face color every tile texture was cleared to at allocation. + /// Exposed so a caller whose palette can change at runtime (a theme + /// polarity flip, a `glassmorphism` toggle) can treat a color change as + /// a reason to rebuild — the tiles carry the old color baked in, and no + /// later draw re-clears them. + pub fn card_color(&self) -> [f32; 4] { + self.card_color + } + pub fn title_bar_h(&self) -> u32 { self.title_bar_h } diff --git a/crates/noa-render/src/lib.rs b/crates/noa-render/src/lib.rs index a73b6f8..9a0a87b 100644 --- a/crates/noa-render/src/lib.rs +++ b/crates/noa-render/src/lib.rs @@ -37,4 +37,7 @@ pub use snapshot::{ CommandPaletteSnapshot, ConfirmDialogSnapshot, FrameSnapshot, FrameSnapshotRecycle, HoverLink, ImagePlacementSnapshot, PaletteRow, Preedit, SnapshotImage, }; -pub use theme::{OverlayStyle, Theme, UI_ACCENT, blend, contrast_ratio}; +pub use theme::{ + OverlayStyle, Theme, UI_ACCENT, blend, contrast_ratio, overlay_surface_alpha, + set_overlay_surface_alpha, +}; diff --git a/crates/noa-render/src/renderer/overlay.rs b/crates/noa-render/src/renderer/overlay.rs index 3ae937b..db0692d 100644 --- a/crates/noa-render/src/renderer/overlay.rs +++ b/crates/noa-render/src/renderer/overlay.rs @@ -232,12 +232,11 @@ pub(super) fn append_command_palette_instances( let style = OverlayStyle::from_theme(theme); let color = |c: [f32; 4]| to_u8_color(surface_output_rgba(c, target_format_is_srgb)); - let surface_bg = color(style.surface_bg()); let surface_fg = color(style.surface_fg()); let muted_fg = color(style.muted_fg()); let border = color(style.border()); let accent = color(style.accent()); - let selected_bg = color(style.selected_bg()); + let selected_wash = selected_row_wash_color(&style, target_format_is_srgb); // `shown/total` counter (A): how many entries are on screen vs the total // matched, shown only when the list is windowed short. @@ -298,13 +297,17 @@ pub(super) fn append_command_palette_instances( *slot = muted_fg; } } + // `None`: this row's background is the block's own surface color, which + // the scratch's clear already carries (glassmorphism fix 1) — an + // additional quad here would just double-apply `overlay_surface_alpha()` + // on top of the clear for no visual gain (same RGB either way). emit_palette_row( instances, font, metrics, x0, y0, - surface_bg, + None, &query_text, &query_fg, &query_bold, @@ -335,7 +338,7 @@ pub(super) fn append_command_palette_instances( let fg = vec![muted_fg; text.chars().count()]; let bold = vec![false; text.chars().count()]; emit_palette_row( - instances, font, metrics, x0, list_y0, surface_bg, &text, &fg, &bold, + instances, font, metrics, x0, list_y0, None, &text, &fg, &bold, ); } else { for (i, row) in visible.iter().enumerate() { @@ -346,9 +349,7 @@ pub(super) fn append_command_palette_instances( let text = palette_line(label, None, inner); let fg = vec![muted_fg; text.chars().count()]; let bold = vec![false; text.chars().count()]; - emit_palette_row( - instances, font, metrics, x0, y, surface_bg, &text, &fg, &bold, - ); + emit_palette_row(instances, font, metrics, x0, y, None, &text, &fg, &bold); } PaletteRow::Entry { title, @@ -357,7 +358,7 @@ pub(super) fn append_command_palette_instances( enabled, } => { let selected = offset + i == palette.selected; - let row_bg = if selected { selected_bg } else { surface_bg }; + let row_bg = selected.then_some(selected_wash); let text = palette_line(title, hint.as_deref(), inner); let ncols = text.chars().count(); let base_fg = if *enabled { surface_fg } else { muted_fg }; @@ -422,6 +423,13 @@ const PALETTE_CARET: char = '\u{258F}'; /// width only ever exceeds this for unusually long titles/queries, and only /// shrinks below it when the pane itself is narrower. const PALETTE_MIN_INNER: usize = 56; +/// Alpha of the selected row's accent wash quad (D), independent of +/// `overlay_surface_alpha()` — see the long comment at +/// [`selected_row_wash_color`] for why this can't just be +/// `OverlayStyle::selected_bg()`. `0.20` matches the mix fraction +/// `OverlayStyle::from_theme` uses for `selected_bg`'s own (opaque-path / +/// native-overlay) RGB, so the two stay visually consistent. +const SELECTED_ROW_WASH_ALPHA: f32 = 0.20; /// The resolved geometry of the palette block for a given grid: where it sits, /// how big it is (in grid cells), and which slice of `rows` is visible. Shared @@ -530,11 +538,87 @@ pub fn command_palette_layout( }) } -/// Emit one palette row: its `block_w` background cells then its shaped glyph -/// run, with a per-column foreground and per-column bold flag (so match -/// highlights and dimmed hints paint within a single row). `text` is -/// `inner + 2` columns of ASCII/width-1 glyphs, so column index equals char -/// index. +/// The selected row's accent wash color (D). NOT `style.selected_bg()`: that +/// stamps `overlay_surface_alpha()` into its own alpha, which is meant for +/// contexts (the macOS native overlay) that paint it as this row's *only* +/// fill. Here it's drawn as an extra quad on top of a scratch already +/// cleared to `overlay_surface_alpha()` (glassmorphism fix 1) — standard +/// "over" blending can only ever *raise* alpha above whatever's underneath +/// for any nonzero quad alpha (`src_a + dst_a*(1-src_a) > dst_a` whenever +/// `dst_a < 1`), so reusing the surface alpha here would double-apply it, +/// exactly like the plain rows used to (they now emit no quad at all — +/// `overlay_surface_alpha()` reaches them purely through the clear). Using a +/// *fixed* low wash alpha instead bounds the overshoot: at +/// `overlay_surface_alpha() = 0.68`, this row lands at +/// `0.20 + 0.68*0.80 = 0.744` instead of the old `0.68 + 0.68*0.32 = 0.898` — +/// closer to the other rows' exact `0.68`, though not identical (that would +/// need a second draw pass with a dst-alpha-preserving blend, ordered +/// between this row's background and its glyphs — more machinery than one +/// row's density warrants). At `overlay_surface_alpha() = 1.0` (glassmorphism +/// off) this is exactly `0.20 + 1.0*0.80 = 1.0`, so the non-glass render is +/// unaffected. +/// +/// Color space: `OverlayStyle::selected_bg`'s original color (`theme.rs`'s +/// `blend(surface_bg, OVERLAY_ACCENT, 0.20)`) lerps 8-bit sRGB channels +/// directly. But this quad is composited by the GPU's fixed-function blend +/// hardware, which — whenever the render target is an `*Srgb` format +/// (`target_format_is_srgb`) — blends in LINEAR light, not sRGB-encoded +/// values (`surface_output_rgba` pre-linearizes every other color this +/// module emits for exactly that reason). A plain accent quad at +/// `SELECTED_ROW_WASH_ALPHA` would therefore mix a visibly brighter, more +/// saturated color than the old sRGB-space blend intended on a dark theme — +/// a silent restyle, not just an alpha change. Pre-compensate: solve for the +/// LINEAR quad value `q` that reproduces the OLD sRGB-lerped `selected_bg` +/// once the GPU blends it against `surface_bg` at this wash's alpha `a`: +/// ```text +/// s2l(q) = (s2l(target) - (1 - a) * s2l(surface)) / a +/// ``` +/// where `target` is the old `selected_bg`, `surface` is `surface_bg`, and +/// `s2l` is the sRGB electro-optical transfer function (`srgb_to_linear`) — +/// packed directly as the linear value (skipping a redundant +/// encode-then-decode round trip through `color()`, which would only cost +/// precision). For a light theme `q` can go negative — the target is darker +/// than `(1 - a) * surface` alone reaches even at `q = 0` — so no quad color +/// can hit it exactly at this alpha; clamped to `0.0` and the (still +/// bounded, still smaller than reusing `overlay_surface_alpha()` would have +/// been) residual is accepted. See +/// `selected_row_wash_reproduces_the_old_srgb_lerp_after_gpu_blend` in +/// `renderer/tests/overlay.rs` for the measured error in both cases. +/// +/// Skipped when the target ISN'T sRGB: there `surface_output_rgba` is a +/// passthrough, so the GPU blends the stored values directly and a plain +/// accent quad already reproduces the old sRGB-space math with no +/// compensation needed. +pub(super) fn selected_row_wash_color( + style: &OverlayStyle, + target_format_is_srgb: bool, +) -> [u8; 4] { + let a = SELECTED_ROW_WASH_ALPHA; + if target_format_is_srgb { + let [tr, tg, tb, _] = style.selected_bg(); + let [sr, sg, sb, _] = style.surface_bg(); + let compensate = |target: f32, surface: f32| { + ((srgb_to_linear(target) - (1.0 - a) * srgb_to_linear(surface)) / a).clamp(0.0, 1.0) + }; + to_u8_color([ + compensate(tr, sr), + compensate(tg, sg), + compensate(tb, sb), + a, + ]) + } else { + let [ar, ag, ab, _] = style.accent(); + to_u8_color(surface_output_rgba([ar, ag, ab, a], target_format_is_srgb)) + } +} + +/// Emit one palette row: its `block_w` background cells (if `bg` is `Some` — +/// `None` means this row's background is the block's own surface color, +/// already carried by the scratch's clear, so no quad is drawn for it; see +/// [`append_command_palette_instances`]) then its shaped glyph run, with a +/// per-column foreground and per-column bold flag (so match highlights and +/// dimmed hints paint within a single row). `text` is `inner + 2` columns of +/// ASCII/width-1 glyphs, so column index equals char index. #[allow(clippy::too_many_arguments)] fn emit_palette_row( instances: &mut Vec, @@ -542,21 +626,23 @@ fn emit_palette_row( metrics: Metrics, x0: u16, y: u16, - bg: [u8; 4], + bg: Option<[u8; 4]>, text: &str, fg_by_col: &[[u8; 4]], bold_by_col: &[bool], ) { let cells = palette_segment_cells(text, fg_by_col, bold_by_col); - for i in 0..cells.len() as u16 { - instances.push(CellInstance { - glyph_pos: [0, 0], - glyph_size: [0, 0], - bearing: [0, 0], - grid_pos: [x0 + i, y], - color: bg, - flags: 0, - }); + if let Some(bg) = bg { + for i in 0..cells.len() as u16 { + instances.push(CellInstance { + glyph_pos: [0, 0], + glyph_size: [0, 0], + bearing: [0, 0], + grid_pos: [x0 + i, y], + color: bg, + flags: 0, + }); + } } for mut run in segment_row(font, &cells) { run.start_col += x0; @@ -639,28 +725,21 @@ pub(super) fn append_confirm_dialog_instances( }; let style = OverlayStyle::from_theme(theme); - let surface_bg = to_u8_color(surface_output_rgba( - style.surface_bg(), - target_format_is_srgb, - )); let surface_fg = to_u8_color(surface_output_rgba( style.surface_fg(), target_format_is_srgb, )); let muted_fg = to_u8_color(surface_output_rgba(style.muted_fg(), target_format_is_srgb)); + // Both rows are the block's own surface color (the dialog has no + // selected/differing row) — `None` so neither draws a background quad; + // the scratch's clear already carries `overlay_surface_alpha()` + // (glassmorphism fix 1), and a same-color quad on top would just + // double-apply it, as the whole card used to before this fix. let inner = layout.inner as usize; let rows = [ - OverlayRow::uniform( - palette_line(&dialog.message, None, inner), - surface_bg, - surface_fg, - ), - OverlayRow::uniform( - palette_line(&dialog.hint, None, inner), - surface_bg, - muted_fg, - ), + OverlayRow::uniform(palette_line(&dialog.message, None, inner), None, surface_fg), + OverlayRow::uniform(palette_line(&dialog.hint, None, inner), None, muted_fg), ]; for (i, row) in rows.iter().enumerate() { append_overlay_row( @@ -724,19 +803,24 @@ pub fn confirm_dialog_layout( }) } -/// One row of a modal overlay block: a full-`block_w`-column line of text, a -/// background color, and a per-column foreground (so a palette entry can paint -/// its title and its dimmed keybind hint in different colors within one row). +/// One row of a modal overlay block: a full-`block_w`-column line of text, an +/// optional background color, and a per-column foreground (so a palette entry +/// can paint its title and its dimmed keybind hint in different colors within +/// one row). pub(super) struct OverlayRow { text: String, - bg: [u8; 4], + /// `None` when this row's background is the block's own surface color — + /// already carried by the scratch's clear, so no quad is drawn (see + /// [`append_confirm_dialog_instances`]). `Some` for a row that needs a + /// visibly different fill. + bg: Option<[u8; 4]>, /// One foreground color per column of `text`. fg: Vec<[u8; 4]>, } impl OverlayRow { /// A row painted in a single foreground color. - fn uniform(text: String, bg: [u8; 4], fg: [u8; 4]) -> Self { + fn uniform(text: String, bg: Option<[u8; 4]>, fg: [u8; 4]) -> Self { let cols = text.chars().count(); OverlayRow { text, @@ -747,7 +831,8 @@ impl OverlayRow { } /// Emit one overlay row's background rects (`block_w` cells wide, from `row`'s -/// text length) plus its per-column-colored shaped glyphs at grid row `y`. +/// text length, skipped entirely when `row.bg` is `None`) plus its +/// per-column-colored shaped glyphs at grid row `y`. pub(super) fn append_overlay_row( instances: &mut Vec, font: &mut FontGrid, @@ -757,15 +842,17 @@ pub(super) fn append_overlay_row( row: &OverlayRow, ) { let cells = overlay_segment_cells(&row.text, &row.fg); - for i in 0..cells.len() as u16 { - instances.push(CellInstance { - glyph_pos: [0, 0], - glyph_size: [0, 0], - bearing: [0, 0], - grid_pos: [x0 + i, y], - color: row.bg, - flags: 0, - }); + if let Some(bg) = row.bg { + for i in 0..cells.len() as u16 { + instances.push(CellInstance { + glyph_pos: [0, 0], + glyph_size: [0, 0], + bearing: [0, 0], + grid_pos: [x0 + i, y], + color: bg, + flags: 0, + }); + } } for mut run in segment_row(font, &cells) { run.start_col += x0; diff --git a/crates/noa-render/src/renderer/tests/overlay.rs b/crates/noa-render/src/renderer/tests/overlay.rs index 1ec5de2..a33e709 100644 --- a/crates/noa-render/src/renderer/tests/overlay.rs +++ b/crates/noa-render/src/renderer/tests/overlay.rs @@ -257,6 +257,103 @@ fn palette_scroll_window_keeps_the_selection_visible() { assert_eq!(palette_scroll_window(5, 0, 0), (0, 0)); } +/// sRGB electro-optical inverse (linear -> sRGB-encoded), the GPU operation +/// this test needs to reconstruct what a `Bgra8UnormSrgb` target's automatic +/// encode-on-write does. `noa-render` never needs this direction in +/// production (the GPU performs it in hardware), so it lives only here. +fn linear_to_srgb(channel: f32) -> f32 { + let channel = channel.clamp(0.0, 1.0); + if channel <= 0.003_130_8 { + channel * 12.92 + } else { + 1.055 * channel.powf(1.0 / 2.4) - 0.055 + } +} + +/// Glassmorphism color-space regression (review follow-up on the alpha fix): +/// `selected_row_wash_color` must reproduce `OverlayStyle::selected_bg()`'s +/// RGB — the OLD sRGB-space-lerped color — once actually composited by the +/// GPU, not just carry the right alpha. Reconstructs both the sRGB-target +/// path (linear blend, matching `Renderer`'s `Bgra8UnormSrgb` use) and the +/// non-sRGB path (direct blend, no pre-linearization) for a dark and a light +/// theme, mirroring `overlay_style_tracks_theme_polarity`'s fixtures +/// (`crates/noa-render/src/theme.rs`). +#[test] +fn selected_row_wash_reproduces_the_old_srgb_lerp_after_gpu_blend() { + fn reconstruct(quad: [u8; 4], surface_srgb: [f32; 3], target_format_is_srgb: bool) -> [f32; 3] { + let a = f32::from(quad[3]) / 255.0; + let q = [ + f32::from(quad[0]) / 255.0, + f32::from(quad[1]) / 255.0, + f32::from(quad[2]) / 255.0, + ]; + if target_format_is_srgb { + // Both the quad and the destination were pre-linearized before + // packing (`surface_output_rgba`); the GPU blends those linear + // values, and the `*Srgb` target's automatic encode-on-write + // stores the sRGB-encoded result — decode, blend, re-encode. + let s = surface_srgb.map(srgb_to_linear); + std::array::from_fn(|i| linear_to_srgb(q[i] * a + s[i] * (1.0 - a))) + } else { + // No pre-linearization: the GPU blends the stored values as-is. + std::array::from_fn(|i| q[i] * a + surface_srgb[i] * (1.0 - a)) + } + } + + // sRGB-to-u8 with the same rounding `to_u8_color`/`Rgb` use elsewhere, + // so the tolerances below are in the same units as the theme's own + // 8-bit channels. + let to_byte = |c: f32| (c.clamp(0.0, 1.0) * 255.0).round(); + + let dark = Theme::new(); + let mut light = Theme::new(); + light.default_fg = noa_core::Rgb::new(0x20, 0x20, 0x20); + light.default_bg = noa_core::Rgb::new(0xf7, 0xf7, 0xf7); + + for (theme_name, theme) in [("dark", &dark), ("light", &light)] { + let style = OverlayStyle::from_theme(theme); + let target = style.selected_bg(); // the OLD sRGB-lerped color to reproduce + let surface = style.surface_bg(); + + for target_format_is_srgb in [true, false] { + let quad = selected_row_wash_color(&style, target_format_is_srgb); + let reconstructed = reconstruct( + quad, + [surface[0], surface[1], surface[2]], + target_format_is_srgb, + ); + + // Non-sRGB targets don't pre-linearize at all (see + // `selected_row_wash_color`'s else branch), so this path was + // never affected by the color-space bug and should reproduce + // the old blend essentially exactly regardless of theme. + // + // sRGB targets: dark themes solve exactly (no clamp needed — + // verified against the review's own worked numbers). Light + // themes can clamp (target darker than `(1-a)*surface` alone + // reaches even at quad = 0), so that case gets a wider, + // explicitly-bounded tolerance instead of pretending it's exact. + let tolerance = if target_format_is_srgb && theme_name == "light" { + 24.0 + } else { + 2.0 + }; + for (channel, name) in [0, 1, 2].into_iter().zip(["r", "g", "b"]) { + let got = to_byte(reconstructed[channel]); + let want = to_byte(target[channel]); + assert!( + (got - want).abs() <= tolerance, + "{theme_name} theme, target_format_is_srgb={target_format_is_srgb}, \ + channel {name}: composited selected-row wash should reproduce \ + OverlayStyle::selected_bg() ({want}) within {tolerance}, got {got} \ + (light+sRGB is the one case this can't hit exactly — see \ + `selected_row_wash_color`'s doc on the light-theme clamp)" + ); + } + } + } +} + #[test] fn command_palette_overlay_emits_bg_and_glyph_instances_and_clears_on_close() { let Some(mut font) = font_with_rasterized_m() else { @@ -304,27 +401,36 @@ fn command_palette_overlay_emits_bg_and_glyph_instances_and_clears_on_close() { let mut with_palette = Vec::new(); rebuild_cell_instances(&mut with_palette, &snap, &mut font, &theme, false); + // Only the selected row draws a background quad (its accent wash): the + // other three (query + two non-selected entries) are the block's own + // surface color, already carried by the scratch's clear (glassmorphism + // alpha-double-apply fix) — see `append_command_palette_instances`'s + // `selected_wash`. let bg_with = with_palette.iter().filter(|i| i.flags == 0).count(); assert!( bg_with > bg_before, - "opening the palette must add background quads" + "opening the palette must add the selected row's background quad" ); - // The block spans 4 grid rows (query + 3 entries); at least those - // rows must carry palette instances. - let rows_touched: std::collections::BTreeSet = with_palette + let bg_rows_touched: std::collections::BTreeSet = with_palette .iter() .filter(|i| i.flags == 0) .map(|i| i.grid_pos[1]) .collect(); - assert!( - rows_touched.len() >= 4, - "query row plus three entry rows must all draw: {rows_touched:?}" + assert_eq!( + bg_rows_touched.len(), + 1, + "exactly the selected row draws a background quad: {bg_rows_touched:?}" ); + // The block spans 4 grid rows (query + 3 entries); all four still emit + // glyphs even though only one of them also emits a background quad. + let glyph_rows_touched: std::collections::BTreeSet = with_palette + .iter() + .filter(|i| i.flags & CellInstance::FLAG_GLYPH != 0) + .map(|i| i.grid_pos[1]) + .collect(); assert!( - with_palette - .iter() - .any(|i| i.flags & CellInstance::FLAG_GLYPH != 0), - "the palette text must emit glyph instances" + glyph_rows_touched.len() >= 4, + "query row plus three entry rows must all draw text: {glyph_rows_touched:?}" ); snap.command_palette = None; @@ -356,20 +462,22 @@ fn command_palette_overlay_shows_empty_state_for_zero_results() { let mut with_empty_palette = Vec::new(); rebuild_cell_instances(&mut with_empty_palette, &snap, &mut font, &theme, false); - let rows_touched: std::collections::BTreeSet = with_empty_palette + // Neither row draws a background quad here: the query row and the + // empty-state row are both the block's own surface color, already + // carried by the scratch's clear (glassmorphism alpha-double-apply fix). + assert_eq!( + with_empty_palette.iter().filter(|i| i.flags == 0).count(), + 0, + "no selected row in the empty state, so no background quads are drawn" + ); + let glyph_rows_touched: std::collections::BTreeSet = with_empty_palette .iter() - .filter(|i| i.flags == 0) + .filter(|i| i.flags & CellInstance::FLAG_GLYPH != 0) .map(|i| i.grid_pos[1]) .collect(); assert!( - rows_touched.len() >= 2, - "query row and empty-state row must both draw: {rows_touched:?}" - ); - assert!( - with_empty_palette - .iter() - .any(|i| i.flags & CellInstance::FLAG_GLYPH != 0), - "the empty-state text must emit glyph instances" + glyph_rows_touched.len() >= 2, + "query row and empty-state row must both draw text: {glyph_rows_touched:?}" ); } @@ -390,7 +498,10 @@ fn confirm_dialog_overlay_emits_bg_and_glyph_instances_and_clears_on_close() { let mut closed = Vec::new(); rebuild_cell_instances(&mut closed, &snap, &mut font, &theme, false); - let bg_before = closed.iter().filter(|i| i.flags == 0).count(); + let glyphs_before = closed + .iter() + .filter(|i| i.flags & CellInstance::FLAG_GLYPH != 0) + .count(); snap.confirm_dialog = Some(crate::ConfirmDialogSnapshot { message: "Paste 3 line(s) of text?".to_string(), @@ -399,33 +510,44 @@ fn confirm_dialog_overlay_emits_bg_and_glyph_instances_and_clears_on_close() { let mut with_dialog = Vec::new(); rebuild_cell_instances(&mut with_dialog, &snap, &mut font, &theme, false); + // NOT a background-quad count: both dialog rows are the block's own + // surface color, which the scratch's clear already carries (glassmorphism + // alpha-double-apply fix), so `append_confirm_dialog_instances` draws + // zero `flags == 0` quads for them by design — see + // `confirm_dialog_glyph_rows`'s doc. Glyphs are still the right proxy. assert!( - with_dialog.iter().filter(|i| i.flags == 0).count() > bg_before, - "opening the dialog must add background quads" + with_dialog + .iter() + .filter(|i| i.flags & CellInstance::FLAG_GLYPH != 0) + .count() + > glyphs_before, + "opening the dialog must add glyph instances" + ); + assert_eq!( + with_dialog.iter().filter(|i| i.flags == 0).count(), + 0, + "the dialog draws no background quads of its own (relies on the scratch's clear)" ); // A message row and a hint row. let rows_touched: std::collections::BTreeSet = with_dialog .iter() - .filter(|i| i.flags == 0) + .filter(|i| i.flags & CellInstance::FLAG_GLYPH != 0) .map(|i| i.grid_pos[1]) .collect(); assert!( rows_touched.len() >= 2, "message and hint rows must both draw: {rows_touched:?}" ); - assert!( - with_dialog - .iter() - .any(|i| i.flags & CellInstance::FLAG_GLYPH != 0), - "the dialog text must emit glyph instances" - ); snap.confirm_dialog = None; let mut reclosed = Vec::new(); rebuild_cell_instances(&mut reclosed, &snap, &mut font, &theme, false); assert_eq!( - reclosed.iter().filter(|i| i.flags == 0).count(), - bg_before, + reclosed + .iter() + .filter(|i| i.flags & CellInstance::FLAG_GLYPH != 0) + .count(), + glyphs_before, "closing the dialog removes its overlay instances" ); } @@ -440,19 +562,22 @@ fn confirm_dialog_is_two_rows_regardless_of_grid_height() { // The dialog block itself is always the compact message + hint pair; // its breathing room comes from noa-app's rounded-card composite, not // from padding rows in the instance stream. - let tall = confirm_dialog_bg_rows(&mut font, &theme, 40, 10); + let tall = confirm_dialog_glyph_rows(&mut font, &theme, 40, 10); assert_eq!(tall, 2, "tall grid draws the compact 2-row form"); - let short = confirm_dialog_bg_rows(&mut font, &theme, 40, 4); + let short = confirm_dialog_glyph_rows(&mut font, &theme, 40, 4); assert_eq!(short, 2, "short grid draws the compact 2-row form"); - let tiny = confirm_dialog_bg_rows(&mut font, &theme, 40, 1); + let tiny = confirm_dialog_glyph_rows(&mut font, &theme, 40, 1); assert_eq!(tiny, 0, "a one-row grid cannot host the dialog"); } -/// Count the distinct grid rows carrying confirm-dialog overlay background -/// quads (`flags == 0`) for a `cols` x `rows` grid. The default block -/// cursor paints a `FLAG_CURSOR` quad, not a plain bg quad, so it is not -/// counted here. -fn confirm_dialog_bg_rows(font: &mut FontGrid, theme: &Theme, cols: u16, rows: u16) -> usize { +/// Count the distinct grid rows carrying confirm-dialog overlay glyph +/// instances for a `cols` x `rows` grid. NOT background quads +/// (`flags == 0`): the dialog's two rows are always the block's own surface +/// color (glassmorphism alpha-double-apply fix), which the scratch's own +/// clear already carries, so neither row draws a background quad at all — +/// see `append_confirm_dialog_instances`. Glyphs are still emitted +/// unconditionally, so they're the right proxy for "did this row render." +fn confirm_dialog_glyph_rows(font: &mut FontGrid, theme: &Theme, cols: u16, rows: u16) -> usize { let mut terminal = Terminal::new(GridSize::new(cols, rows)); let mut snap = FrameSnapshot::from_terminal(&mut terminal); snap.confirm_dialog = Some(crate::ConfirmDialogSnapshot { @@ -462,7 +587,7 @@ fn confirm_dialog_bg_rows(font: &mut FontGrid, theme: &Theme, cols: u16, rows: u let mut inst = Vec::new(); rebuild_cell_instances(&mut inst, &snap, font, theme, false); inst.iter() - .filter(|i| i.flags == 0) + .filter(|i| i.flags & CellInstance::FLAG_GLYPH != 0) .map(|i| i.grid_pos[1]) .collect::>() .len() diff --git a/crates/noa-render/src/shaders/card.wgsl b/crates/noa-render/src/shaders/card.wgsl index a17d996..92e130d 100644 --- a/crates/noa-render/src/shaders/card.wgsl +++ b/crates/noa-render/src/shaders/card.wgsl @@ -1,8 +1,13 @@ // Rounded-card composite for the Session Overview (REQ-OV-12/14, v2 mockup // parity). Draws one tile texture as a rounded-corner card with a border / // focus ring, sampled over the near-black backdrop the composite pass clears -// to. One draw call per tile; `rect`/`surface_size` place the quad, and the -// fragment shader applies the rounded-rect SDF alpha mask + border stroke. +// to. `rect`/`surface_size` place the quad; the fragment shader applies the +// rounded-rect SDF alpha mask + border stroke. +// +// Two fragment entry points share this one geometry: `fs_main` (the card +// face, `coverage > 0`) and `fs_glow` (the focus/attention/zoom ring drawn +// outside it, `coverage <= 0`). `CardPipeline` draws each with its own blend +// state — see the doc on `fs_glow` for why the split exists. struct CardUniforms { // Vec4-first std140 order (see CLAUDE.md GPU gotcha): x, y, w, h in px. @@ -67,37 +72,77 @@ fn sd_round_box(p: vec2, half: vec2, radius: f32) -> f32 { return length(max(q, vec2(0.0))) + min(max(q.x, q.y), 0.0) - radius; } -@fragment -fn fs_main(in: VertexOut) -> @location(0) vec4 { +// Signed distance shared by `fs_main` and `fs_glow` so the two entry points +// agree exactly on where the card edge is. +fn card_sd(in: VertexOut) -> f32 { let half = u.rect.zw * 0.5; let p = in.local_px - half; - let d = sd_round_box(p, half, u.corner_radius); - let glow_width = max(u.glow_width, 0.0); + return sd_round_box(p, half, u.corner_radius); +} + +@fragment +fn fs_main(in: VertexOut) -> @location(0) vec4 { + let d = card_sd(in); // Card coverage: 1 inside, fading to 0 over ~1px at the rounded edge. + // The glow ring (`coverage <= 0`) is handled entirely by `fs_glow` now — + // see its doc comment for why the two need separate blend states. let coverage = 1.0 - smoothstep(0.0, 1.0, d); if coverage <= 0.0 { - if glow_width <= 0.0 { - discard; - } - let glow_alpha = (1.0 - smoothstep(0.0, glow_width, d)) * u.glow_color.a * u.opacity; - if glow_alpha <= 0.0 { - discard; - } - return vec4(u.glow_color.rgb, glow_alpha); + discard; } let tex = textureSample(tile_tex, tile_sampler, clamp(in.uv, vec2(0.0), vec2(1.0))); // `inset` is the distance from the edge, growing inward. The border stroke // occupies the outermost `border_width` px. let inset = -d; - let border_mix = 1.0 - smoothstep(u.border_width - 1.0, u.border_width, inset); - let rgb = mix(tex.rgb, u.border_color.rgb, clamp(border_mix, 0.0, 1.0)); + let border_mix = clamp(1.0 - smoothstep(u.border_width - 1.0, u.border_width, inset), 0.0, 1.0); + let rgb = mix(tex.rgb, u.border_color.rgb, border_mix); + // The stroke keeps the border color's own alpha instead of inheriting the + // sampled surface's. They differ only for a translucent source — a + // frosted `glassmorphism` card — where the rim is exactly the part that + // must stay solid to hold the card's edge against whatever shows through + // it. `max` so a caller passing a transparent border color (the common + // "no stroke" style, where `border_mix` is 0 anyway) can never *lower* + // the fill's alpha. + let alpha = mix(tex.a, max(tex.a, u.border_color.a), border_mix); // Multiply in the sampled texture's own alpha (not just the card-shape - // coverage) so a translucent source texture — e.g. the session sidebar's - // band rendered under background-opacity < 1 — stays translucent through - // the composite instead of being forced opaque. Every existing caller - // renders its source texture with clear alpha 1.0, so `tex.a` is 1.0 there - // and this is a no-op (unchanged output). - return vec4(rgb, coverage * tex.a * u.opacity); + // coverage) so a translucent source texture — the session sidebar's band + // under `background-opacity < 1`, a frosted modal card — stays + // translucent through the composite instead of being forced opaque. A + // caller that renders its source with clear alpha 1.0 has `tex.a` = 1.0, + // so this is a no-op for it (unchanged output). + return vec4(rgb, coverage * alpha * u.opacity); +} + +// The focus/attention/zoom ring drawn outside the rounded card (`coverage <= +// 0`), split out of `fs_main` so `CardPipeline` can draw it with a blend +// state that preserves destination alpha instead of replacing it. +// +// Under `CardPipeline::ALPHA_REPLACE` (glassmorphism's Overview/sidebar +// composites — see `overview_card_blend`), the face pass intentionally +// writes its own alpha over the destination so repeated interaction-state +// draws don't thicken the glass. Applying that same replace behavior to the +// glow's 0.45->0 falloff would instead punch a fading transparent halo into +// the backdrop each frame a tile is selected/attention-flagged/zoomed — the +// glow's *own* low alpha would replace whatever (possibly higher) alpha was +// already there. `CardPipeline::GLOW_PRESERVE_DST_ALPHA` keeps this pass's +// RGB as a normal over-blend but leaves the destination alpha untouched, so +// the glow only tints color, never erodes the surface it's drawn onto. Under +// `ALPHA_BLENDING` (opaque palette) the backdrop's alpha is already 1 and +// stays 1 either way, so this split is a no-op there — see +// `crates/noa-render/tests/pipeline.rs` for the regression coverage. +@fragment +fn fs_glow(in: VertexOut) -> @location(0) vec4 { + let d = card_sd(in); + let coverage = 1.0 - smoothstep(0.0, 1.0, d); + let glow_width = max(u.glow_width, 0.0); + if coverage > 0.0 || glow_width <= 0.0 { + discard; + } + let glow_alpha = (1.0 - smoothstep(0.0, glow_width, d)) * u.glow_color.a * u.opacity; + if glow_alpha <= 0.0 { + discard; + } + return vec4(u.glow_color.rgb, glow_alpha); } diff --git a/crates/noa-render/src/theme.rs b/crates/noa-render/src/theme.rs index d33dbe7..9fe85bc 100644 --- a/crates/noa-render/src/theme.rs +++ b/crates/noa-render/src/theme.rs @@ -7,6 +7,8 @@ //! ramp) plus a default foreground/background, mirroring Ghostty's default //! theme. +use std::sync::atomic::{AtomicU32, Ordering}; + use noa_core::{Color, DEFAULT_BG, DEFAULT_CURSOR, DEFAULT_FG, Rgb, xterm_palette}; use noa_grid::TerminalColors; @@ -282,6 +284,35 @@ pub struct OverlayStyle { /// so the selection cue reads the same across themes. const OVERLAY_ACCENT: Rgb = UI_ACCENT; +/// Alpha applied to overlay *surface fills* (card face, highlighted row) — +/// `1.0` normally, below it under `glassmorphism`, so the navigation surfaces +/// (command palette, search prompt, confirm dialogs) read as frosted panes +/// like the sidebar and tab overview rather than as the one opaque element +/// left on screen. Stored as `f32` bits: a process-wide display token set +/// once per palette install, read on every overlay build. +/// +/// Text, borders, and accents deliberately stay opaque — the alpha is a +/// property of the *surface*, and translucent glyphs would cost legibility +/// for no glass gain. +static OVERLAY_SURFACE_ALPHA: AtomicU32 = AtomicU32::new(1.0_f32.to_bits()); + +/// Install the overlay surface alpha (see [`OVERLAY_SURFACE_ALPHA`]). Called +/// from the same place the chrome palette is installed, so the two never +/// disagree about whether this session is frosted. +pub fn set_overlay_surface_alpha(alpha: f32) { + OVERLAY_SURFACE_ALPHA.store(alpha.clamp(0.0, 1.0).to_bits(), Ordering::Relaxed); +} + +/// The active overlay surface alpha. +pub fn overlay_surface_alpha() -> f32 { + f32::from_bits(OVERLAY_SURFACE_ALPHA.load(Ordering::Relaxed)) +} + +fn rgba_surface(color: Rgb) -> [f32; 4] { + let [r, g, b, _] = rgba(color); + [r, g, b, overlay_surface_alpha()] +} + impl OverlayStyle { /// Derive the overlay palette from `theme`: /// - `surface_bg` = 8% of the way from the terminal bg toward its fg — an @@ -310,7 +341,7 @@ impl OverlayStyle { } pub fn surface_bg(&self) -> [f32; 4] { - rgba(self.surface_bg) + rgba_surface(self.surface_bg) } pub fn surface_fg(&self) -> [f32; 4] { @@ -340,9 +371,11 @@ impl OverlayStyle { } /// The highlighted palette row's background — a step brighter than - /// `surface_bg` (D). + /// `surface_bg` (D). Carries the same surface alpha: it is painted *over* + /// the card face, so an opaque row inside a frosted card would read as a + /// solid bar floating on glass. pub fn selected_bg(&self) -> [f32; 4] { - rgba(self.selected_bg) + rgba_surface(self.selected_bg) } } @@ -512,4 +545,50 @@ mod tests { assert!(light_style.surface_bg.r < light.default_bg.r); assert!(light_style.muted_fg.r > light.default_fg.r); } + + // `OVERLAY_SURFACE_ALPHA` is a process-wide static and cargo runs tests + // in parallel, so any test that installs a value must serialize against + // every other one that reads it. + static OVERLAY_ALPHA_TEST_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(()); + + // Glassmorphism frosts the overlay *surfaces* only: the card face and the + // highlighted row carry the installed alpha, while text, borders, and the + // accent stay fully opaque (translucent glyphs would cost legibility for + // no glass gain). RGB is never touched by the alpha. + #[test] + fn overlay_surface_alpha_reaches_only_the_surface_fills() { + let _guard = OVERLAY_ALPHA_TEST_LOCK.lock().unwrap(); + let style = OverlayStyle::from_theme(&Theme::new()); + assert_eq!(style.surface_bg()[3], 1.0); + + set_overlay_surface_alpha(0.82); + assert_eq!(overlay_surface_alpha(), 0.82); + assert_eq!(style.surface_bg()[3], 0.82); + assert_eq!(style.selected_bg()[3], 0.82); + assert_eq!(style.surface_bg()[..3], rgba(style.surface_bg)[..3]); + for opaque in [ + style.surface_fg(), + style.muted_fg(), + style.border(), + style.accent(), + style.accent_bg(), + style.accent_fg(), + ] { + assert_eq!(opaque[3], 1.0); + } + + // Restore the default for every other test in this process. + set_overlay_surface_alpha(1.0); + assert_eq!(style.surface_bg()[3], 1.0); + } + + #[test] + fn overlay_surface_alpha_clamps_to_the_unit_range() { + let _guard = OVERLAY_ALPHA_TEST_LOCK.lock().unwrap(); + set_overlay_surface_alpha(2.5); + assert_eq!(overlay_surface_alpha(), 1.0); + set_overlay_surface_alpha(-1.0); + assert_eq!(overlay_surface_alpha(), 0.0); + set_overlay_surface_alpha(1.0); + } } diff --git a/crates/noa-render/tests/pipeline/cards.rs b/crates/noa-render/tests/pipeline/cards.rs index ed8102e..73e1884 100644 --- a/crates/noa-render/tests/pipeline/cards.rs +++ b/crates/noa-render/tests/pipeline/cards.rs @@ -38,6 +38,7 @@ fn overview_card_pipeline_composites_tiles_without_validation_error() { 2, TEST_TITLE_BAR_H, TEST_CARD_COLOR, + wgpu::BlendState::ALPHA_BLENDING, ); // Populate both tiles (mirror in the content region, card color in the band). for tile_index in 0..2 { @@ -702,3 +703,233 @@ fn command_palette_card_composites_without_validation_error() { "command-palette card produced a uniform frame (nothing drawn)" ); } + +/// A frosted card must stay frosted only where it is *surface*. `card.wgsl` +/// takes the fill's alpha from the sampled texture, so a translucent source +/// (a `glassmorphism` modal card, the sidebar band) would otherwise drag the +/// border stroke down with it and leave the card without an edge against +/// whatever shows through. The stroke takes the border color's own alpha +/// instead; the fill keeps the source's. +/// +/// Drawn over a target cleared to fully transparent, so the readback alpha is +/// exactly what the shader emitted. +#[test] +fn card_border_stays_opaque_over_a_translucent_source() { + let Some((device, queue)) = device_queue() else { + eprintln!("no wgpu adapter available — skipping card border alpha test"); + return; + }; + let format = wgpu::TextureFormat::Bgra8UnormSrgb; + let size = 64u32; + + // Source: a uniformly half-transparent surface, the way a frosted modal + // card's scratch texture is cleared. + let source = device.create_texture(&wgpu::TextureDescriptor { + label: Some("noa-test-translucent-source"), + size: wgpu::Extent3d { + width: size, + height: size, + depth_or_array_layers: 1, + }, + mip_level_count: 1, + sample_count: 1, + dimension: wgpu::TextureDimension::D2, + format, + usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::TEXTURE_BINDING, + view_formats: &[], + }); + let source_view = source.create_view(&wgpu::TextureViewDescriptor::default()); + + let (target_tex, target_view) = render_target(&device, size, size); + clear_view( + &device, + &queue, + &source_view, + wgpu::Color { + r: 0.1, + g: 0.1, + b: 0.14, + a: 0.5, + }, + ); + clear_view(&device, &queue, &target_view, wgpu::Color::TRANSPARENT); + + let card = CardPipeline::new(&device, format, wgpu::BlendState::ALPHA_BLENDING); + let style = CardStyle { + background: [0.0; 4], + // Opaque stroke, 4px wide. + border_color: [0.9, 0.9, 0.95, 1.0], + focus_color: [0.0; 4], + corner_radius: 0.0, + border_width: 4.0, + focus_width: 0.0, + focus_glow_width: 0.0, + }; + card.overlay_texture_cards( + &device, + &queue, + &target_view, + PixelSize { w: size, h: size }, + &style, + &[CardTexturePlacement { + texture_view: &source_view, + x: 0, + y: 0, + w: size, + h: size, + selected: false, + }], + ); + + let pixels = read_rgba_pixels(&device, &queue, &target_tex, size, size); + let alpha_at = |x: u32, y: u32| pixels[((y * size + x) * 4 + 3) as usize]; + + // Interior: the surface's own alpha, untouched. + let fill = alpha_at(size / 2, size / 2); + assert!( + (100..=150).contains(&fill), + "card fill should stay translucent (~128), got {fill}" + ); + + // Stroke: the border color's alpha, not the surface's. + let stroke = alpha_at(1, size / 2); + assert!( + stroke >= 240, + "card border should stay opaque (~255), got {stroke}" + ); +} + +/// Glassmorphism fix 2 regression: a selected card's focus-glow ring must +/// tint the backdrop's color without replacing its alpha. `card.wgsl` splits +/// the glow into its own `fs_glow` entry point, drawn with +/// `CardPipeline::GLOW_PRESERVE_DST_ALPHA` (dst-alpha kept as-is) instead of +/// the face's blend — under `ALPHA_REPLACE` (the Overview/sidebar blend +/// under `glassmorphism`, see `overview_card_blend`), the old combined +/// shader's alpha factors (`src·1 + dst·0`) let the glow's own 0.45->0 +/// falloff overwrite whatever destination alpha was already there, punching +/// a fading transparent halo around every selected/attention/zoomed tile. +#[test] +fn card_glow_preserves_destination_alpha_under_alpha_replace() { + let Some((device, queue)) = device_queue() else { + eprintln!("no wgpu adapter available — skipping card glow dst-alpha test"); + return; + }; + let format = wgpu::TextureFormat::Bgra8UnormSrgb; + let size = 80u32; + + // A 1x1 opaque white tile. Glow pixels never sample it — `fs_glow` + // returns before `textureSample` — so its content is irrelevant; it + // only needs to be a valid bound texture for the card's bind group. + let tile = device.create_texture(&wgpu::TextureDescriptor { + label: Some("noa-test-glow-tile"), + size: wgpu::Extent3d { + width: 1, + height: 1, + depth_or_array_layers: 1, + }, + mip_level_count: 1, + sample_count: 1, + dimension: wgpu::TextureDimension::D2, + format: wgpu::TextureFormat::Rgba8Unorm, + usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST, + view_formats: &[], + }); + queue.write_texture( + wgpu::TexelCopyTextureInfo { + texture: &tile, + mip_level: 0, + origin: wgpu::Origin3d::ZERO, + aspect: wgpu::TextureAspect::All, + }, + &[255, 255, 255, 255], + wgpu::TexelCopyBufferLayout { + offset: 0, + bytes_per_row: Some(4), + rows_per_image: Some(1), + }, + wgpu::Extent3d { + width: 1, + height: 1, + depth_or_array_layers: 1, + }, + ); + let tile_view = tile.create_view(&wgpu::TextureViewDescriptor::default()); + + let (target_tex, target_view) = render_target(&device, size, size); + // A translucent backdrop, matching a `glassmorphism` scratch texture's + // clear alpha — the value the glow must NOT replace. + let backdrop = wgpu::Color { + r: 0.05, + g: 0.05, + b: 0.08, + a: 0.8, + }; + clear_view(&device, &queue, &target_view, backdrop); + + let card = CardPipeline::new(&device, format, CardPipeline::ALPHA_REPLACE); + let style = CardStyle { + background: [0.0; 4], + border_color: [0.0; 4], + focus_color: [1.0, 0.55, 0.15, 1.0], + corner_radius: 8.0, + border_width: 0.0, + focus_width: 0.0, + focus_glow_width: 10.0, + }; + let placement = CardTexturePlacement { + texture_view: &tile_view, + x: 20, + y: 20, + w: 40, + h: 30, + selected: true, + }; + + device.push_error_scope(wgpu::ErrorFilter::Validation); + card.overlay_texture_cards( + &device, + &queue, + &target_view, + PixelSize { w: size, h: size }, + &style, + &[placement], + ); + device + .poll(wgpu::PollType::wait_indefinitely()) + .expect("poll device after card glow composite"); + let err = pollster::block_on(device.pop_error_scope()); + assert!( + err.is_none(), + "wgpu validation error during card glow composite: {err:?}" + ); + + let pixels = read_rgba_pixels(&device, &queue, &target_tex, size, size); + let px_at = |x: u32, y: u32| { + let offset = ((y * size + x) * 4) as usize; + &pixels[offset..offset + 4] + }; + let backdrop_alpha = (backdrop.a * 255.0).round() as i16; + + // 3px left of the card's left edge (rect x=20), vertically centered — + // well inside the 10px glow spread and well outside the rounded-rect + // coverage (no antialiased boundary pixel; d ~= 3px here). + let glow_px = px_at(17, 35); + assert!( + (i16::from(glow_px[3]) - backdrop_alpha).abs() <= 6, + "glow ring must preserve destination alpha (backdrop {backdrop_alpha}), got {glow_px:?}" + ); + // The glow still tints color toward the focus color — confirms the glow + // pass actually drew there, not just that nothing touched alpha. + assert!( + glow_px[0] > 90, + "glow ring should tint the backdrop's red channel toward the focus color, got {glow_px:?}" + ); + + // Outside the outset quad entirely (card rect - glow_width in every + // direction) — must stay exactly the original backdrop. + let untouched = px_at(2, 2); + assert_eq!( + untouched[3], backdrop_alpha as u8, + "pixels outside the glow spread must be untouched, got {untouched:?}" + ); +} diff --git a/crates/noa-render/tests/pipeline/cell.rs b/crates/noa-render/tests/pipeline/cell.rs index 3b2c24b..31a6ffb 100644 --- a/crates/noa-render/tests/pipeline/cell.rs +++ b/crates/noa-render/tests/pipeline/cell.rs @@ -1,8 +1,11 @@ use super::shared::*; -use noa_core::{CellAttrs, Color, DEFAULT_GRID_PADDING, PixelSize, Rgb}; +use noa_core::{CellAttrs, Color, DEFAULT_GRID_PADDING, GridPadding, PixelSize, Rgb}; use noa_font::FontGrid; use noa_grid::{Cell, Cursor, Row, SearchState, Selection, SelectionPoint, TerminalColors}; -use noa_render::{CommandPaletteSnapshot, FrameSnapshot, Renderer, Theme}; +use noa_render::{ + CommandPaletteSnapshot, FrameSnapshot, OverlayStyle, PaletteRow, Renderer, Theme, + command_palette_layout, overlay_surface_alpha, set_overlay_surface_alpha, +}; #[test] fn cell_pipeline_builds_without_validation_error() { @@ -269,6 +272,200 @@ fn command_palette_overlay_draws_one_frame_without_validation_error() { ); } +/// Glassmorphism fix-1-followup regression: the command-palette scratch's +/// alpha must be uniform across the whole card face — the clear (fix 1) +/// carries `overlay_surface_alpha()`, and a plain row must NOT also draw an +/// `overlay_surface_alpha()`-carrying background quad on top of it (that +/// double-applies the alpha, e.g. `0.68 + 0.68*0.32 = 0.898` instead of +/// `0.68`). The selected row is the one row that legitimately needs a +/// different fill (its accent wash); see `append_command_palette_instances`'s +/// `selected_wash` for why that can only be *bounded* close to the target +/// alpha, not made exactly equal, under ordinary "over" blending. +/// +/// This draws the raw cell-instance scratch directly (no card composite — +/// that's `cards.rs`'s job), so it observes exactly what `noa-app`'s +/// `set_clear_color`-after-`rebuild_cells` + `draw` sequence would produce. +#[test] +fn command_palette_surface_alpha_is_uniform_across_plain_and_selected_rows() { + let Some((device, queue)) = device_queue() else { + eprintln!("no wgpu adapter available — skipping command-palette surface-alpha test"); + return; + }; + let mut font = + FontGrid::new(14.0, noa_font::FontConfig::default()).expect("load a system monospace font"); + // Zero padding: grid cell (c, r) maps to pixel (c*cell_w, r*cell_h) + // exactly, so the probe pixels below don't need to account for a margin. + let mut renderer = Renderer::new( + &device, + &queue, + wgpu::TextureFormat::Bgra8UnormSrgb, + &mut font, + GridPadding::new(0.0, 0.0, 0.0, 0.0), + ) + .expect("build renderer"); + + let cols = 30u16; + let rows_n = 8u16; + let (cell_w, cell_h) = { + let m = font.metrics(); + (m.cell_w, m.cell_h) + }; + let surface_size = PixelSize { + w: (f32::from(cols) * cell_w).ceil() as u32, + h: (f32::from(rows_n) * cell_h).ceil() as u32, + }; + renderer.resize(surface_size); + + let palette = CommandPaletteSnapshot { + query: "sp".to_string(), + rows: vec![ + PaletteRow::Entry { + title: "Split Right".to_string(), + hint: None, + match_positions: vec![], + enabled: true, + }, + PaletteRow::Entry { + title: "Split Down".to_string(), + hint: None, + match_positions: vec![], + enabled: true, + }, + PaletteRow::Entry { + title: "Toggle Split Zoom".to_string(), + hint: None, + match_positions: vec![], + enabled: true, + }, + ], + selected: 1, + total_entries: 3, + }; + // Row 0 (query) and entry index 0 are plain; entry index 1 ("Split + // Down") is selected. Computed the same way the app computes it, so this + // test breaks (loudly) if the block's geometry formula ever changes. + let layout = + command_palette_layout(&palette, cols, rows_n).expect("palette layout for a roomy grid"); + let query_row = layout.y0; + let selected_row = layout.y0 + 1 + 1; // list_y0 (y0+1) + entry index 1 + let plain_entry_row = layout.y0 + 1; // list_y0 + entry index 0 + // Row 0 of the grid sits above the block (`layout.y0 >= 1` here), so it's + // untouched by any palette instance — a pure "clear only" probe, standing + // in for the card's own interior padding margin in production (both are + // fed by nothing but the clear). + assert!(query_row >= 1, "test fixture assumption: block below row 0"); + + let rows: Vec = (0..rows_n) + .map(|_| Row::from_cells(vec![Cell::default(); cols as usize], false, true)) + .collect(); + // Cursor hidden: `Cursor::default()` is visible at (0, 0) by construction + // (DECTCEM defaults on), which would otherwise paint an opaque block over + // the very "clear only" probe pixel this test relies on. + let cursor = Cursor { + visible: false, + ..Cursor::default() + }; + let snap = FrameSnapshot { + scroll_shift: 0, + row_dirty: vec![true; rows.len()], + rows, + cursor, + copy_cursor: None, + colors: TerminalColors::default(), + selection: None, + search: SearchState::default(), + row_base: 0, + abs_row_base: 0, + active_is_alt: false, + cols, + rows_n, + focused: true, + cursor_blink_visible: false, + hover_link: None, + search_prompt: None, + command_palette: Some(palette), + confirm_dialog: None, + preedit: None, + image_placements: Vec::new(), + images: Vec::new(), + }; + + let target = 0.68_f32; + set_overlay_surface_alpha(target); + assert_eq!(overlay_surface_alpha(), target); + + renderer.rebuild_cells(&snap, &mut font, &Theme::new()); + // After `rebuild_cells` (mirrors `noa-app`'s fix-1 ordering — see + // `crates/noa-app/src/app/sidebar/palette.rs`'s `set_clear_color` call + // sites) so this clear isn't clobbered by the snapshot's own default bg. + let style = OverlayStyle::from_theme(&Theme::new()); + renderer.set_clear_color(style.surface_bg()); + renderer.sync_atlas(&device, &queue, &mut font); + + let (target_tex, view) = render_target(&device, surface_size.w, surface_size.h); + device.push_error_scope(wgpu::ErrorFilter::Validation); + renderer.draw(&device, &queue, &view); + let err = pollster::block_on(device.pop_error_scope()); + // Always restore, even on assertion failure below, so a failing run + // doesn't leak a translucent surface alpha into whatever test runs next + // in this process. + set_overlay_surface_alpha(1.0); + assert!( + err.is_none(), + "wgpu validation error during command-palette surface-alpha draw: {err:?}" + ); + + let pixels = read_rgba_pixels(&device, &queue, &target_tex, surface_size.w, surface_size.h); + let alpha_at = |col: u16, row: u16| { + let x = (f32::from(col) * cell_w + cell_w * 0.5) as u32; + let y = (f32::from(row) * cell_h + cell_h * 0.5) as u32; + let offset = ((y * surface_size.w + x) * 4 + 3) as usize; + pixels[offset] + }; + // Column `layout.x0`: the block's leading pad column, always a literal + // space (`palette_line`'s one-space margin) — no glyph ink there, so the + // sampled alpha is purely the row's background treatment. + let probe_col = layout.x0; + + let expected = (target * 255.0).round() as i16; + let padding_alpha = i16::from(alpha_at(probe_col, 0)); + let plain_alpha = i16::from(alpha_at(probe_col, plain_entry_row)); + let query_alpha = i16::from(alpha_at(probe_col, query_row)); + let selected_alpha = i16::from(alpha_at(probe_col, selected_row)); + + for (label, got) in [ + ("padding (row 0, outside the block)", padding_alpha), + ("plain entry row", plain_alpha), + ("query row", query_alpha), + ] { + assert!( + (got - expected).abs() <= 3, + "{label} alpha should equal overlay_surface_alpha() ({expected}), got {got}" + ); + } + + // The selected row can't be made exactly `expected` (see the module doc + // above), but it must land much closer to it than the pre-fix bug did: + // pre-fix this pixel would read ~229 (`0.68 + 0.68*0.32`, i.e. + // `rgba_surface(selected_bg)` stacked on the clear); this fix's formula + // gives `0.20 + 0.68*0.80 = 0.744` -> ~190. + let expected_selected = (SELECTED_ROW_WASH_ALPHA_FOR_TEST + + target * (1.0 - SELECTED_ROW_WASH_ALPHA_FOR_TEST)) + * 255.0; + assert!( + (f32::from(selected_alpha) - expected_selected).abs() <= 4.0, + "selected row alpha should be ~{expected_selected:.0} (wash formula), got {selected_alpha}" + ); + assert!( + selected_alpha < 210, + "selected row alpha {selected_alpha} is too close to the pre-fix bug's ~229 (0.68 stacked on 0.68)" + ); +} +// Mirrors `SELECTED_ROW_WASH_ALPHA` in `crates/noa-render/src/renderer/overlay.rs` +// (private to that module) — kept here only so this test's expected-value +// formula is self-documenting; update both if that constant ever changes. +const SELECTED_ROW_WASH_ALPHA_FOR_TEST: f32 = 0.20; + /// WP4 (REQ-NF-4, AC-WP4-03): draw one frame via a full rebuild (the first /// frame through a fresh `PaneRenderCache`) and a second frame via the /// per-row dirty-patch path (only one of two rows marked dirty), asserting diff --git a/crates/noa-render/tests/pipeline/overview.rs b/crates/noa-render/tests/pipeline/overview.rs index 5d6aafd..6386ae0 100644 --- a/crates/noa-render/tests/pipeline/overview.rs +++ b/crates/noa-render/tests/pipeline/overview.rs @@ -28,6 +28,7 @@ fn overview_blit_pipeline_draws_tile_without_validation_error() { 1, TEST_TITLE_BAR_H, TEST_CARD_COLOR, + wgpu::BlendState::ALPHA_BLENDING, ); assert_eq!(overview.format(), renderer.target_format()); assert_eq!(overview.scratch_size(), scratch_size); @@ -72,6 +73,7 @@ fn overview_blit_scratch_resizes_to_source_frame_without_validation_error() { 1, TEST_TITLE_BAR_H, TEST_CARD_COLOR, + wgpu::BlendState::ALPHA_BLENDING, ); assert_eq!(overview.scratch_size(), initial_scratch_size); @@ -111,6 +113,7 @@ fn overview_blit_tile_pixel_hash_tracks_content_changes() { 1, TEST_TITLE_BAR_H, TEST_CARD_COLOR, + wgpu::BlendState::ALPHA_BLENDING, ); rebuild_text_frame(&mut renderer, &mut font, &device, &queue, "AAA"); @@ -176,6 +179,7 @@ fn overview_freshly_allocated_tiles_are_cleared_not_uninitialized() { 2, TEST_TITLE_BAR_H, TEST_CARD_COLOR, + wgpu::BlendState::ALPHA_BLENDING, ); // Tile 0 is never rendered — it must still read back as a uniform card @@ -238,6 +242,7 @@ fn overview_pane_subrect_composition_places_panes_in_distinct_regions() { 1, TEST_TITLE_BAR_H, TEST_CARD_COLOR, + wgpu::BlendState::ALPHA_BLENDING, ); // Two side-by-side pane cells inside the content region (below the title @@ -334,6 +339,7 @@ fn overview_blit_resources_drop_before_renderer_without_validation_error() { 1, TEST_TITLE_BAR_H, TEST_CARD_COLOR, + wgpu::BlendState::ALPHA_BLENDING, ); overview .render_existing_renderer_to_tile(&device, &queue, &mut renderer, scratch_size, 0) diff --git a/crates/noa-render/tests/pipeline/shared.rs b/crates/noa-render/tests/pipeline/shared.rs index 7151b5c..a5eb0b0 100644 --- a/crates/noa-render/tests/pipeline/shared.rs +++ b/crates/noa-render/tests/pipeline/shared.rs @@ -273,3 +273,33 @@ pub(crate) fn image_snapshot( }], } } + +/// Clear `view` to `color` in a pass of its own — for tests that need a +/// known starting state before an overlay composite (`LoadOp::Load`) so the +/// alpha they read back is exactly what the shader emitted. +pub(crate) fn clear_view( + device: &wgpu::Device, + queue: &wgpu::Queue, + view: &wgpu::TextureView, + color: wgpu::Color, +) { + let mut encoder = device.create_command_encoder(&wgpu::CommandEncoderDescriptor { + label: Some("noa-test-clear-encoder"), + }); + encoder.begin_render_pass(&wgpu::RenderPassDescriptor { + label: Some("noa-test-clear-pass"), + color_attachments: &[Some(wgpu::RenderPassColorAttachment { + view, + depth_slice: None, + resolve_target: None, + ops: wgpu::Operations { + load: wgpu::LoadOp::Clear(color), + store: wgpu::StoreOp::Store, + }, + })], + depth_stencil_attachment: None, + timestamp_writes: None, + occlusion_query_set: None, + }); + queue.submit(Some(encoder.finish())); +} diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index f09e846..c0568aa 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -117,8 +117,9 @@ font-variation = wght=550 | `cursor-style` | `block`, `bar`, `underline` | blinking block | `block_hollow` is recognized but ignored as unsupported | | `cursor-style-blink` | `true`, `false` | equivalent to `true` | Cursor blinking. It also blinks when only the shape is specified | | `cursor-stop-blinking-after` | non-negative integer (seconds) | `10` | noa-specific key whose **default deviates from Ghostty** (which blinks forever) — see [Deviations from Ghostty defaults](#deviations-from-ghostty-defaults): after this many seconds with no keyboard/IME input or output on the focused pane, the cursor settles solid so an idle noa schedules no blink wake-ups. Any activity resumes blinking. `0` never stops (Ghostty-parity behavior) | -| `background-opacity` | finite decimal | `1.0` | Clamped to `0.0..=1.0` | -| `background-blur-radius` | `true`, `false`, non-negative integer | `0` | macOS blur. `true` maps to `20`, `false` to `0`; integers are clamped to `0..=64` | +| `background-opacity` | finite decimal | `1.0` | Clamped to `0.0..=1.0`. **Ignored while `glassmorphism = true`** (that key installs `0.50`) | +| `background-blur-radius` | `true`, `false`, non-negative integer | `0` | macOS blur. `true` maps to `20`, `false` to `0`; integers are clamped to `0..=64`. **Ignored while `glassmorphism = true`** (that key installs `64`, the maximum) | +| `glassmorphism` | `true`, `false` | `false` | noa-specific key. Renders noa's own chrome (session sidebar, tab overview) as translucent frosted panes with a brightened rim instead of opaque ones, matching the native overlays. Off installs the byte-identical opaque chrome palette, so it costs nothing when unused. On, it **takes over `background-opacity` (`0.50`) and `background-blur-radius` (`64`)**: frosted chrome over an opaque window has nothing behind it to show through, so those two keys are resolved from this one instead of being honored — configured values are ignored, and a warning names them. Turn `glassmorphism` off to control them yourself. A window created opaque can't become translucent in place, so enabling it in a running opaque session takes full effect on the next launch | The list of themes can be inspected with `noa +list-themes`. diff --git a/docs/FEATURES.md b/docs/FEATURES.md index a06af63..3b68246 100644 --- a/docs/FEATURES.md +++ b/docs/FEATURES.md @@ -55,6 +55,7 @@ A from-scratch DFA parser plus a `Handler` trait separating parsing from state. - **Cursor styles** — block / bar / underline / hollow, focus / blink phase support - **Underline rendering** — single / double / curly / dotted / dashed, hover-link underline - **Background transparency / blur** — `background-opacity`, `background-blur-radius` (native macOS blur) +- **Glassmorphism mode** — `glassmorphism` (default off): translucent frosted sidebar / tab-overview / titlebar-tab-bar chrome and frosted command palette / prompts / dialogs over the blurred desktop; on, it takes over `background-opacity` / `background-blur-radius` with its own recommended pair - **Background image** — `background-image` (single file / directory rotation), fit / position / repeat / opacity / interval settings - **minimum-contrast** — enforcement of a WCAG contrast-ratio floor - **Font pipeline** — font-kit discovery → rustybuzz shaping → swash rasterization → etagere atlas (monochrome + color emoji) @@ -72,7 +73,7 @@ For the type, allowed values, defaults, and clamp/fallback rules of every key, s |---|---| | Window | `window-width/height`, `window-padding-x/-y`, `window-save-state` | | Font | `font-family[-bold/-italic/-bold-italic]`, `font-size`, `font-feature`, `font-variation*`, `font-synthetic-style`, `font-thicken[-strength]` | -| Color/theme | `theme`, `background`, `foreground`, `cursor-color`, `selection-foreground/background`, `minimum-contrast`, `background-opacity`, `background-blur-radius` | +| Color/theme | `theme`, `background`, `foreground`, `cursor-color`, `selection-foreground/background`, `minimum-contrast`, `background-opacity`, `background-blur-radius`, `glassmorphism` | | Background image | `background-image`, `background-image-opacity/-position/-fit/-repeat/-interval` | | Cursor | `cursor-style`, `cursor-style-blink`, `cursor-stop-blinking-after` | | Bell | `visual-bell`, `audible-bell`, `audible-bell-dock-bounce`, `audible-bell-when-unfocused` | diff --git a/docs/specs/agent-attention.md b/docs/specs/agent-attention.md index c2f07b2..7f81627 100644 --- a/docs/specs/agent-attention.md +++ b/docs/specs/agent-attention.md @@ -15,8 +15,8 @@ When several Claude Code / Codex / agy sessions run concurrently, Noa should make a newly raised notification easy to notice without leaving a distracting animation running. OSC 9/777 indicates that a notification exists; it does not prove that the process is blocked awaiting a response. The UI therefore uses -the neutral label `通知あり` ("notification") and preserves the notification until the relevant -window gains focus. +the neutral label `notification` and preserves it until the relevant window +gains focus. - **audience**: developers running multiple concurrent terminal sessions - **job-to-be-done**: identify which session changed state at a glance @@ -40,7 +40,7 @@ window gains focus. - **Arrival cue**: one-shot emphasis for `ATTENTION_FLASH_DURATION` (150 ms) - **Notification scope**: sidebar cards + tab overview + Dock/OS notification - **Detection triggers**: OSC 9/777 + known-agent BEL -- **Copy**: `通知あり` ("notification"); do not claim “awaiting response” without a dedicated +- **Copy**: `notification`; do not claim “awaiting response” without a dedicated response-required protocol ## L1 — Requirements @@ -55,7 +55,7 @@ window gains focus. - **FR-A2 One-shot arrival emphasis**: a card's `false → true` attention transition briefly tints its sidebar background and strengthens the Overview ring glow. At expiry, one repaint removes the emphasis while the stable red - indicator, solid rail/ring, and `通知あり` ("notification") label remain. + indicator, solid rail/ring, and `notification` label remain. - **FR-A3 Attention promotion on BEL detection**: known agent processes (`ClaudeCode`/`Codex`/`Agy`) promote BEL to `SessionDelta::Attention`. Generic or unresolved processes remain `SessionDelta::Bell`. @@ -111,6 +111,6 @@ window gains focus. ## Open Questions - Should a future protocol expose a distinct “response required” state and - permit stronger copy than `通知あり` ("notification")? + permit stronger copy than `notification`? - Should the 150 ms duration become configurable if Noa later adds a global reduced-motion/animation preference? diff --git a/docs/specs/session-sidebar.md b/docs/specs/session-sidebar.md index e02a280..ad0504c 100644 --- a/docs/specs/session-sidebar.md +++ b/docs/specs/session-sidebar.md @@ -124,13 +124,13 @@ Header bar: busy label (reduced form) + centered title + session-name pill. + bu - **FR-7 … menu**: each card provides a close action; rename is kept as a name override in SessionStore. Close delegates to the existing close_pane/close_tab teardown path (including the confirm dialog, pty termination, and GC choke-point) — since cards are per-pane (SessionCardId holds a pane_id), close_pane is correct, cascading to close_tab for the last pane (Judge ruling, 2026-07-05). Inline text-input UI for rename is **implemented** (2026-07-11 update: inline on-card editing via `SidebarRenameSession`. The deferral in Open Question 5 is resolved). - **FR-8 Git branch**: a throttled `git -C branch --show-current` supplies results into SessionStore. - **FR-9 Icon detection**: determines the project icon by first-match on cwd markers (`Cargo.toml`→rust, `package.json`→node, `*.tf`→terraform, `go.mod`→go, `pyproject.toml`→python, `.git` only→git, none→folder). -- **FR-10 Updated-time**: shows the last-output timestamp relatively ("3 minutes ago"; beyond 24h shown as "Yesterday 23:47"). +- **FR-10 Updated-time**: shows the last-output timestamp relatively — `just now` / `Nm ago` / `Nh ago` same-day, `Yday HH:MM` for yesterday, `Mon D` beyond that. Every form is abbreviated to fit the ~11-cell right-aligned column (`CARD_UPDATED_W`); an overlong form would be drawn over the card name. - **FR-11 Status indicators**: busy (OSC 133 `has_running_program`) = blue play icon + segmented rail / idle = hollow green circle + no rail / unread bell = yellow bell + short rail notch. The unread bell is drained by the io thread from `Terminal::take_pending_bell` (terminal.rs:305, sourced from BEL) and sent as a SessionDelta, cleared once that session's window is focused. Rail shapes are categorical, not completion percentages. - **FR-12 GC/teardown**: removes the corresponding entry from SessionStore at all 5 teardown sites when a session ends. - **FR-13 Config keys**: adds `sidebar-enabled` (bool initial value), `sidebar-width` (points, default 360), `sidebar-hotkey` (toggle chord, following the existing parse/dispatch pattern of `quick-terminal-hotkey`), and `sidebar-preview-lines` (card last-output preview line count, default 3) to noa-config. No generic keybind→action system is introduced. - **FR-14 Quick-terminal exclusion**: quick-terminal windows are excluded from the sidebar, and no inset is applied either. - **FR-15 Scroll**: when the card count exceeds the sidebar's visible area, vertical scrolling (with clamped scroll offset) reaches every card. No grouping/collapsing. -- **FR-16 Attention (notification indicator)**: when a pane in a non-focused window issues an OSC 9/777 desktop notification, an `attention` flag is set on that session card. This shows as (a) red exclamation icon + solid status rail (priority: attention > bell > busy > idle), (b) `· 通知あり` ("notification") appended to the process line, and (c) a persistent red marker/ring in the tab overview. The initial transition gets a one-shot 150 ms emphasis and then stays still. Cleared together with unread bell when the window gains focus. Notifications on the currently focused window don't raise attention because the user is already looking at it. +- **FR-16 Attention (notification indicator)**: when a pane in a non-focused window issues an OSC 9/777 desktop notification, an `attention` flag is set on that session card. This shows as (a) red exclamation icon + solid status rail (priority: attention > bell > busy > idle), (b) `· notification` appended to the process line, and (c) a persistent red marker/ring in the tab overview. The initial transition gets a one-shot 150 ms emphasis and then stays still. Cleared together with unread bell when the window gains focus. Notifications on the currently focused window don't raise attention because the user is already looking at it. ### Non-Functional - **NFR-1 No render-path lock**: the render path never locks Terminal, reading session state only via a publish slot of the same shape as `overview_snapshot`. @@ -167,7 +167,7 @@ Header bar: busy label (reduced form) + centered title + session-name pill. + bu - **AC-9b (FR-7) [manual]**: closing via the … menu ends the corresponding session and the card disappears. - **AC-10 (FR-8, NFR-3)**: the pure function `decide_branch_poll(now, last_poll, cache)` returns Skip for <1s, Spawn for ≥1s, and Hit for an already negative-cached non-git cwd — asserted with explicit `now: Instant` values (following the now-as-param pattern of `decide_overview_publish`, no wall-clock sleep). - **AC-11 (FR-9)**: given a set of marker files, the icon-determination function returns results matching the first-match table (table-driven unit test). -- **AC-12 (FR-10)**: the updated-time formatter returns the correct string at each boundary — "3 minutes ago" / "Yesterday 23:47" / same-day time (unit test). +- **AC-12 (FR-10)**: the updated-time formatter returns the correct string at each boundary — `3m ago` / `just now` / `2h ago` / `Yday 23:47` / `Jul 1` (unit test), and every form it can emit stays ASCII and within the 11-cell column (unit test). - **AC-13 (FR-11)**: the dot-color mapping is verified by unit test — `has_running_program`=true→blue, false→green, unread bell→yellow. - **AC-14 (FR-12)**: the pure function `reconcile_sessions(&mut store, live_ids)` removes entries not in live_ids, and store size == live_ids count is verified by unit test. That all 5 teardown sites actually call this is confirmed via implementation review + [manual] integration check (since `App` can't be constructed from a unit test). - **AC-15a (FR-13)**: `sidebar-enabled`/`sidebar-width`/`sidebar-preview-lines` parse correctly, and the defaults (width=360, preview-lines=3) apply (parser unit test, following the `parse_bool`/`quick-terminal-size` pattern).