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..0709723 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; @@ -203,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) @@ -213,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 + } } } @@ -228,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) @@ -253,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 @@ -429,6 +458,26 @@ 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 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/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..5d90d77 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,21 @@ impl Renderer { m: &FontMetrics, dim_factor: f32, theme: &ResolvedTheme, + 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(grid.default_bg)); + fill_pane_background( + buf, + buf_width, + pane.rect, + color_u32_with_alpha(grid.default_bg, bg_alpha.alpha), + ); let selection_range = if pane.is_active { match mode { @@ -279,6 +292,7 @@ impl Renderer { sb_len, selection_range, row, + bg_alpha, ); } @@ -380,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; @@ -432,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 54a232d..39a3593 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,119 @@ 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_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(); @@ -271,6 +381,7 @@ fn draw_tab_bar_renders_without_panic() { &theme, None, // update_badge: wired in Task 9 None, // hovered_url + 1.0, ); } @@ -304,6 +415,7 @@ fn draw_status_bar_renders_without_panic() { &theme, None, // update_badge: wired in Task 9 None, // hovered_url + 1.0, ); } @@ -335,6 +447,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 +482,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 +508,7 @@ fn draw_status_bar_pane_title_centered() { &theme, None, // update_badge: wired in Task 9 None, // hovered_url + 1.0, ); assert!( @@ -436,6 +551,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 +592,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 +621,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 +684,7 @@ fn draw_with_bell_flash_does_not_panic() { &theme, None, // update_badge: wired in Task 9 None, // hovered_url + 1.0, ); } @@ -597,6 +716,7 @@ fn draw_with_separator_does_not_panic() { &theme, None, // update_badge: wired in Task 9 None, // hovered_url + 1.0, ); } @@ -697,6 +817,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 +989,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 +1085,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 +1316,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 +1351,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 +1531,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 {