diff --git a/frontends/rioterm/src/grid_emit.rs b/frontends/rioterm/src/grid_emit.rs index d5e6bfd2..661d734c 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 ba48acd3..9fbd231b 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 dd946765..714b2f30 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 d2738556..e8f1e37a 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 ce1dabb5..9137e244 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 8e8a7a6d..c71d9627 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