From 14ca9e93a6fc50487370cc5781691dd5f3b6859a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rodrigo=20Ram=C3=ADrez=20Norambuena?= Date: Thu, 16 Jul 2026 18:11:26 -0400 Subject: [PATCH 1/2] feat(config,renderer): add window.opacity for transparent background Add a window.opacity config (0.0-1.0, default 1.0). When below 1.0 the window is created transparent and the terminal background is filled with that alpha while text and UI chrome stay opaque. Alpha handling is compositor/platform-dependent (best-effort). --- CHANGELOG.md | 3 ++ README.md | 1 + assets/config.toml | 1 + doc/SPEC.md | 6 ++++ src/config/config_test.rs | 38 ++++++++++++++++++++ src/config/mod.rs | 5 +++ src/config/tui_config.rs | 14 ++++++++ src/config/tui_config_test.rs | 51 +++++++++++++++++++++----- src/renderer/draw_fns.rs | 16 +++++++++ src/renderer/render_ops.rs | 1 + src/renderer/text.rs | 13 +++++-- src/renderer/text_test.rs | 68 +++++++++++++++++++++++++++++++++++ src/winit_handler.rs | 5 +++ 13 files changed, 211 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9464b78..432f2f3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,9 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ## [Unreleased] +### Added +- add `window.opacity` for a transparent background (compositor-dependent) + ## [0.10.0] - 2026-07-25 ### Added diff --git a/README.md b/README.md index c219819..b998685 100644 --- a/README.md +++ b/README.md @@ -159,6 +159,7 @@ width = 800 height = 600 title = "mmterm" cursor_blink_ms = 500 +opacity = 1.0 # < 1.0 = transparent background (needs a compositor that supports it) [shell] # program = "/bin/zsh" # defaults to $SHELL diff --git a/assets/config.toml b/assets/config.toml index fbbd7ef..2a757a6 100644 --- a/assets/config.toml +++ b/assets/config.toml @@ -11,6 +11,7 @@ title = "mmterm" cursor_blink_ms = 500 inactive_dim = 0.55 detect_urls = true +opacity = 1.0 [shell] # program = "/bin/zsh" diff --git a/doc/SPEC.md b/doc/SPEC.md index e3f10a6..75fcf58 100644 --- a/doc/SPEC.md +++ b/doc/SPEC.md @@ -221,6 +221,7 @@ Screenshot capture is a two-step flow: region selection followed by a name promp | window | cursor_blink_ms | uint | `500` | | window | inactive_dim | float | `0.55` | | window | detect_urls | bool | `true` | +| window | opacity | float | `1.0` | | terminal | scrollback_lines | uint | `10000` (min 100) | | shell | program | string? | `$SHELL` | | logging | auto_log | bool | `false` | @@ -228,6 +229,11 @@ Screenshot capture is a two-step flow: region selection followed by a name promp | status_bar | right | string | `""` | | theme | name | string | `"default"` | +`window.opacity` sets the alpha of the terminal background. Values below `1.0` +make the window transparent while keeping text and UI chrome fully opaque; this +requires a compositor that supports transparency and the exact result is +platform-dependent (X11, Wayland, and macOS handle window alpha differently). + ### Themes Themes define all terminal and UI colors in a single `.toml` file. diff --git a/src/config/config_test.rs b/src/config/config_test.rs index 4bd6877..dbac900 100644 --- a/src/config/config_test.rs +++ b/src/config/config_test.rs @@ -134,6 +134,44 @@ fn default_detect_urls_value() { assert!(default_detect_urls()); } +#[test] +fn default_opacity_value() { + assert_eq!(default_opacity(), 1.0); + assert_eq!(Config::default().window.opacity, 1.0); +} + +#[test] +fn opacity_default_applied_when_missing() { + let toml = r###" +[font] +family = "Mono" +size = 14.0 +[window] +width = 800 +height = 600 +title = "t" +cursor_blink_ms = 500 +[shell] +[colors] +background = "#000000" +foreground = "#ffffff" +cursor = "#ffffff" +selection = "#333333" +palette = [] +"###; + let cfg: Config = toml::from_str(toml).expect("parse failed"); + assert_eq!(cfg.window.opacity, 1.0); +} + +#[test] +fn opacity_round_trips_through_toml() { + let mut cfg = Config::default(); + cfg.window.opacity = 0.8; + let s = toml::to_string_pretty(&cfg).expect("serialize failed"); + let back: Config = toml::from_str(&s).expect("parse failed"); + assert_eq!(back.window.opacity, 0.8); +} + #[test] fn detect_urls_default_applied_when_missing() { let toml = r###" diff --git a/src/config/mod.rs b/src/config/mod.rs index 571c12a..d7c7562 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -147,6 +147,9 @@ fn default_inactive_dim() -> f32 { fn default_detect_urls() -> bool { true } +fn default_opacity() -> f32 { + 1.0 +} #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct WindowConfig { @@ -158,6 +161,8 @@ pub struct WindowConfig { pub inactive_dim: f32, #[serde(default = "default_detect_urls")] pub detect_urls: bool, + #[serde(default = "default_opacity")] + pub opacity: f32, } #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] diff --git a/src/config/tui_config.rs b/src/config/tui_config.rs index 6060b52..c254313 100644 --- a/src/config/tui_config.rs +++ b/src/config/tui_config.rs @@ -33,6 +33,7 @@ const F_AUTO_UPDATE_CHECK: usize = 37; const F_AUTO_UPDATE_INSTALL: usize = 38; const F_SHELL_INTEGRATION: usize = 39; const F_DESKTOP_NOTIFICATIONS: usize = 40; +const F_OPACITY: usize = 41; const PALETTE_LABELS: [&str; 16] = [ "Palette 0 black", @@ -302,6 +303,14 @@ impl ConfigPanel { section: None, }); + fields.push(Field { + label: "Opacity", + hint: "0.0–1.0 window background opacity", + value: cfg.window.opacity.to_string(), + kind: FieldKind::Float, + section: None, + }); + let mut collapsed = HashSet::new(); collapsed.insert("Palette"); @@ -624,6 +633,10 @@ impl ConfigPanel { let detect_urls = get(F_DETECT_URLS) .parse::() .map_err(|_| "Invalid detect_urls — use true or false")?; + let opacity = get(F_OPACITY) + .parse::() + .map_err(|_| "Invalid opacity")? + .clamp(0.0, 1.0); let shell = { let s = get(F_SHELL); if s.is_empty() { None } else { Some(s) } @@ -687,6 +700,7 @@ impl ConfigPanel { cursor_blink_ms: blink_ms, inactive_dim, detect_urls, + opacity, }, shell: ShellConfig { program: shell }, terminal: TerminalConfig { scrollback_lines }, diff --git a/src/config/tui_config_test.rs b/src/config/tui_config_test.rs index 5f8381b..210590a 100644 --- a/src/config/tui_config_test.rs +++ b/src/config/tui_config_test.rs @@ -10,8 +10,8 @@ fn make_panel() -> ConfigPanel { #[test] fn from_config_has_correct_field_count() { let panel = make_panel(); - // 9 base + 1 scrollback + 2 logging + 1 theme + 4 colors + 16 palette + 1 status_bar + 3 general + 2 updates + 2 shell/notify = 41 - assert_eq!(panel.fields.len(), 41); + // 9 base + 1 scrollback + 2 logging + 1 theme + 4 colors + 16 palette + 1 status_bar + 3 general + 2 updates + 2 shell/notify + 1 opacity = 42 + assert_eq!(panel.fields.len(), 42); } #[test] @@ -305,6 +305,7 @@ fn distinct_config() -> Config { cursor_blink_ms: 523, inactive_dim: 0.42, detect_urls: true, + opacity: 0.8, }, shell: ShellConfig { program: Some("/bin/xyzsh".into()), @@ -375,6 +376,7 @@ fn field_index_sanity() { F_AUTO_UPDATE_INSTALL, F_SHELL_INTEGRATION, F_DESKTOP_NOTIFICATIONS, + F_OPACITY, ]; occupied.extend((0..16).map(|i| F_PALETTE + i)); occupied.sort_unstable(); @@ -433,6 +435,37 @@ fn build_config_roundtrip_toggles_desktop_notifications() { } } +#[test] +fn build_config_preserves_opacity() { + let mut panel = make_panel(); + panel.fields[F_OPACITY].value = "0.75".to_string(); + if let ConfigAction::Save(cfg) = panel.save() { + assert_eq!(cfg.window.opacity, 0.75); + } else { + panic!("expected Save action"); + } +} + +#[test] +fn build_config_clamps_opacity_above_one() { + let mut panel = make_panel(); + panel.fields[F_OPACITY].value = "2.5".to_string(); + let cfg = panel + .build_config() + .expect("opacity clamps, does not error"); + assert_eq!(cfg.window.opacity, 1.0); +} + +#[test] +fn build_config_clamps_opacity_below_zero() { + let mut panel = make_panel(); + panel.fields[F_OPACITY].value = "-1.0".to_string(); + let cfg = panel + .build_config() + .expect("opacity clamps, does not error"); + assert_eq!(cfg.window.opacity, 0.0); +} + #[test] fn build_config_shell_empty_becomes_none() { let mut panel = make_panel(); @@ -680,8 +713,8 @@ fn palette_collapsed_by_default() { #[test] fn visible_indices_hides_palette_body() { let panel = make_panel(); - // 41 total - 15 palette body fields = 26 visible - assert_eq!(panel.visible_indices().len(), 26); + // 42 total - 15 palette body fields = 27 visible + assert_eq!(panel.visible_indices().len(), 27); } #[test] @@ -690,7 +723,7 @@ fn toggle_on_palette_header_expands() { panel.selected = F_PALETTE; panel.toggle_collapse(); assert!(!panel.collapsed.contains("Palette")); - assert_eq!(panel.visible_indices().len(), 41); + assert_eq!(panel.visible_indices().len(), 42); } #[test] @@ -700,7 +733,7 @@ fn toggle_twice_restores_collapsed() { panel.toggle_collapse(); panel.toggle_collapse(); assert!(panel.collapsed.contains("Palette")); - assert_eq!(panel.visible_indices().len(), 26); + assert_eq!(panel.visible_indices().len(), 27); } #[test] @@ -755,10 +788,10 @@ fn move_up_skips_collapsed_palette() { #[test] fn move_down_at_last_visible_clamps() { let mut panel = make_panel(); - // F_DESKTOP_NOTIFICATIONS is the last field and is always visible - panel.selected = F_DESKTOP_NOTIFICATIONS; + // F_OPACITY is the last field and is always visible + panel.selected = F_OPACITY; panel.handle_down(); - assert_eq!(panel.selected, F_DESKTOP_NOTIFICATIONS); + assert_eq!(panel.selected, F_OPACITY); } #[test] diff --git a/src/renderer/draw_fns.rs b/src/renderer/draw_fns.rs index c24998b..7abff28 100644 --- a/src/renderer/draw_fns.rs +++ b/src/renderer/draw_fns.rs @@ -72,6 +72,13 @@ pub(super) fn color_u32(c: Color) -> u32 { (0xFF << 24) | ((c.r as u32) << 16) | ((c.g as u32) << 8) | (c.b as u32) } +/// Pack a color with an explicit alpha byte in the high 8 bits (`0xAARRGGBB`). +/// Used for the terminal background when `window.opacity < 1.0`; whether the +/// alpha is honored depends on the platform compositor (softbuffer). +pub(super) fn color_u32_with_alpha(c: Color, a: u8) -> u32 { + ((a as u32) << 24) | ((c.r as u32) << 16) | ((c.g as u32) << 8) | (c.b as u32) +} + pub(super) fn dim_color(c: u32, factor: f32) -> u32 { let r = (((c >> 16) & 0xFF) as f32 * factor) as u32; let g = (((c >> 8) & 0xFF) as f32 * factor) as u32; @@ -429,6 +436,15 @@ mod tests { assert_eq!(color_u32(c), 0xFF_12_34_56); } + #[test] + fn color_u32_with_alpha_packs_alpha_in_high_byte() { + let c = Color::rgb(0x12, 0x34, 0x56); + assert_eq!(color_u32_with_alpha(c, 0x80), 0x80_12_34_56); + assert_eq!(color_u32_with_alpha(c, 0x00), 0x00_12_34_56); + // Full alpha is identical to the opaque packing. + assert_eq!(color_u32_with_alpha(c, 0xFF), color_u32(c)); + } + #[test] fn dim_color_reduces_brightness() { let c = 0xFF_80_80_80u32; diff --git a/src/renderer/render_ops.rs b/src/renderer/render_ops.rs index 8810942..0a3dccd 100644 --- a/src/renderer/render_ops.rs +++ b/src/renderer/render_ops.rs @@ -248,6 +248,7 @@ impl App { &self.state.theme, update_badge.as_ref(), self.state.hovered_url.as_deref(), + self.state.config.window.opacity, ); // Capture screenshot before overlays; views/guards still alive here. diff --git a/src/renderer/text.rs b/src/renderer/text.rs index d712105..99a0790 100644 --- a/src/renderer/text.rs +++ b/src/renderer/text.rs @@ -176,12 +176,14 @@ impl Renderer { theme: &ResolvedTheme, update_badge: Option<&UpdateBadge>, hovered_url: Option<&str>, + opacity: f32, ) { + let bg_alpha = (opacity.clamp(0.0, 1.0) * 255.0) as u8; let bg_fill = panes .first() .map(|p| p.grid.default_bg) .unwrap_or(theme.background); - buf.fill(color_u32(bg_fill)); + buf.fill(color_u32_with_alpha(bg_fill, bg_alpha)); for pane in panes { self.draw_pane( @@ -192,6 +194,7 @@ impl Renderer { pane.metrics, inactive_dim, theme, + bg_alpha, ); } @@ -244,11 +247,17 @@ impl Renderer { m: &FontMetrics, dim_factor: f32, theme: &ResolvedTheme, + bg_alpha: u8, ) { let grid = pane.grid; // Pre-fill gutter pixels so they match the pane background. - fill_pane_background(buf, buf_width, pane.rect, color_u32(grid.default_bg)); + fill_pane_background( + buf, + buf_width, + pane.rect, + color_u32_with_alpha(grid.default_bg, bg_alpha), + ); let selection_range = if pane.is_active { match mode { diff --git a/src/renderer/text_test.rs b/src/renderer/text_test.rs index 54a232d..d8f7929 100644 --- a/src/renderer/text_test.rs +++ b/src/renderer/text_test.rs @@ -191,6 +191,7 @@ fn draw_empty_buffer_does_not_panic() { &theme, None, // update_badge: wired in Task 9 None, // hovered_url + 1.0, ); } @@ -237,10 +238,61 @@ fn draw_pane_fills_background_color() { &theme, None, // update_badge: wired in Task 9 None, // hovered_url + 1.0, ); assert!(buf.iter().any(|&p| p != 0)); } +#[test] +fn draw_with_partial_opacity_does_not_panic() { + let mut r = make_renderer(); + let m = r.make_metrics(Physical(16.0)); + let (cols, rows) = m.grid_size_for(800, 600u32.saturating_sub(44)); + let grid = make_grid(cols, rows); + let pane = PaneView { + grid: &grid, + rect: [0, 22, 800, 600 - 44], + scroll_offset: 0, + is_active: true, + show_cursor: false, + blink_visible: true, + search_matches: &[], + search_current: None, + hovered_url: None, + cursor_shape: CursorShape::Block, + metrics: &m, + }; + let mut buf = vec![0u32; 800 * 600]; + let theme = default_theme(); + r.draw( + &mut buf, + 800, + 600, + &[pane], + &[], + &InputMode::Insert, + false, + &[("shell".to_string(), true, false)], + 0, + 0, + None, + None, + 0.55, + None, + false, + false, + ShellState::Unknown, + None, + &theme, + None, + None, // hovered_url + 0.8, + ); + // Background is filled with a sub-1.0 alpha in the high byte. + let bg_alpha = (0.8f32 * 255.0) as u8 as u32; + assert!(buf.iter().any(|&p| (p >> 24) == bg_alpha)); +} + #[test] fn draw_tab_bar_renders_without_panic() { let mut r = make_renderer(); @@ -271,6 +323,7 @@ fn draw_tab_bar_renders_without_panic() { &theme, None, // update_badge: wired in Task 9 None, // hovered_url + 1.0, ); } @@ -304,6 +357,7 @@ fn draw_status_bar_renders_without_panic() { &theme, None, // update_badge: wired in Task 9 None, // hovered_url + 1.0, ); } @@ -335,6 +389,7 @@ fn draw_status_bar_shell_indicators_render_without_panic() { &theme, None, None, // hovered_url + 1.0, ); // Something was drawn in the status-bar band (bottom rows). assert!(buf.iter().any(|&p| p != 0)); @@ -369,6 +424,7 @@ fn draw_status_bar_pane_title_centered() { &theme, None, // update_badge: wired in Task 9 None, // hovered_url + 1.0, ); let mut buf_without = vec![0u32; 800 * 600]; @@ -394,6 +450,7 @@ fn draw_status_bar_pane_title_centered() { &theme, None, // update_badge: wired in Task 9 None, // hovered_url + 1.0, ); assert!( @@ -436,6 +493,7 @@ fn draw_status_bar_hovered_url_does_not_panic() { &theme, None, Some(url), + 1.0, ); assert!( buf.iter().any(|&p| p != 0), @@ -476,6 +534,7 @@ fn draw_status_bar_pane_title_suppressed_in_search() { &theme, None, // update_badge: wired in Task 9 None, // hovered_url + 1.0, ); let mut buf_without = vec![0u32; 800 * 600]; @@ -504,6 +563,7 @@ fn draw_status_bar_pane_title_suppressed_in_search() { &theme, None, // update_badge: wired in Task 9 None, // hovered_url + 1.0, ); assert_eq!( @@ -566,6 +626,7 @@ fn draw_with_bell_flash_does_not_panic() { &theme, None, // update_badge: wired in Task 9 None, // hovered_url + 1.0, ); } @@ -597,6 +658,7 @@ fn draw_with_separator_does_not_panic() { &theme, None, // update_badge: wired in Task 9 None, // hovered_url + 1.0, ); } @@ -697,6 +759,7 @@ fn do_draw(r: &mut Renderer, panes: &[PaneView<'_>], mode: &InputMode) { &theme, None, // update_badge: wired in Task 9 None, // hovered_url + 1.0, ); } @@ -868,6 +931,7 @@ fn draw_pane_osc8_link_paints_underline_without_hover() { &theme, None, // update_badge: wired in Task 9 None, // hovered_url + 1.0, ); // Underline row is at rect_y + cell_height - 2 (cell at row 0, rect_y = 22) let ul_y = (22 + m.cell_height.saturating_sub(2)) as usize; @@ -963,6 +1027,7 @@ fn draw_pane_reverse_video_swaps_background_to_fg_color() { &theme, None, // update_badge: wired in Task 9 None, // hovered_url + 1.0, ); // Cell (0,0) background pixel: x = 4 (PANE_PADDING), y = 22+4 (TAB_BAR_H+PANE_PADDING) let px = 4usize; @@ -1193,6 +1258,7 @@ fn draw_status_bar_search_empty_query_shows_slash() { &theme, None, // update_badge: wired in Task 9 None, // hovered_url + 1.0, ); } @@ -1227,6 +1293,7 @@ fn draw_status_bar_search_no_matches_shows_label() { &theme, None, // update_badge: wired in Task 9 None, // hovered_url + 1.0, ); } @@ -1406,6 +1473,7 @@ fn pane_padding_leaves_top_left_corner_as_background() { &theme, None, // update_badge: wired in Task 9 None, // hovered_url + 1.0, ); // Every pixel in the top-left padding block must equal bg. for dy in 0..PANE_PADDING { diff --git a/src/winit_handler.rs b/src/winit_handler.rs index 31dd11f..b219aaa 100644 --- a/src/winit_handler.rs +++ b/src/winit_handler.rs @@ -50,6 +50,11 @@ impl ApplicationHandler for App { let mut attrs = Window::default_attributes() .with_title(self.state.config.window.title.clone()) .with_window_icon(icon); + // Request a transparent window when a sub-1.0 background opacity is set. + // Whether it takes effect depends on the platform compositor. + if self.state.config.window.opacity < 1.0 { + attrs = attrs.with_transparent(true); + } // A `--maximized` / `--fullscreen` flag takes precedence over the saved // session geometry, which in turn overrides the config window size. match self.startup_window { From a3d902821a5755380c3265c0b523c3a5f5412ab5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rodrigo=20Ram=C3=ADrez=20Norambuena?= Date: Sun, 9 Aug 2026 13:37:08 -0400 Subject: [PATCH 2/2] fix(renderer): apply window.opacity to the whole terminal area The alpha only reached the global buffer fill and the pane background, so every cell repainted itself opaque and the transparency was visible only in the pane padding at the window edges. Thread a BgAlpha (alpha + default background) down to resolve_bg_color and pack the alpha into cells still on the default background. Cells with an application-set background, the cursor, the selection, and search highlights stay opaque; glyph blending already forces full alpha. --- src/renderer/draw_fns.rs | 35 ++++++++++++++++++++++- src/renderer/text.rs | 9 +++++- src/renderer/text_test.rs | 58 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 100 insertions(+), 2 deletions(-) diff --git a/src/renderer/draw_fns.rs b/src/renderer/draw_fns.rs index 7abff28..0709723 100644 --- a/src/renderer/draw_fns.rs +++ b/src/renderer/draw_fns.rs @@ -210,6 +210,7 @@ fn resolve_bg_color( cursor_color: Color, selection_color: Color, theme: &ResolvedTheme, + bg_alpha: BgAlpha, ) -> u32 { if is_cursor && cursor_shape == CursorShape::Block { color_u32(cursor_color) @@ -220,7 +221,26 @@ fn resolve_bg_color( } else if in_match { color_u32(theme.search_match) } else { - color_u32(bg) + // Only cells still on the default background become translucent; a cell + // painted by the application keeps its color fully opaque. + color_u32_with_alpha(bg, bg_alpha.for_bg(bg)) + } +} + +/// Window background alpha plus the default background it applies to. +#[derive(Debug, Clone, Copy)] +pub(super) struct BgAlpha { + pub alpha: u8, + pub default_bg: Color, +} + +impl BgAlpha { + pub(super) fn for_bg(&self, bg: Color) -> u8 { + if bg == self.default_bg { + self.alpha + } else { + 0xFF + } } } @@ -235,6 +255,7 @@ pub(super) fn resolve_cell_colors( cursor_color: Color, selection_color: Color, theme: &ResolvedTheme, + bg_alpha: BgAlpha, ) -> (u32, Color) { let (fg, bg) = if cell.reverse { (cell.bg, cell.fg) @@ -260,6 +281,7 @@ pub(super) fn resolve_cell_colors( cursor_color, selection_color, theme, + bg_alpha, ); let fg = if (in_match || is_current_match) && !is_cursor { SEARCH_MATCH_FG @@ -445,6 +467,17 @@ mod tests { assert_eq!(color_u32_with_alpha(c, 0xFF), color_u32(c)); } + #[test] + fn bg_alpha_applies_only_to_the_default_background() { + let default_bg = Color::rgb(0x1e, 0x1e, 0x2e); + let a = BgAlpha { + alpha: 0xCC, + default_bg, + }; + assert_eq!(a.for_bg(default_bg), 0xCC); + assert_eq!(a.for_bg(Color::rgb(0xAA, 0xBB, 0xCC)), 0xFF); + } + #[test] fn dim_color_reduces_brightness() { let c = 0xFF_80_80_80u32; diff --git a/src/renderer/text.rs b/src/renderer/text.rs index 99a0790..5d90d77 100644 --- a/src/renderer/text.rs +++ b/src/renderer/text.rs @@ -250,13 +250,17 @@ impl Renderer { bg_alpha: u8, ) { let grid = pane.grid; + let bg_alpha = BgAlpha { + alpha: bg_alpha, + default_bg: grid.default_bg, + }; // Pre-fill gutter pixels so they match the pane background. fill_pane_background( buf, buf_width, pane.rect, - color_u32_with_alpha(grid.default_bg, bg_alpha), + color_u32_with_alpha(grid.default_bg, bg_alpha.alpha), ); let selection_range = if pane.is_active { @@ -288,6 +292,7 @@ impl Renderer { sb_len, selection_range, row, + bg_alpha, ); } @@ -389,6 +394,7 @@ impl Renderer { sb_len: usize, selection_range: Option<(usize, usize, usize, usize)>, row: usize, + bg_alpha: BgAlpha, ) { let [rx, ry, rw, rh] = pane.rect; let grid = pane.grid; @@ -441,6 +447,7 @@ impl Renderer { grid.cursor_color, grid.selection_color, theme, + bg_alpha, ); self.draw_cell( diff --git a/src/renderer/text_test.rs b/src/renderer/text_test.rs index d8f7929..39a3593 100644 --- a/src/renderer/text_test.rs +++ b/src/renderer/text_test.rs @@ -293,6 +293,64 @@ fn draw_with_partial_opacity_does_not_panic() { assert!(buf.iter().any(|&p| (p >> 24) == bg_alpha)); } +#[test] +fn draw_with_partial_opacity_applies_alpha_inside_the_grid() { + // Regression: opacity used to reach only the pane padding, so the alpha was + // visible at the window edges while every cell stayed opaque. + let mut r = make_renderer(); + let m = r.make_metrics(Physical(16.0)); + let (cols, rows) = m.grid_size_for(800, 600u32.saturating_sub(44)); + let mut grid = make_grid(cols, rows); + // Col 0 keeps the default background; col 1 gets an app-set background. + grid.write_char(' '); + grid.cell_mut(1, 0).bg = Color::rgb(0xAA, 0xBB, 0xCC); + + let pane = make_pane(&grid, &m); + let mut buf = vec![0u32; 800 * 600]; + let theme = default_theme(); + r.draw( + &mut buf, + 800, + 600, + &[pane], + &[], + &InputMode::Insert, + false, + &[("t".to_string(), true, false)], + 0, + 0, + None, + None, + 0.55, + None, + false, + false, + ShellState::Unknown, + None, + &theme, + None, + None, // hovered_url + 0.8, + ); + + let expected = (0.8f32 * 255.0) as u8 as u32; + // Cell (0,0) background pixel: x = PANE_PADDING, y = TAB_BAR_H + PANE_PADDING. + let default_px = buf[26 * 800 + 4]; + assert_eq!( + default_px >> 24, + expected, + "default-background cell must carry the window alpha" + ); + // Cell (1,0) has an explicit background — it stays fully opaque. + let colored_px = buf[26 * 800 + 4 + m.cell_width as usize]; + assert_eq!( + colored_px >> 24, + 0xFF, + "app-set cell background must stay opaque" + ); + assert_eq!(colored_px & 0x00FF_FFFF, 0x00AA_BBCC); +} + #[test] fn draw_tab_bar_renders_without_panic() { let mut r = make_renderer();