From aea62166f4889646b3f4dba4462fccb043c321e6 Mon Sep 17 00:00:00 2001 From: Raphael Amorim Date: Sat, 2 May 2026 09:37:47 +0200 Subject: [PATCH 1/2] fix opacity --- frontends/rioterm/src/grid_emit.rs | 76 +++++++++++++++++++++++---- frontends/rioterm/src/renderer/mod.rs | 8 +++ frontends/rioterm/src/screen/mod.rs | 24 +++++++++ rio-backend/src/config/window.rs | 13 +++++ sugarloaf/src/context/metal.rs | 17 ++++++ sugarloaf/src/sugarloaf.rs | 19 +++++++ 6 files changed, 148 insertions(+), 9 deletions(-) diff --git a/frontends/rioterm/src/grid_emit.rs b/frontends/rioterm/src/grid_emit.rs index d5e6bfd214..661d734c6f 100644 --- a/frontends/rioterm/src/grid_emit.rs +++ b/frontends/rioterm/src/grid_emit.rs @@ -23,6 +23,7 @@ //! `font::shaper::run::RunIterator`. use rio_backend::config::colors::term::TermColors; +use rio_backend::config::colors::{AnsiColor, NamedColor}; use rio_backend::crosswords::grid::row::Row; use rio_backend::crosswords::pos::{Column, Line, Pos}; use rio_backend::crosswords::search::Match; @@ -836,30 +837,87 @@ fn decoration_color( } } +/// Per-cell background color including the alpha-routing logic that +/// makes window opacity actually visible. Mirrors ghostty's +/// `rebuildRow` bg path (`generic.zig:2902-2937`): +/// +/// - Cells with an **explicit** bg (BgRgb / BgPalette inline encoding, +/// or a non-default `style.bg`) → `alpha = 255`. Stays opaque so +/// syntax-highlighted regions, TUI panels, etc. don't bleed through. +/// With `window.opacity-cells = true`, the per-frame opacity gets +/// applied here too — for users who want their Neovim / tmux UI to +/// share the window translucency. +/// - Cells with the **terminal default** bg (`style.bg == +/// AnsiColor::Named(NamedColor::Background)` and no INVERSE) → +/// `alpha = 0`. The grid bg pass blends premultiplied-over so writing +/// `(0,0,0,0)` is a no-op — the drawable's clear color (which +/// carries `window.opacity` via `dynamic_background.1.a`) shows +/// through. This is what gives the user a translucent window. +/// - INVERSE flag → fg/bg swap promotes the cell to "has explicit bg" +/// so it stays opaque (matches ghostty's `style.flags.inverse` arm). +/// INVERSE bypasses `opacity-cells` to keep cursor / inverted-text +/// readable. +/// +/// Selection / hint highlights are applied at the `build_row_bg` slow +/// path with their own (always opaque) bg colors, so they don't go +/// through this function. pub fn cell_bg( sq: Square, style_set: &StyleSet, renderer: &Renderer, term_colors: &TermColors, ) -> [u8; 4] { - let color = match sq.content_tag() { + // Alpha for cells that paint an explicit bg. Default = fully + // opaque (matches ghostty default, keeps TUI contrast). With + // `window.opacity-cells = true` and a transparent window, we + // multiply by the window opacity so the explicit-bg cells stay + // proportionally translucent. INVERSE always uses 255. + let explicit_bg_alpha = if renderer.opacity_cells { + renderer.cell_bg_alpha + } else { + 255 + }; + + match sq.content_tag() { ContentTag::BgRgb => { let (r, g, b) = sq.bg_rgb(); - return [r, g, b, 255]; + [r, g, b, explicit_bg_alpha] } ContentTag::BgPalette => { let idx = sq.bg_palette_index() as usize; - renderer.color(idx, term_colors) + let color = renderer.color(idx, term_colors); + let [r, g, b, _] = normalized_to_u8(color); + [r, g, b, explicit_bg_alpha] } ContentTag::Codepoint => { - let mut style = style_set.get(sq.style_id()); - if style.flags.contains(StyleFlags::INVERSE) { - std::mem::swap(&mut style.fg, &mut style.bg); + let style = style_set.get(sq.style_id()); + let inverse = style.flags.contains(StyleFlags::INVERSE); + // "Default bg" mirrors ghostty's `bg_style == null` check: + // the cell carries the terminal-default bg sentinel, no + // SGR override. INVERSE flips fg/bg, which always produces + // a non-default effective bg, so treat as explicit. + let has_default_bg = + !inverse && matches!(style.bg, AnsiColor::Named(NamedColor::Background)); + if has_default_bg { + // Skip painting → the translucent clear (or opaque + // global bg, for non-transparent windows) shows + // through unchanged. Premultiplied blending makes + // (0,0,0,0) a no-op. + return [0, 0, 0, 0]; } - renderer.compute_bg_color(&style, term_colors) + // Resolve the explicit bg (with INVERSE swap applied). + let mut resolved = style; + if inverse { + std::mem::swap(&mut resolved.fg, &mut resolved.bg); + } + let color = renderer.compute_bg_color(&resolved, term_colors); + let [r, g, b, _] = normalized_to_u8(color); + // INVERSE always opaque — keep cursor / inverted text + // readable regardless of the opacity-cells flag. + let alpha = if inverse { 255 } else { explicit_bg_alpha }; + [r, g, b, alpha] } - }; - normalized_to_u8(color) + } } #[inline] diff --git a/frontends/rioterm/src/renderer/mod.rs b/frontends/rioterm/src/renderer/mod.rs index ba48acd3a7..9fbd231b4a 100644 --- a/frontends/rioterm/src/renderer/mod.rs +++ b/frontends/rioterm/src/renderer/mod.rs @@ -60,6 +60,12 @@ pub struct Renderer { // Dynamic background keep track of the original bg color and // the same r,g,b with the mutated alpha channel. pub dynamic_background: ([f32; 4], rio_backend::sugarloaf::Color, bool), + /// `window.opacity-cells` — apply window opacity to cells with an + /// SGR-set background too. Off by default; mirrors ghostty's + /// `background-opacity-cells`. `cell_bg_alpha` is the precomputed + /// `(window.opacity * 255) as u8` to avoid a multiply per cell. + pub opacity_cells: bool, + pub cell_bg_alpha: u8, pub custom_mouse_cursor: bool, pub trail_cursor_enabled: bool, pub trail_cursor: trail_cursor::TrailCursor, @@ -115,6 +121,8 @@ impl Renderer { }, named_colors, dynamic_background, + opacity_cells: config.window.opacity_cells, + cell_bg_alpha: (config.window.opacity.clamp(0.0, 1.0) * 255.0).round() as u8, search: search::SearchOverlay::default(), assistant: assistant::AssistantOverlay::default(), scrollbar: scrollbar::Scrollbar::new(config.enable_scroll_bar), diff --git a/frontends/rioterm/src/screen/mod.rs b/frontends/rioterm/src/screen/mod.rs index dd946765fc..714b2f30a1 100644 --- a/frontends/rioterm/src/screen/mod.rs +++ b/frontends/rioterm/src/screen/mod.rs @@ -100,6 +100,18 @@ pub struct ScreenWindowProperties { pub window_id: rio_window::window::WindowId, } +/// Whether the render surface should run in macOS compositor's +/// opaque-window fast path. Mirrors ghostty's NSWindow.isOpaque +/// decision (`macos/.../TerminalWindow.swift:482-505`): only flip to +/// non-opaque when the user actually configured transparency +/// (`window.opacity < 1`) or a translucent background effect +/// (`window.blur`). Default = opaque so the steady-state look-and-feel +/// for the common case stays as fast as Terminal.app. +#[inline] +fn window_should_be_opaque(config: &rio_backend::config::Config) -> bool { + config.window.opacity >= 1.0 && !config.window.blur +} + impl Screen<'_> { pub fn new<'screen>( window_properties: ScreenWindowProperties, @@ -273,6 +285,13 @@ impl Screen<'_> { sugarloaf_errors, )?; + // Match ghostty: window is opaque (compositor fast path) unless + // the user actually configured transparency. The render surface + // can hold per-pixel alpha either way — see `cell_bg` in + // `grid_emit.rs` — but flipping the layer to non-opaque is + // what makes the OS treat those alpha bits as see-through. + sugarloaf.set_window_opaque(window_should_be_opaque(config)); + sugarloaf.set_background_color(Some(renderer.dynamic_background.1)); if let Some(image) = &config.window.background_image { @@ -511,6 +530,11 @@ impl Screen<'_> { // Update keyboard config in context manager self.context_manager.config.keyboard = config.keyboard; + // Re-evaluate the opaque flag — toggling `window.opacity` / + // `window.blur` at runtime should flip the compositor mode. + self.sugarloaf + .set_window_opaque(window_should_be_opaque(config)); + self.sugarloaf .set_background_color(Some(self.renderer.dynamic_background.1)); diff --git a/rio-backend/src/config/window.rs b/rio-backend/src/config/window.rs index d2738556c9..e8f1e37afe 100644 --- a/rio-backend/src/config/window.rs +++ b/rio-backend/src/config/window.rs @@ -86,6 +86,18 @@ pub struct Window { pub mode: WindowMode, #[serde(default = "default_opacity")] pub opacity: f32, + /// Apply `window.opacity` to cells that paint an explicit + /// background color too, not just to the window's default + /// background. Off by default (matches ghostty's + /// `background-opacity-cells = false`): cells with an SGR-set + /// background stay fully opaque so syntax-highlighted regions and + /// status-line painted by tmux/Neovim keep their contrast. Flip + /// to `true` to make TUIs see-through too. + /// + /// On the wire: kebab-case `opacity-cells` under `[window]`. + /// Mirrors `background-opacity-cells` in ghostty 1.2.0. + #[serde(rename = "opacity-cells", default = "bool::default")] + pub opacity_cells: bool, #[serde(default = "bool::default")] pub blur: bool, #[serde(rename = "background-image", skip_serializing)] @@ -126,6 +138,7 @@ impl Default for Window { height: default_window_height(), mode: WindowMode::default(), opacity: default_opacity(), + opacity_cells: false, background_image: None, decorations: Decorations::default(), blur: false, diff --git a/sugarloaf/src/context/metal.rs b/sugarloaf/src/context/metal.rs index ce1dabb54d..9137e244ff 100644 --- a/sugarloaf/src/context/metal.rs +++ b/sugarloaf/src/context/metal.rs @@ -119,6 +119,13 @@ impl MetalContext { } else { tracing::warn!("Failed to create Display P3 CGColorSpace"); } + // Default to opaque so the macOS compositor can take its + // fast path on windows without configured transparency. + // Mirrors ghostty's NSWindow.isOpaque default + // (`macos/.../TerminalWindow.swift:482-505`). The host (rio) + // flips this to `false` via `Sugarloaf::set_window_opaque` + // when `config.window.opacity < 1` or background blur is on. + layer.set_opaque(true); layer.set_presents_with_transaction(false); // Use CGSize from core_graphics_types @@ -175,6 +182,16 @@ impl MetalContext { self.scale = scale; } + /// Toggle the CAMetalLayer's opaque flag. `true` (default) lets the + /// macOS compositor take its opaque-window fast path; `false` is + /// required for `window.opacity < 1` or background blur to render + /// translucent. Cheap (one Cocoa property write); safe to call + /// every config reload. + #[inline] + pub fn set_layer_opaque(&self, opaque: bool) { + self.layer.set_opaque(opaque); + } + #[inline] pub fn get_current_texture(&self) -> Result { if let Some(drawable) = self.layer.next_drawable() { diff --git a/sugarloaf/src/sugarloaf.rs b/sugarloaf/src/sugarloaf.rs index 8e8a7a6dc4..c71d96271e 100644 --- a/sugarloaf/src/sugarloaf.rs +++ b/sugarloaf/src/sugarloaf.rs @@ -508,6 +508,25 @@ impl Sugarloaf<'_> { self } + /// Mark the window's render surface opaque (`true`, default — fast + /// macOS compositor path) or non-opaque (`false`, required for + /// `window.opacity < 1` and macOS-glass background blur). Mirrors + /// ghostty's conditional `NSWindow.isOpaque` toggle in + /// `macos/.../TerminalWindow.swift`. Safe to call every config + /// reload; underlying call is a single Cocoa property write on + /// macOS, no-op on other backends until they grow their own + /// transparency story. + #[inline] + pub fn set_window_opaque(&self, opaque: bool) { + match &self.ctx.inner { + #[cfg(target_os = "macos")] + crate::context::ContextType::Metal(ctx) => ctx.set_layer_opaque(opaque), + _ => { + let _ = opaque; + } + } + } + /// Try to load and install a window background image. Returns `Err` /// with a human-readable message on failure (file missing, decode /// failed, decoded image is empty, etc.) so callers can surface the From 632d7e5909b968e9c0290658b9150bd0f6ce269c Mon Sep 17 00:00:00 2001 From: Raphael Amorim Date: Sat, 2 May 2026 16:07:33 +0200 Subject: [PATCH 2/2] glass support --- frontends/rioterm/src/grid_emit.rs | 24 +-- frontends/rioterm/src/renderer/mod.rs | 51 ++++- frontends/rioterm/src/router/window.rs | 14 +- frontends/rioterm/src/screen/mod.rs | 20 +- rio-backend/src/config/mod.rs | 4 +- rio-backend/src/config/platform.rs | 2 +- rio-backend/src/config/window.rs | 117 ++++++++++- rio-window/src/platform/macos.rs | 16 ++ rio-window/src/platform_impl/linux/mod.rs | 2 +- .../platform_impl/linux/wayland/window/mod.rs | 11 +- .../src/platform_impl/linux/x11/window.rs | 2 +- rio-window/src/platform_impl/macos/glass.rs | 161 +++++++++++++++ rio-window/src/platform_impl/macos/mod.rs | 1 + .../platform_impl/macos/window_delegate.rs | 194 +++++++++++++++++- .../src/platform_impl/orbital/window.rs | 2 +- rio-window/src/platform_impl/web/window.rs | 2 +- .../src/platform_impl/windows/window.rs | 20 +- rio-window/src/window.rs | 67 ++++-- sugarloaf/src/context/metal.rs | 9 +- sugarloaf/src/sugarloaf.rs | 10 +- 20 files changed, 639 insertions(+), 90 deletions(-) create mode 100644 rio-window/src/platform_impl/macos/glass.rs diff --git a/frontends/rioterm/src/grid_emit.rs b/frontends/rioterm/src/grid_emit.rs index 661d734c6f..4d1182cecd 100644 --- a/frontends/rioterm/src/grid_emit.rs +++ b/frontends/rioterm/src/grid_emit.rs @@ -838,8 +838,7 @@ fn decoration_color( } /// Per-cell background color including the alpha-routing logic that -/// makes window opacity actually visible. Mirrors ghostty's -/// `rebuildRow` bg path (`generic.zig:2902-2937`): +/// makes window opacity actually visible: /// /// - Cells with an **explicit** bg (BgRgb / BgPalette inline encoding, /// or a non-default `style.bg`) → `alpha = 255`. Stays opaque so @@ -854,9 +853,8 @@ fn decoration_color( /// carries `window.opacity` via `dynamic_background.1.a`) shows /// through. This is what gives the user a translucent window. /// - INVERSE flag → fg/bg swap promotes the cell to "has explicit bg" -/// so it stays opaque (matches ghostty's `style.flags.inverse` arm). -/// INVERSE bypasses `opacity-cells` to keep cursor / inverted-text -/// readable. +/// so it stays opaque. INVERSE bypasses `opacity-cells` to keep +/// cursor / inverted-text readable. /// /// Selection / hint highlights are applied at the `build_row_bg` slow /// path with their own (always opaque) bg colors, so they don't go @@ -868,10 +866,10 @@ pub fn cell_bg( term_colors: &TermColors, ) -> [u8; 4] { // Alpha for cells that paint an explicit bg. Default = fully - // opaque (matches ghostty default, keeps TUI contrast). With - // `window.opacity-cells = true` and a transparent window, we - // multiply by the window opacity so the explicit-bg cells stay - // proportionally translucent. INVERSE always uses 255. + // opaque (keeps TUI contrast). With `window.opacity-cells = true` + // and a transparent window, we multiply by the window opacity so + // the explicit-bg cells stay proportionally translucent. INVERSE + // always uses 255. let explicit_bg_alpha = if renderer.opacity_cells { renderer.cell_bg_alpha } else { @@ -892,10 +890,10 @@ pub fn cell_bg( ContentTag::Codepoint => { let style = style_set.get(sq.style_id()); let inverse = style.flags.contains(StyleFlags::INVERSE); - // "Default bg" mirrors ghostty's `bg_style == null` check: - // the cell carries the terminal-default bg sentinel, no - // SGR override. INVERSE flips fg/bg, which always produces - // a non-default effective bg, so treat as explicit. + // "Default bg": the cell carries the terminal-default bg + // sentinel with no SGR override. INVERSE flips fg/bg, + // which always produces a non-default effective bg, so + // treat as explicit. let has_default_bg = !inverse && matches!(style.bg, AnsiColor::Named(NamedColor::Background)); if has_default_bg { diff --git a/frontends/rioterm/src/renderer/mod.rs b/frontends/rioterm/src/renderer/mod.rs index 9fbd231b4a..d7ddfaaeec 100644 --- a/frontends/rioterm/src/renderer/mod.rs +++ b/frontends/rioterm/src/renderer/mod.rs @@ -28,6 +28,24 @@ use rio_backend::sugarloaf::Sugarloaf; use std::collections::BTreeSet; use std::ops::RangeInclusive; +/// The window-bg clear alpha that flows into sugarloaf's +/// `set_background_color`. Stored on the renderer and re-applied on +/// every `effective_bg` write so OSC 11 changes don't reset +/// transparency to 1.0. +/// +/// - Glass blur styles force `0.0` so the macOS-26 `NSGlassEffectView` +/// under the metal layer is what shows through. +/// - Otherwise it's the configured `window.opacity`, clamped to +/// `[0, 1]`. +#[inline] +fn window_bg_alpha(config: &Config) -> f32 { + if config.window.blur.is_glass() { + 0.0 + } else { + config.window.opacity.clamp(0.0, 1.0) + } +} + pub struct Renderer { is_vi_mode_enabled: bool, is_game_mode_enabled: bool, @@ -61,11 +79,15 @@ pub struct Renderer { // the same r,g,b with the mutated alpha channel. pub dynamic_background: ([f32; 4], rio_backend::sugarloaf::Color, bool), /// `window.opacity-cells` — apply window opacity to cells with an - /// SGR-set background too. Off by default; mirrors ghostty's - /// `background-opacity-cells`. `cell_bg_alpha` is the precomputed - /// `(window.opacity * 255) as u8` to avoid a multiply per cell. + /// SGR-set background too. Off by default. `cell_bg_alpha` is the + /// precomputed `(window.opacity * 255) as u8` to avoid a multiply + /// per cell. pub opacity_cells: bool, pub cell_bg_alpha: u8, + /// Target alpha for the window-bg clear (`0..=1`). 0 in glass + /// mode, otherwise `window.opacity`. Re-applied to `effective_bg` + /// every frame so OSC 11 doesn't undo the user's transparency. + pub window_bg_alpha: f32, pub custom_mouse_cursor: bool, pub trail_cursor_enabled: bool, pub trail_cursor: trail_cursor::TrailCursor, @@ -78,8 +100,18 @@ impl Renderer { let mut dynamic_background = (named_colors.background.0, named_colors.background.1, false); - if config.window.opacity < 1. { - dynamic_background.1.a = config.window.opacity as f64; + // Window-bg target alpha. Cached here at init and re-applied + // to every OSC-11-driven `effective_bg` refresh in + // `Renderer::run` so a runtime bg change doesn't reset + // transparency to 1.0. Glass styles force alpha = 0 so the + // NSGlassEffectView underneath the metal layer can provide + // the actual translucent bg. + let target_bg_alpha = window_bg_alpha(config); + if config.window.blur.is_glass() { + dynamic_background.1.a = 0.0; + dynamic_background.2 = true; + } else if config.window.opacity < 1. { + dynamic_background.1.a = target_bg_alpha as f64; dynamic_background.2 = true; } else if config.window.background_image.is_some() { dynamic_background.1 = rio_backend::sugarloaf::Color::TRANSPARENT; @@ -123,6 +155,7 @@ impl Renderer { dynamic_background, opacity_cells: config.window.opacity_cells, cell_bg_alpha: (config.window.opacity.clamp(0.0, 1.0) * 255.0).round() as u8, + window_bg_alpha: target_bg_alpha, search: search::SearchOverlay::default(), assistant: assistant::AssistantOverlay::default(), scrollbar: scrollbar::Scrollbar::new(config.enable_scroll_bar), @@ -659,7 +692,7 @@ impl Renderer { // want it to follow focus the way does (each surface's // `terminal.colors.background` drives its own window chrome). let current_context = context_manager.current_grid_mut().current_mut(); - let effective_bg = match ¤t_context.renderable_content.background { + let mut effective_bg = match ¤t_context.renderable_content.background { Some(crate::context::renderable::BackgroundState::Set(color)) => *color, // Explicit OSC 111 reset OR panel that never ran OSC 11 → // fall back to the config / dynamic_background (honors @@ -668,6 +701,12 @@ impl Renderer { self.dynamic_background.1 } }; + // Re-apply the configured window-bg alpha. Without this, an + // OSC 11 sequence that sets a new bg color resets the alpha + // to 1.0 and the window goes opaque even when + // `window.opacity < 1`. Glass mode forces alpha 0 so the + // backdrop view shows through. + effective_bg.a = self.window_bg_alpha as f64; let window_update = if self.last_window_bg != Some(effective_bg) { sugarloaf.set_background_color(Some(effective_bg)); diff --git a/frontends/rioterm/src/router/window.rs b/frontends/rioterm/src/router/window.rs index c125305ec2..9613b19836 100644 --- a/frontends/rioterm/src/router/window.rs +++ b/frontends/rioterm/src/router/window.rs @@ -38,7 +38,7 @@ pub fn create_window_builder( .with_resizable(true) .with_decorations(true) .with_transparent(config.window.opacity < 1.) - .with_blur(config.window.blur) + .with_blur(config.window.blur.into()) .with_window_icon(Some(icon)); match config.window.decorations { @@ -244,5 +244,15 @@ pub fn configure_window(winit_window: &Window, config: &Config) { winit_window.set_title(title); } - winit_window.set_blur(config.window.blur); + // macOS-only: stash the opacity value before set_blur so the + // initial glass install reads the correct tint. No-op on macOS < + // 26 / non-macOS — only matters when liquid-glass actually + // engages. + #[cfg(target_os = "macos")] + { + use rio_window::platform::macos::WindowExtMacOS; + winit_window.set_glass_opacity(config.window.opacity as f64); + } + + winit_window.set_blur(config.window.blur.into()); } diff --git a/frontends/rioterm/src/screen/mod.rs b/frontends/rioterm/src/screen/mod.rs index 714b2f30a1..33bb6ce84b 100644 --- a/frontends/rioterm/src/screen/mod.rs +++ b/frontends/rioterm/src/screen/mod.rs @@ -101,15 +101,15 @@ pub struct ScreenWindowProperties { } /// Whether the render surface should run in macOS compositor's -/// opaque-window fast path. Mirrors ghostty's NSWindow.isOpaque -/// decision (`macos/.../TerminalWindow.swift:482-505`): only flip to -/// non-opaque when the user actually configured transparency -/// (`window.opacity < 1`) or a translucent background effect -/// (`window.blur`). Default = opaque so the steady-state look-and-feel -/// for the common case stays as fast as Terminal.app. +/// opaque-window fast path. Non-opaque iff the user actually +/// configured transparency (`window.opacity < 1`) or a glass +/// background effect — system blur on its own is not enough, since +/// without `opacity < 1` there's nothing transparent for the blur to +/// read through, so we keep the fast path for that case. Default = +/// opaque. #[inline] fn window_should_be_opaque(config: &rio_backend::config::Config) -> bool { - config.window.opacity >= 1.0 && !config.window.blur + config.window.opacity >= 1.0 && !config.window.blur.is_glass() } impl Screen<'_> { @@ -285,9 +285,9 @@ impl Screen<'_> { sugarloaf_errors, )?; - // Match ghostty: window is opaque (compositor fast path) unless - // the user actually configured transparency. The render surface - // can hold per-pixel alpha either way — see `cell_bg` in + // Window is opaque (compositor fast path) unless the user + // actually configured transparency. The render surface can + // hold per-pixel alpha either way — see `cell_bg` in // `grid_emit.rs` — but flipping the layer to non-opaque is // what makes the OS treat those alpha bits as see-through. sugarloaf.set_window_opaque(window_should_be_opaque(config)); diff --git a/rio-backend/src/config/mod.rs b/rio-backend/src/config/mod.rs index c82f170169..cfd76923b0 100644 --- a/rio-backend/src/config/mod.rs +++ b/rio-backend/src/config/mod.rs @@ -1381,7 +1381,7 @@ mod tests { assert_eq!(result.window.width, 800); assert_eq!(result.window.height, 600); assert_eq!(result.window.opacity, 0.75); - assert!(result.window.blur); + assert!(result.window.blur.is_enabled()); } #[test] @@ -1519,7 +1519,7 @@ mod tests { // Window: opacity and blur overridden, others preserved assert_eq!(result.window.opacity, 1.0); - assert!(result.window.blur); + assert!(result.window.blur.is_enabled()); assert_eq!(result.window.width, 1024); assert_eq!(result.window.height, 768); diff --git a/rio-backend/src/config/platform.rs b/rio-backend/src/config/platform.rs index 1ef2be3cc3..f3ea8ee578 100644 --- a/rio-backend/src/config/platform.rs +++ b/rio-backend/src/config/platform.rs @@ -44,7 +44,7 @@ pub struct PlatformWindow { #[serde(default = "Option::default")] pub opacity: Option, #[serde(default = "Option::default")] - pub blur: Option, + pub blur: Option, #[serde( default = "Option::default", rename = "background-image", diff --git a/rio-backend/src/config/window.rs b/rio-backend/src/config/window.rs index e8f1e37afe..b9c95c26c9 100644 --- a/rio-backend/src/config/window.rs +++ b/rio-backend/src/config/window.rs @@ -76,6 +76,107 @@ pub enum WindowsCornerPreference { RoundSmall = 3, } +/// Background blur / liquid-glass behaviour for the window. +/// +/// Accepted in TOML as either a bool or one of the macOS glass-effect +/// strings, mirroring the established `window.blur = true` legacy +/// config: +/// +/// ```toml +/// blur = false # off +/// blur = true # standard system blur (CGS / KWin / DWM) +/// blur = "macos-glass-regular" # macOS 26+ liquid glass, regular opacity +/// blur = "macos-glass-clear" # macOS 26+ liquid glass, highly transparent +/// ``` +/// +/// On platforms where the requested style isn't available (e.g. glass +/// values on macOS < 26 or on Linux/Windows), the windowing layer +/// degrades to `System` and emits a `tracing::warn` so the user finds +/// out without a hard failure. Glass values imply a translucent +/// window — they flip the layer/window's opaque flag the same way +/// `window.opacity < 1` does. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum WindowBlur { + #[default] + Off, + System, + MacosGlassRegular, + MacosGlassClear, +} + +impl WindowBlur { + /// True for any non-`Off` variant. Use this in places that just + /// care "is some kind of background effect on?" without needing to + /// distinguish the style. + #[inline] + pub fn is_enabled(self) -> bool { + !matches!(self, WindowBlur::Off) + } + + /// True for the macOS liquid-glass variants. They imply a + /// translucent window the same way `window.opacity < 1` does. + #[inline] + pub fn is_glass(self) -> bool { + matches!( + self, + WindowBlur::MacosGlassRegular | WindowBlur::MacosGlassClear + ) + } +} + +impl From for rio_window::window::BlurStyle { + fn from(b: WindowBlur) -> Self { + match b { + WindowBlur::Off => rio_window::window::BlurStyle::Off, + WindowBlur::System => rio_window::window::BlurStyle::System, + WindowBlur::MacosGlassRegular => { + rio_window::window::BlurStyle::MacosGlassRegular + } + WindowBlur::MacosGlassClear => rio_window::window::BlurStyle::MacosGlassClear, + } + } +} + +impl Serialize for WindowBlur { + fn serialize(&self, s: S) -> Result { + match self { + // Round-trip the legacy / common path as a bool so existing + // config files stay byte-identical after a save. + WindowBlur::Off => s.serialize_bool(false), + WindowBlur::System => s.serialize_bool(true), + WindowBlur::MacosGlassRegular => s.serialize_str("macos-glass-regular"), + WindowBlur::MacosGlassClear => s.serialize_str("macos-glass-clear"), + } + } +} + +impl<'de> Deserialize<'de> for WindowBlur { + fn deserialize>(d: D) -> Result { + // `untagged` lets a single field accept either a TOML bool + // (`blur = true`) or a TOML string (`blur = "macos-glass-clear"`) + // without forcing the caller to wrap it in a tagged form. + #[derive(Deserialize)] + #[serde(untagged)] + enum Raw { + Bool(bool), + Str(String), + } + + match Raw::deserialize(d)? { + Raw::Bool(false) => Ok(WindowBlur::Off), + Raw::Bool(true) => Ok(WindowBlur::System), + Raw::Str(s) => match s.as_str() { + "macos-glass-regular" => Ok(WindowBlur::MacosGlassRegular), + "macos-glass-clear" => Ok(WindowBlur::MacosGlassClear), + other => Err(serde::de::Error::custom(format!( + "unknown window.blur value `{other}`; expected a bool or one of \ + \"macos-glass-regular\", \"macos-glass-clear\"" + ))), + }, + } + } +} + #[derive(PartialEq, Serialize, Deserialize, Clone, Debug)] pub struct Window { #[serde(default = "default_window_width")] @@ -88,18 +189,16 @@ pub struct Window { pub opacity: f32, /// Apply `window.opacity` to cells that paint an explicit /// background color too, not just to the window's default - /// background. Off by default (matches ghostty's - /// `background-opacity-cells = false`): cells with an SGR-set - /// background stay fully opaque so syntax-highlighted regions and - /// status-line painted by tmux/Neovim keep their contrast. Flip - /// to `true` to make TUIs see-through too. + /// background. Off by default — cells with an SGR-set background + /// stay fully opaque so syntax-highlighted regions and status + /// lines painted by tmux/Neovim keep their contrast. Flip to + /// `true` to make TUIs see-through too. /// /// On the wire: kebab-case `opacity-cells` under `[window]`. - /// Mirrors `background-opacity-cells` in ghostty 1.2.0. #[serde(rename = "opacity-cells", default = "bool::default")] pub opacity_cells: bool, - #[serde(default = "bool::default")] - pub blur: bool, + #[serde(default)] + pub blur: WindowBlur, #[serde(rename = "background-image", skip_serializing)] pub background_image: Option, #[serde(default = "Decorations::default")] @@ -141,7 +240,7 @@ impl Default for Window { opacity_cells: false, background_image: None, decorations: Decorations::default(), - blur: false, + blur: WindowBlur::default(), macos_use_unified_titlebar: false, macos_use_shadow: true, macos_traffic_light_position_x: None, diff --git a/rio-window/src/platform/macos.rs b/rio-window/src/platform/macos.rs index f97865b74b..095e9a468d 100644 --- a/rio-window/src/platform/macos.rs +++ b/rio-window/src/platform/macos.rs @@ -119,6 +119,16 @@ pub trait WindowExtMacOS { /// The position is specified as (x, y) coordinates in points from the top-left corner. /// Pass None to reset to the default position. fn set_traffic_light_position(&self, position: Option<(f64, f64)>); + + /// Push the configured `window.opacity` into the liquid-glass + /// tint computation. Effective only when + /// [`crate::window::BlurStyle::MacosGlassRegular`] / + /// [`crate::window::BlurStyle::MacosGlassClear`] is active and + /// `NSGlassEffectView` is available at runtime (macOS 26+). + /// Otherwise stored for the next time glass installs. Mirrors + /// the `tintColor = bg.withAlphaComponent(opacity)` step in + /// AppKit-native glass setups. + fn set_glass_opacity(&self, opacity: f64); } impl WindowExtMacOS for Window { @@ -150,6 +160,12 @@ impl WindowExtMacOS for Window { .maybe_queue_on_main(move |w| w.set_background_color(r, g, b, a)) } + #[inline] + fn set_glass_opacity(&self, opacity: f64) { + self.window + .maybe_queue_on_main(move |w| w.set_glass_opacity(opacity)) + } + #[inline] fn set_tabbing_identifier(&self, identifier: &str) { self.window diff --git a/rio-window/src/platform_impl/linux/mod.rs b/rio-window/src/platform_impl/linux/mod.rs index 88c66770d8..c02e763681 100644 --- a/rio-window/src/platform_impl/linux/mod.rs +++ b/rio-window/src/platform_impl/linux/mod.rs @@ -339,7 +339,7 @@ impl Window { } #[inline] - pub fn set_blur(&self, blur: bool) { + pub fn set_blur(&self, blur: crate::window::BlurStyle) { x11_or_wayland!(match self; Window(w) => w.set_blur(blur)); } diff --git a/rio-window/src/platform_impl/linux/wayland/window/mod.rs b/rio-window/src/platform_impl/linux/wayland/window/mod.rs index c5fd37f773..61a96fa911 100644 --- a/rio-window/src/platform_impl/linux/wayland/window/mod.rs +++ b/rio-window/src/platform_impl/linux/wayland/window/mod.rs @@ -124,7 +124,9 @@ impl Window { // Set transparency hint. window_state.set_transparent(attributes.transparent); - window_state.set_blur(attributes.blur); + // KWin's blur protocol is binary on/off — glass styles don't + // exist outside macOS, so any non-Off value maps to "blur on". + window_state.set_blur(attributes.blur.is_enabled()); // Set the decorations hint. window_state.set_decorate(attributes.decorations); @@ -427,8 +429,11 @@ impl Window { } #[inline] - pub fn set_blur(&self, blur: bool) { - self.window_state.lock().unwrap().set_blur(blur); + pub fn set_blur(&self, blur: crate::window::BlurStyle) { + self.window_state + .lock() + .unwrap() + .set_blur(blur.is_enabled()); } #[inline] diff --git a/rio-window/src/platform_impl/linux/x11/window.rs b/rio-window/src/platform_impl/linux/x11/window.rs index 0fafc8f41f..c525789797 100644 --- a/rio-window/src/platform_impl/linux/x11/window.rs +++ b/rio-window/src/platform_impl/linux/x11/window.rs @@ -1148,7 +1148,7 @@ impl UnownedWindow { pub fn set_transparent(&self, _transparent: bool) {} #[inline] - pub fn set_blur(&self, _blur: bool) {} + pub fn set_blur(&self, _blur: crate::window::BlurStyle) {} fn set_decorations_inner( &self, diff --git a/rio-window/src/platform_impl/macos/glass.rs b/rio-window/src/platform_impl/macos/glass.rs new file mode 100644 index 0000000000..0741646f26 --- /dev/null +++ b/rio-window/src/platform_impl/macos/glass.rs @@ -0,0 +1,161 @@ +//! `NSGlassEffectView` wrapper for macOS 26 (Tahoe) liquid-glass blur. +//! +//! `NSGlassEffectView` is a brand-new AppKit class — `objc2-app-kit` +//! at the version this crate is pinned to doesn't expose it, so we +//! reach for the runtime directly. The class is looked up by name and +//! returns `None` on macOS < 26 (or when running against an older +//! AppKit), letting `set_blur` fall back to the CGS system-blur path. +//! +//! View-hierarchy contract: +//! +//! - In non-glass modes the `NSWindow.contentView` is `WinitView` +//! directly (status quo). +//! - In glass mode `NSWindow.contentView` is an `NSGlassEffectView` +//! and `glass.contentView` is `WinitView`. The glass paints the +//! blurred backdrop; `WinitView`'s `CAMetalLayer` (which we already +//! flip non-opaque via `Sugarloaf::set_window_opaque`) composites +//! on top. +//! +//! ## Lifetime hygiene +//! +//! Installing glass relocates `WinitView` from the window's +//! `contentView` slot into the glass's `contentView` slot. AppKit +//! retains the view in its new home before releasing the old slot, so +//! a Retained stashed elsewhere stays valid throughout. +//! The reverse path (uninstall) re-parents `WinitView` back to the +//! window before dropping the glass, in the same retain-then-release +//! order. + +use objc2::rc::{Allocated, Retained}; +use objc2::runtime::AnyClass; +use objc2::{msg_send, msg_send_id}; +use objc2_app_kit::{NSColor, NSView}; + +/// Maps to `NSGlassEffectViewStyle` raw values from AppKit. Apple +/// docs: +/// +/// - `Regular = 0` — standard glass with some opacity. +/// - `Clear = 1` — highly transparent glass, content shows through +/// more. +#[derive(Clone, Copy, Debug)] +pub(crate) enum GlassStyle { + Regular, + Clear, +} + +impl GlassStyle { + fn raw(self) -> isize { + match self { + GlassStyle::Regular => 0, + GlassStyle::Clear => 1, + } + } +} + +/// Strongly-typed handle to an `NSGlassEffectView` instance. Owns the +/// Retained reference; dropping it releases the underlying NSView. +#[derive(Debug)] +pub(crate) struct GlassEffect { + /// Stored as `Retained` because we don't have a generated + /// `NSGlassEffectView` Rust type — `NSView` is the closest + /// supertype `objc2-app-kit` ships, and the runtime calls below + /// don't need the leaf type. + view: Retained, +} + +impl GlassEffect { + /// `true` iff `NSGlassEffectView` is registered with the Objective-C + /// runtime. Returns `false` on macOS < 26 / older AppKit and on + /// platforms that don't link AppKit. Stable across the process — + /// callers can cache the result. + #[inline] + pub(crate) fn class_available() -> bool { + AnyClass::get("NSGlassEffectView").is_some() + } + + /// Allocate and `-init` a fresh `NSGlassEffectView`. Returns + /// `None` if the class isn't available at runtime. + pub(crate) fn new() -> Option { + let cls = AnyClass::get("NSGlassEffectView")?; + // SAFETY: NSGlassEffectView's `+alloc` / `-init` are the + // standard NSObject lifecycle methods inherited from NSView; + // they return a +1 retained instance with no in-band errors. + // objc2's `msg_send_id!` requires the + // `+alloc → Allocated` / `-init → Retained` rituals; + // both are stable across all NSView subclasses. + let view: Retained = unsafe { + let alloc: Allocated = msg_send_id![cls, alloc]; + msg_send_id![alloc, init] + }; + Some(GlassEffect { view }) + } + + /// Set `NSGlassEffectViewStyle` (regular vs clear). + pub(crate) fn set_style(&self, style: GlassStyle) { + let raw = style.raw(); + // SAFETY: `setStyle:` accepts an `NSGlassEffectViewStyle` + // (NSInteger). Passing 0 / 1 matches the documented raw + // values; out-of-range writes are clamped by AppKit. + unsafe { + let _: () = msg_send![&*self.view, setStyle: raw]; + } + } + + /// Tint the glass with `bg × opacity` — sets `tintColor` to the + /// host bg colour with its alpha channel multiplied by the + /// configured `window.opacity`. Without the opacity multiply, an + /// opaque-bg tint masks the blur entirely on `macos-glass-clear`. + pub(crate) fn set_tint_color_with_opacity(&self, color: &NSColor, opacity: f64) { + let opacity = opacity.clamp(0.0, 1.0); + // SAFETY: `colorWithAlphaComponent:` returns an autoreleased + // NSColor copy; `msg_send_id!` retains it for our use. + unsafe { + let tinted: Retained = + msg_send_id![color, colorWithAlphaComponent: opacity]; + let _: () = msg_send![&*self.view, setTintColor: &*tinted]; + } + } + + /// Set the glass's corner radius in points. Pass the host + /// window's `_cornerRadius` so the glass matches the rounded + /// window chrome — without this, the glass paints to its square + /// bounds and a rim of un-blurred pixels appears at the rounded + /// corners. + pub(crate) fn set_corner_radius(&self, radius: f64) { + // SAFETY: `setCornerRadius:` is a CGFloat (f64 on x86_64 + // / aarch64 macOS) setter; out-of-range values are clamped + // by AppKit. + unsafe { + let _: () = msg_send![&*self.view, setCornerRadius: radius]; + } + } + + /// Install the given view as the glass's contained content. The + /// glass renders behind / under it; the content view's own + /// translucent regions show the glass through. + pub(crate) fn set_content_view(&self, content: &NSView) { + // SAFETY: `setContentView:` adopts the view as a subview of + // the glass and retains it. + unsafe { + let _: () = msg_send![&*self.view, setContentView: content]; + } + } + + /// Borrow a Retained reference to the glass's current contentView + /// — typically `WinitView`. Used at uninstall time to recover the + /// inner view before reparenting it back onto the NSWindow, and + /// by `WindowDelegate::view()` to keep the existing + /// `Retained` accessor working in glass mode. + pub(crate) fn content_view(&self) -> Option> { + // SAFETY: `-contentView` is a standard `+0` getter; the + // `msg_send_id!` flavour with `Option>` handles + // the nil / non-nil + retain semantics correctly. + unsafe { msg_send_id![&*self.view, contentView] } + } + + /// Borrow the glass as an `NSView` for use as the host window's + /// contentView slot. + pub(crate) fn as_ns_view(&self) -> &NSView { + &self.view + } +} diff --git a/rio-window/src/platform_impl/macos/mod.rs b/rio-window/src/platform_impl/macos/mod.rs index 389a289f66..b718231a39 100644 --- a/rio-window/src/platform_impl/macos/mod.rs +++ b/rio-window/src/platform_impl/macos/mod.rs @@ -10,6 +10,7 @@ mod event; mod event_handler; mod event_loop; mod ffi; +mod glass; mod menu; mod monitor; mod observer; diff --git a/rio-window/src/platform_impl/macos/window_delegate.rs b/rio-window/src/platform_impl/macos/window_delegate.rs index 9c4bf00705..b9fc24deae 100644 --- a/rio-window/src/platform_impl/macos/window_delegate.rs +++ b/rio-window/src/platform_impl/macos/window_delegate.rs @@ -142,6 +142,17 @@ pub(crate) struct State { // Position of traffic light buttons (close, minimize, maximize) // Specified as (x, y) coordinates from top-left corner traffic_light_position: Cell>, + // Liquid-glass effect, populated only when `BlurStyle::MacosGlass*` + // is active AND `NSGlassEffectView` is available at runtime + // (macOS 26+). When present, the host window's contentView is the + // glass and `WinitView` is hosted inside it as `glass.contentView`. + glass_effect: RefCell>, + // Opacity multiplier baked into the glass tint colour + // (`tintColor = bg.withAlphaComponent(opacity)`). Stored + // separately because the rio-window `set_blur` API takes a + // `BlurStyle` only — opacity is plumbed through + // `set_glass_opacity`. + glass_opacity: Cell, } declare_class!( @@ -773,6 +784,8 @@ impl WindowDelegate { traffic_light_position: Cell::new( attrs.platform_specific.traffic_light_position, ), + glass_effect: RefCell::new(None), + glass_opacity: Cell::new(1.0), }); let delegate: Retained = unsafe { msg_send_id![super(delegate), init] }; @@ -794,7 +807,7 @@ impl WindowDelegate { ) }; - if attrs.blur { + if attrs.blur.is_enabled() { delegate.set_blur(attrs.blur); } @@ -843,8 +856,23 @@ impl WindowDelegate { #[track_caller] pub(super) fn view(&self) -> Retained { - // SAFETY: The view inside WinitWindow is always `WinitView` - unsafe { Retained::cast(self.window().contentView().unwrap()) } + // In glass mode the host window's contentView is an + // `NSGlassEffectView` and `WinitView` lives one level deeper + // as the glass's contentView. Drill through when glass is + // installed; otherwise the contentView is `WinitView` + // directly. + // SAFETY: The view inside WinitWindow is always `WinitView`, + // either as contentView (no glass) or as glass.contentView + // (glass installed). Both paths are populated by + // `WindowDelegate::new` / `install_or_update_glass`. + if let Some(glass) = self.ivars().glass_effect.borrow().as_ref() { + let inner = glass + .content_view() + .expect("glass installed but its contentView is nil"); + unsafe { Retained::cast(inner) } + } else { + unsafe { Retained::cast(self.window().contentView().unwrap()) } + } } #[track_caller] @@ -933,10 +961,41 @@ impl WindowDelegate { } } - pub fn set_blur(&self, blur: bool) { - // NOTE: in general we want to specify the blur radius, but the choice of 80 - // should be a reasonable default. - let radius = if blur { 80 } else { 0 }; + pub fn set_blur(&self, blur: crate::window::BlurStyle) { + use crate::window::BlurStyle; + + match blur { + BlurStyle::Off => { + self.uninstall_glass(); + self.set_cgs_blur_radius(0); + } + BlurStyle::System => { + self.uninstall_glass(); + self.set_cgs_blur_radius(80); + } + BlurStyle::MacosGlassRegular | BlurStyle::MacosGlassClear => { + if super::glass::GlassEffect::class_available() { + // CGS blur off — the glass view paints the + // backdrop itself, layering CGS on top doubles + // the effect. + self.set_cgs_blur_radius(0); + self.install_or_update_glass(blur); + } else { + tracing::warn!( + requested = ?blur, + "macOS liquid-glass requires NSGlassEffectView (macOS 26+). \ + Falling back to the standard system backdrop blur." + ); + self.uninstall_glass(); + self.set_cgs_blur_radius(80); + } + } + } + } + + /// CGS private-API knob — the legacy `window.blur = true` path. + /// Radius 80 matches upstream winit; 0 disables. + fn set_cgs_blur_radius(&self, radius: i64) { let window_number = unsafe { self.window().windowNumber() }; unsafe { ffi::CGSSetWindowBackgroundBlurRadius( @@ -947,6 +1006,110 @@ impl WindowDelegate { } } + /// Install (first call) or update (subsequent calls) the + /// liquid-glass effect. View hierarchy moves from + /// `window.contentView = WinitView` + /// to + /// `window.contentView = NSGlassEffectView`, + /// `glass.contentView = WinitView`. + fn install_or_update_glass(&self, blur: crate::window::BlurStyle) { + use super::glass::{GlassEffect, GlassStyle}; + use crate::window::BlurStyle; + + let style = match blur { + BlurStyle::MacosGlassRegular => GlassStyle::Regular, + BlurStyle::MacosGlassClear => GlassStyle::Clear, + _ => return, + }; + + let mut slot = self.ivars().glass_effect.borrow_mut(); + if slot.is_none() { + // First install. Order matters for retain counts: + // grab the current contentView (`+1` retain via the + // Retained return type) BEFORE swapping. + // `window.setContentView(glass)` will detach + release + // the old contentView; the Retained we hold keeps it + // alive across the gap until `glass.setContentView` + // re-parents it. + let glass = match GlassEffect::new() { + Some(g) => g, + None => return, + }; + let window = self.window(); + if let Some(view) = window.contentView() { + window.setContentView(Some(glass.as_ns_view())); + glass.set_content_view(&view); + } else { + // No contentView at all — install the glass empty; + // the regular `setContentView` flow will fill it + // later. Defensive; shouldn't fire in practice. + window.setContentView(Some(glass.as_ns_view())); + } + *slot = Some(glass); + } + + if let Some(glass) = slot.as_ref() { + glass.set_style(style); + // Tint = bg × opacity. Without the opacity multiply the + // tint colour swallows the blur entirely on + // `macos-glass-clear`. + glass.set_tint_color_with_opacity( + &self.ivars().background_color.borrow(), + self.ivars().glass_opacity.get(), + ); + // Match the host window's rounded corners so glass + // doesn't paint past them. `_cornerRadius` is a private + // selector — gated on `respondsToSelector:` so it + // degrades gracefully if Apple ever removes / renames it. + if let Some(radius) = self.window_corner_radius() { + glass.set_corner_radius(radius); + } + } + } + + /// Query the host `NSWindow`'s rounded-corner radius via the + /// private `_cornerRadius` selector. Returns `None` when the + /// selector isn't available so the caller can fall back to + /// square corners. + fn window_corner_radius(&self) -> Option { + use objc2::msg_send; + use objc2::sel; + let window = self.window(); + // SAFETY: `respondsToSelector:` is a stable NSObject method; + // when it returns true, `_cornerRadius` is documented (in + // NSThemeFrame headers, leaked via private headers) to + // return a CGFloat. Both calls are gated, so a future SDK + // that removes `_cornerRadius` falls through to `None`. + unsafe { + let responds: bool = + msg_send![window, respondsToSelector: sel!(_cornerRadius)]; + if responds { + let radius: f64 = msg_send![window, _cornerRadius]; + Some(radius) + } else { + None + } + } + } + + /// Reverse of `install_or_update_glass`: pull `WinitView` back + /// out of the glass and reinstate it as the window's contentView, + /// then drop the glass. No-op if glass isn't installed. + fn uninstall_glass(&self) { + let mut slot = self.ivars().glass_effect.borrow_mut(); + if let Some(glass) = slot.take() { + let window = self.window(); + if let Some(view) = glass.content_view() { + // Same retain ordering as install: hold the inner + // view via `Retained` across the swap, then let glass + // drop and release. + window.setContentView(Some(&view)); + } + // glass drops here; AppKit releases the NSGlassEffectView. + drop(glass); + } + } + pub fn set_visible(&self, visible: bool) { match visible { true => self.window().makeKeyAndOrderFront(None), @@ -2068,6 +2231,23 @@ impl WindowExtMacOS for WindowDelegate { self.ivars().traffic_light_position.set(position); self.move_traffic_light(); } + + fn set_glass_opacity(&self, opacity: f64) { + let clamped = opacity.clamp(0.0, 1.0); + if (self.ivars().glass_opacity.get() - clamped).abs() < f64::EPSILON { + return; + } + self.ivars().glass_opacity.set(clamped); + // If glass is currently installed, re-apply the tint so the + // visible alpha tracks the new opacity without waiting for a + // full set_blur cycle. + if let Some(glass) = self.ivars().glass_effect.borrow().as_ref() { + glass.set_tint_color_with_opacity( + &self.ivars().background_color.borrow(), + clamped, + ); + } + } } const DEFAULT_STANDARD_FRAME: NSRect = diff --git a/rio-window/src/platform_impl/orbital/window.rs b/rio-window/src/platform_impl/orbital/window.rs index 9234f91727..b9a3ec72fc 100644 --- a/rio-window/src/platform_impl/orbital/window.rs +++ b/rio-window/src/platform_impl/orbital/window.rs @@ -289,7 +289,7 @@ impl Window { } #[inline] - pub fn set_blur(&self, _blur: bool) {} + pub fn set_blur(&self, _blur: crate::window::BlurStyle) {} #[inline] pub fn set_visible(&self, visible: bool) { diff --git a/rio-window/src/platform_impl/web/window.rs b/rio-window/src/platform_impl/web/window.rs index 78aa0cce45..f9ac5ccc06 100644 --- a/rio-window/src/platform_impl/web/window.rs +++ b/rio-window/src/platform_impl/web/window.rs @@ -135,7 +135,7 @@ impl Inner { pub fn set_transparent(&self, _transparent: bool) {} - pub fn set_blur(&self, _blur: bool) {} + pub fn set_blur(&self, _blur: crate::window::BlurStyle) {} pub fn set_visible(&self, _visible: bool) { // Intentionally a no-op diff --git a/rio-window/src/platform_impl/windows/window.rs b/rio-window/src/platform_impl/windows/window.rs index a06e64a273..0005beb7d3 100644 --- a/rio-window/src/platform_impl/windows/window.rs +++ b/rio-window/src/platform_impl/windows/window.rs @@ -163,13 +163,15 @@ impl Window { }); } - pub fn set_blur(&self, blur: bool) { - // Maps the cross-platform `blur` flag to the Windows 11 - // Acrylic backdrop (`DWMSBT_TRANSIENTWINDOW`). On Windows 10 - // / pre-22H2 builds `DwmSetWindowAttribute` returns - // `E_INVALIDARG` and the backdrop silently does nothing — - // matches the no-op behaviour of the previous stub. - self.set_system_backdrop(if blur { + pub fn set_blur(&self, blur: crate::window::BlurStyle) { + // Maps the cross-platform blur style to the Windows 11 + // Acrylic backdrop (`DWMSBT_TRANSIENTWINDOW`). DWM has no + // liquid-glass equivalent, so any non-`Off` style maps the + // same way. On Windows 10 / pre-22H2 builds + // `DwmSetWindowAttribute` returns `E_INVALIDARG` and the + // backdrop silently does nothing — matches the no-op behaviour + // of the previous stub. + self.set_system_backdrop(if blur.is_enabled() { BackdropType::TransientWindow } else { BackdropType::None @@ -1439,8 +1441,8 @@ impl InitData<'_> { } win.set_system_backdrop(self.attributes.platform_specific.backdrop_type); - if self.attributes.blur { - win.set_blur(true); + if self.attributes.blur.is_enabled() { + win.set_blur(self.attributes.blur); } if let Some(color) = self.attributes.platform_specific.border_color { diff --git a/rio-window/src/window.rs b/rio-window/src/window.rs index 092c51181b..b19897ac87 100644 --- a/rio-window/src/window.rs +++ b/rio-window/src/window.rs @@ -1,6 +1,43 @@ //! The [`Window`] struct and associated types. use std::fmt; +/// Background blur / liquid-glass style for a window. +/// +/// `Off` and `System` are the legacy bool states (`false` / `true`) +/// preserved for backward compatibility. The macOS variants request +/// the `NSGlassEffectView`-backed liquid-glass effect introduced in +/// macOS 26 (Tahoe). Platforms that don't support a requested style +/// degrade to `System` and emit a `tracing::warn` rather than failing. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum BlurStyle { + #[default] + Off, + /// Native system blur — CGS backdrop on macOS, KWin blur on + /// Wayland, DWM acrylic on Windows. + System, + /// macOS 26+: liquid glass with the `regular` style (some opacity). + MacosGlassRegular, + /// macOS 26+: liquid glass with the `clear` style (highly transparent). + MacosGlassClear, +} + +impl BlurStyle { + /// True for any non-`Off` variant. + #[inline] + pub fn is_enabled(self) -> bool { + !matches!(self, BlurStyle::Off) + } + + /// True for the macOS liquid-glass variants. + #[inline] + pub fn is_glass(self) -> bool { + matches!( + self, + BlurStyle::MacosGlassRegular | BlurStyle::MacosGlassClear + ) + } +} + use crate::dpi::{PhysicalPosition, PhysicalSize, Position, Size}; use crate::error::{ExternalError, NotSupportedError}; use crate::monitor::{MonitorHandle, VideoModeHandle}; @@ -115,7 +152,7 @@ pub struct WindowAttributes { pub maximized: bool, pub visible: bool, pub transparent: bool, - pub blur: bool, + pub blur: BlurStyle, pub decorations: bool, pub window_icon: Option, pub preferred_theme: Option, @@ -146,7 +183,7 @@ impl Default for WindowAttributes { fullscreen: None, visible: true, transparent: false, - blur: false, + blur: BlurStyle::default(), decorations: true, window_level: Default::default(), window_icon: None, @@ -327,13 +364,11 @@ impl WindowAttributes { self } - /// Sets whether the background of the window should be blurred by the system. + /// Sets the window's background blur / liquid-glass style. /// - /// The default is `false`. - /// - /// See [`Window::set_blur`] for details. + /// The default is [`BlurStyle::Off`]. See [`Window::set_blur`]. #[inline] - pub fn with_blur(mut self, blur: bool) -> Self { + pub fn with_blur(mut self, blur: BlurStyle) -> Self { self.blur = blur; self } @@ -955,15 +990,21 @@ impl Window { /// Change the window blur state. /// - /// If `true`, this will make the transparent window background blurry. + /// Apply a [`BlurStyle`] to the window background. /// /// ## Platform-specific /// - /// - **Android / iOS / X11 / Web / Windows:** Unsupported. - /// - **Wayland:** Only works with org_kde_kwin_blur_manager protocol. - #[inline] - pub fn set_blur(&self, blur: bool) { - let _span = tracing::debug_span!("rio_window::Window::set_blur", blur).entered(); + /// - **Android / iOS / X11 / Web:** Unsupported. + /// - **Wayland:** `System` works with `org_kde_kwin_blur_manager`; + /// glass values fall back to `System`. + /// - **Windows:** `System` uses the DWM acrylic backdrop; glass + /// values fall back to `System`. + /// - **macOS:** `System` uses the CGS backdrop blur. Glass + /// variants use `NSGlassEffectView` on macOS 26+ and fall back + /// to `System` with a warning on earlier versions. + #[inline] + pub fn set_blur(&self, blur: BlurStyle) { + let _span = tracing::debug_span!("rio_window::Window::set_blur", ?blur).entered(); self.window.maybe_queue_on_main(move |w| w.set_blur(blur)) } diff --git a/sugarloaf/src/context/metal.rs b/sugarloaf/src/context/metal.rs index 9137e244ff..b01fa59dd4 100644 --- a/sugarloaf/src/context/metal.rs +++ b/sugarloaf/src/context/metal.rs @@ -120,11 +120,10 @@ impl MetalContext { tracing::warn!("Failed to create Display P3 CGColorSpace"); } // Default to opaque so the macOS compositor can take its - // fast path on windows without configured transparency. - // Mirrors ghostty's NSWindow.isOpaque default - // (`macos/.../TerminalWindow.swift:482-505`). The host (rio) - // flips this to `false` via `Sugarloaf::set_window_opaque` - // when `config.window.opacity < 1` or background blur is on. + // fast path on windows without configured transparency. The + // host (rio) flips this to `false` via + // `Sugarloaf::set_window_opaque` when `config.window.opacity + // < 1` or a background blur style is on. layer.set_opaque(true); layer.set_presents_with_transaction(false); diff --git a/sugarloaf/src/sugarloaf.rs b/sugarloaf/src/sugarloaf.rs index c71d96271e..5d823440b2 100644 --- a/sugarloaf/src/sugarloaf.rs +++ b/sugarloaf/src/sugarloaf.rs @@ -510,12 +510,10 @@ impl Sugarloaf<'_> { /// Mark the window's render surface opaque (`true`, default — fast /// macOS compositor path) or non-opaque (`false`, required for - /// `window.opacity < 1` and macOS-glass background blur). Mirrors - /// ghostty's conditional `NSWindow.isOpaque` toggle in - /// `macos/.../TerminalWindow.swift`. Safe to call every config - /// reload; underlying call is a single Cocoa property write on - /// macOS, no-op on other backends until they grow their own - /// transparency story. + /// `window.opacity < 1` and macOS-glass background blur). Safe to + /// call every config reload; underlying call is a single Cocoa + /// property write on macOS, no-op on other backends until they + /// grow their own transparency story. #[inline] pub fn set_window_opaque(&self, opaque: bool) { match &self.ctx.inner {