diff --git a/README.md b/README.md index 11d6202..abb92fd 100644 --- a/README.md +++ b/README.md @@ -54,6 +54,7 @@ Automated coverage includes inline unit tests, subprocess integration tests for - **Catppuccin Mocha** color theme throughout - **True-color support** — ANSI 8/16, 256-color, and 24-bit RGB - **Multiple tabs** — Cmd+T/W to open/close, Cmd+[/] or Cmd+1–9 to navigate, drag to reorder +- **Native background controls** — `Appearance -> Background...` opens the macOS color panel with an alpha slider for live background color/opacity changes - **Scrollback** — 10,000-line buffer; scroll with mouse wheel, Cmd+Up/Down, Cmd+Home/End - **Clipboard** — Cmd+C copies selection (text), Cmd+V pastes (text or image path); OSC 52 supported - **URL detection** — hold Cmd to underline URLs; Cmd+click opens in browser diff --git a/src/bin/tdiff.rs b/src/bin/tdiff.rs index 499bfcc..8e5b565 100644 --- a/src/bin/tdiff.rs +++ b/src/bin/tdiff.rs @@ -46,9 +46,13 @@ fn strip_ansi(s: &str) -> String { // OSC sequence — terminated by BEL (0x07) or ST (ESC \). chars.next(); // consume ']' while let Some(nc) = chars.next() { - if nc == '\x07' { break; } + if nc == '\x07' { + break; + } if nc == '\x1b' { - if chars.peek() == Some(&'\\') { chars.next(); } + if chars.peek() == Some(&'\\') { + chars.next(); + } break; } } @@ -56,7 +60,9 @@ fn strip_ansi(s: &str) -> String { _ => { // CSI or other ESC sequence — consume until alphabetic final byte. for nc in chars.by_ref() { - if nc.is_ascii_alphabetic() { break; } + if nc.is_ascii_alphabetic() { + break; + } } } } @@ -69,7 +75,11 @@ fn strip_ansi(s: &str) -> String { // ── Syntect helpers ─────────────────────────────────────────────────────────── -fn make_highlighter<'a>(syntax_name: &str, ps: &'a SyntaxSet, ts: &'a ThemeSet) -> HighlightLines<'a> { +fn make_highlighter<'a>( + syntax_name: &str, + ps: &'a SyntaxSet, + ts: &'a ThemeSet, +) -> HighlightLines<'a> { let syntax = ps .find_syntax_by_name(syntax_name) .or_else(|| ps.find_syntax_by_extension(syntax_name)) @@ -86,7 +96,10 @@ fn make_highlighter<'a>(syntax_name: &str, ps: &'a SyntaxSet, ts: &'a ThemeSet) fn parse_file_path(header: &str) -> &str { // Strip leading "a/" or "b/" that git adds let p = header.trim(); - let p = p.strip_prefix("a/").or_else(|| p.strip_prefix("b/")).unwrap_or(p); + let p = p + .strip_prefix("a/") + .or_else(|| p.strip_prefix("b/")) + .unwrap_or(p); // Strip /dev/null (binary / new-file cases) if p.starts_with("/dev/null") { "" } else { p } } @@ -102,11 +115,7 @@ fn syntax_hint(path: &str) -> &str { // ── Per-line highlighter ────────────────────────────────────────────────────── /// Highlights a code line using a running HighlightLines (state advances per call). -fn hl_line<'s>( - line: &'s str, - h: &mut HighlightLines, - ps: &SyntaxSet, -) -> Vec<(Style, String)> { +fn hl_line<'s>(line: &'s str, h: &mut HighlightLines, ps: &SyntaxSet) -> Vec<(Style, String)> { let with_nl = if line.ends_with('\n') { std::borrow::Cow::Borrowed(line) } else { @@ -184,7 +193,10 @@ fn run(out: &mut impl Write) -> io::Result<()> { // New file diff — print separator reset(out)?; fg(out, sfr, sfg, sfb)?; - writeln!(out, "──────────────────────────────────────────────────────")?; + writeln!( + out, + "──────────────────────────────────────────────────────" + )?; reset(out)?; fg(out, or_, og, ob)?; writeln!(out, "{line}")?; @@ -268,13 +280,13 @@ fn run(out: &mut impl Write) -> io::Result<()> { } s } else { - vec![( - Style::default(), - content.to_string(), - )] + vec![(Style::default(), content.to_string())] }; write_code_line(out, " ", OVERLAY0, &spans, (0x1e, 0x1e, 0x2e))?; - } else if line.starts_with("index ") || line.starts_with("new file") || line.starts_with("deleted file") { + } else if line.starts_with("index ") + || line.starts_with("new file") + || line.starts_with("deleted file") + { fg(out, or_, og, ob)?; writeln!(out, "{line}")?; reset(out)?; diff --git a/src/bin/tjson.rs b/src/bin/tjson.rs index 3e78bf8..6676e36 100644 --- a/src/bin/tjson.rs +++ b/src/bin/tjson.rs @@ -16,7 +16,7 @@ use std::io::{self, BufRead, Read, Write}; -use portable_pty::{native_pty_system, CommandBuilder, PtySize}; +use portable_pty::{CommandBuilder, PtySize, native_pty_system}; use syntect::easy::HighlightLines; use syntect::highlighting::{FontStyle, Style, ThemeSet}; use syntect::parsing::SyntaxSet; @@ -33,9 +33,15 @@ fn reset(out: &mut impl Write) -> io::Result<()> { fn write_span(out: &mut impl Write, style: Style, text: &str) -> io::Result<()> { let s = style.foreground; fg(out, s.r, s.g, s.b)?; - if style.font_style.contains(FontStyle::BOLD) { out.write_all(b"\x1b[1m")?; } - if style.font_style.contains(FontStyle::ITALIC) { out.write_all(b"\x1b[3m")?; } - if style.font_style.contains(FontStyle::UNDERLINE) { out.write_all(b"\x1b[4m")?; } + if style.font_style.contains(FontStyle::BOLD) { + out.write_all(b"\x1b[1m")?; + } + if style.font_style.contains(FontStyle::ITALIC) { + out.write_all(b"\x1b[3m")?; + } + if style.font_style.contains(FontStyle::UNDERLINE) { + out.write_all(b"\x1b[4m")?; + } out.write_all(text.as_bytes())?; reset(out) } @@ -81,7 +87,10 @@ fn process_line( if let Ok(pretty) = serde_json::to_string_pretty(&val) { match print_highlighted(out, &pretty, ps, syntax, theme) { Ok(()) => return, - Err(_) => { *drain = true; return; } + Err(_) => { + *drain = true; + return; + } } } } @@ -109,7 +118,9 @@ fn run_filter( Ok(l) => l, Err(_) => break, }; - if drain { continue; } + if drain { + continue; + } process_line(&line, &mut out, ps, syntax, theme, &mut drain); } let _ = out.flush(); @@ -124,16 +135,23 @@ fn run_pty( theme: &syntect::highlighting::Theme, ) { // Inherit terminal dimensions if available. - let cols: u16 = std::env::var("COLUMNS").ok() + let cols: u16 = std::env::var("COLUMNS") + .ok() .and_then(|s| s.parse().ok()) .unwrap_or(220); - let rows: u16 = std::env::var("LINES").ok() + let rows: u16 = std::env::var("LINES") + .ok() .and_then(|s| s.parse().ok()) .unwrap_or(50); let pty_system = native_pty_system(); let pair = pty_system - .openpty(PtySize { rows, cols, pixel_width: 0, pixel_height: 0 }) + .openpty(PtySize { + rows, + cols, + pixel_width: 0, + pixel_height: 0, + }) .expect("openpty failed"); let mut cmd = CommandBuilder::new(&args[0]); @@ -172,10 +190,16 @@ fn run_pty( while let Some(pos) = buf.iter().position(|&b| b == b'\n') { let mut line_bytes = buf.drain(..=pos).collect::>(); // Strip trailing \n and \r\n. - if line_bytes.last() == Some(&b'\n') { line_bytes.pop(); } - if line_bytes.last() == Some(&b'\r') { line_bytes.pop(); } + if line_bytes.last() == Some(&b'\n') { + line_bytes.pop(); + } + if line_bytes.last() == Some(&b'\r') { + line_bytes.pop(); + } - if drain { continue; } + if drain { + continue; + } // Try to interpret as UTF-8 for JSON detection; fall back to raw write. match std::str::from_utf8(&line_bytes) { @@ -193,12 +217,20 @@ fn run_pty( if !buf.is_empty() && !drain { match std::str::from_utf8(&buf) { Ok(line) => process_line(line, &mut out, ps, syntax, theme, &mut drain), - Err(_) => { let _ = out.write_all(&buf); } + Err(_) => { + let _ = out.write_all(&buf); + } } } let exit_code = match child.wait() { - Ok(status) => if status.success() { 0 } else { 1 }, + Ok(status) => { + if status.success() { + 0 + } else { + 1 + } + } Err(_) => 1, }; std::process::exit(exit_code); @@ -234,7 +266,8 @@ mod tests { fn highlighted(json: &str) -> String { let ps = SyntaxSet::load_defaults_newlines(); let ts = ThemeSet::load_defaults(); - let syntax = ps.find_syntax_by_extension("json") + let syntax = ps + .find_syntax_by_extension("json") .unwrap_or_else(|| ps.find_syntax_plain_text()); let theme = ts.themes.values().next().expect("no theme"); let val: serde_json::Value = serde_json::from_str(json).unwrap(); @@ -247,10 +280,10 @@ mod tests { #[test] fn test_json_object_contains_keys() { let out = highlighted(r#"{"level":30,"msg":"hello"}"#); - assert!(out.contains("level"), "key 'level' missing from output"); - assert!(out.contains("msg"), "key 'msg' missing from output"); - assert!(out.contains("hello"), "string value missing from output"); - assert!(out.contains("30"), "number value missing from output"); + assert!(out.contains("level"), "key 'level' missing from output"); + assert!(out.contains("msg"), "key 'msg' missing from output"); + assert!(out.contains("hello"), "string value missing from output"); + assert!(out.contains("30"), "number value missing from output"); } #[test] @@ -263,14 +296,20 @@ mod tests { #[test] fn test_output_has_ansi_escapes() { let out = highlighted(r#"{"x":1}"#); - assert!(out.contains("\x1b["), "expected ANSI escape sequences in output"); + assert!( + out.contains("\x1b["), + "expected ANSI escape sequences in output" + ); } #[test] fn test_pretty_printed_multiline() { let out = highlighted(r#"{"a":1,"b":2}"#); let lines: Vec<&str> = out.lines().collect(); - assert!(lines.len() >= 3, "expected multi-line pretty output, got: {out:?}"); + assert!( + lines.len() >= 3, + "expected multi-line pretty output, got: {out:?}" + ); } #[test] @@ -286,9 +325,12 @@ mod tests { fn test_partial_json_not_parsed() { let line = r#"{"incomplete":"#; let trimmed = line.trim(); - let would_parse = trimmed.starts_with('{') - && serde_json::from_str::(trimmed).is_ok(); - assert!(!would_parse, "partial JSON must fall through to passthrough"); + let would_parse = + trimmed.starts_with('{') && serde_json::from_str::(trimmed).is_ok(); + assert!( + !would_parse, + "partial JSON must fall through to passthrough" + ); } // ── process_line passthrough ────────────────────────────────────────────── @@ -296,7 +338,8 @@ mod tests { fn process_output(line: &str) -> String { let ps = SyntaxSet::load_defaults_newlines(); let ts = ThemeSet::load_defaults(); - let syntax = ps.find_syntax_by_extension("json") + let syntax = ps + .find_syntax_by_extension("json") .unwrap_or_else(|| ps.find_syntax_plain_text()); let theme = ts.themes.values().next().expect("no theme"); let mut buf = Vec::new(); @@ -309,7 +352,10 @@ mod tests { fn test_number_passes_through_unchanged() { let out = process_output("42"); assert!(out.contains("42"), "number must pass through: {out:?}"); - assert!(!out.contains("\x1b["), "number must not be syntax-highlighted"); + assert!( + !out.contains("\x1b["), + "number must not be syntax-highlighted" + ); } #[test] @@ -338,7 +384,10 @@ mod tests { // Looks like JSON but isn't — unquoted keys. let line = "{key: value}"; let out = process_output(line); - assert!(out.contains("{key: value}"), "invalid JSON must pass through: {out:?}"); + assert!( + out.contains("{key: value}"), + "invalid JSON must pass through: {out:?}" + ); } #[test] @@ -346,21 +395,36 @@ mod tests { // serde_json rejects trailing non-whitespace after a valid value. let line = r#"{"a": 1} not json"#; let out = process_output(line); - assert!(out.contains(r#"{"a": 1} not json"#), "JSON with trailing garbage must pass through"); + assert!( + out.contains(r#"{"a": 1} not json"#), + "JSON with trailing garbage must pass through" + ); } #[test] fn test_empty_object_prettified() { let out = process_output("{}"); - assert!(out.contains("{}") || out.contains('{'), "empty object must be prettified"); - assert!(out.contains("\x1b["), "prettified output must have ANSI escapes"); + assert!( + out.contains("{}") || out.contains('{'), + "empty object must be prettified" + ); + assert!( + out.contains("\x1b["), + "prettified output must have ANSI escapes" + ); } #[test] fn test_empty_array_prettified() { let out = process_output("[]"); - assert!(out.contains("[]") || out.contains('['), "empty array must be prettified"); - assert!(out.contains("\x1b["), "prettified output must have ANSI escapes"); + assert!( + out.contains("[]") || out.contains('['), + "empty array must be prettified" + ); + assert!( + out.contains("\x1b["), + "prettified output must have ANSI escapes" + ); } #[test] @@ -368,9 +432,15 @@ mod tests { // process_line calls `line.trim()` before JSON detection, so // " {}" is treated as "{}" and gets prettified. let out = process_output(r#" {"x": 1}"#); - assert!(out.contains("\x1b["), "JSON with leading spaces must be prettified"); + assert!( + out.contains("\x1b["), + "JSON with leading spaces must be prettified" + ); // The raw leading spaces must NOT appear in prettified output. - assert!(!out.starts_with(" {"), "leading spaces must be stripped from prettified output"); + assert!( + !out.starts_with(" {"), + "leading spaces must be stripped from prettified output" + ); } // ── Additional process_line / process_output tests ──────────────────────── @@ -386,7 +456,10 @@ mod tests { fn test_whitespace_only_line_passes_through() { let out = process_output(" "); // Whitespace-only lines cannot start with '{' or '[' after trimming. - assert!(out.contains(" "), "whitespace-only line must pass through unchanged"); + assert!( + out.contains(" "), + "whitespace-only line must pass through unchanged" + ); } #[test] @@ -399,7 +472,10 @@ mod tests { #[test] fn test_deeply_nested_json_prettified() { let out = process_output(r#"{"a":{"b":{"c":{"d":42}}}}"#); - assert!(out.contains("\x1b["), "deeply nested JSON must be prettified"); + assert!( + out.contains("\x1b["), + "deeply nested JSON must be prettified" + ); assert!(out.contains("42"), "leaf value must appear in output"); } @@ -415,20 +491,29 @@ mod tests { fn test_line_starting_with_brace_but_invalid_json_passes_through() { let line = "{not valid json at all"; let out = process_output(line); - assert!(out.contains(line), "invalid JSON starting with '{{' must pass through"); + assert!( + out.contains(line), + "invalid JSON starting with '{{' must pass through" + ); } #[test] fn test_line_starting_with_bracket_but_invalid_json_passes_through() { let line = "[1, 2, broken"; let out = process_output(line); - assert!(out.contains(line), "invalid JSON starting with '[' must pass through"); + assert!( + out.contains(line), + "invalid JSON starting with '[' must pass through" + ); } #[test] fn test_json_with_null_value_prettified() { let out = process_output(r#"{"key":null}"#); - assert!(out.contains("null"), "null value must appear in prettified output"); + assert!( + out.contains("null"), + "null value must appear in prettified output" + ); } #[test] diff --git a/src/config.rs b/src/config.rs index 84792b5..1f58b60 100644 --- a/src/config.rs +++ b/src/config.rs @@ -27,6 +27,26 @@ impl Color { } } +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct BackgroundAppearance { + pub color: Color, + pub alpha: u8, +} + +impl BackgroundAppearance { + pub const fn new(color: Color, alpha: u8) -> Self { + Self { color, alpha } + } + + pub fn alpha_f32(self) -> f32 { + self.alpha as f32 / 255. + } + + pub fn alpha_f64(self) -> f64 { + self.alpha as f64 / 255. + } +} + // Neutral dark theme — matches the screenshot aesthetic // Near-black background, vivid ANSI palette, warm amber yellow, bright green pub const DEFAULT_FG: Color = Color::new(0xc8, 0xc8, 0xc8); // light gray @@ -73,6 +93,8 @@ pub const GHOST_COLOR: Color = Color::new(0x55, 0x55, 0x55); /// Alpha applied to terminal background cells (0xFF = fully opaque). pub const BG_ALPHA: u8 = 0xFF; +pub const DEFAULT_BACKGROUND_APPEARANCE: BackgroundAppearance = + BackgroundAppearance::new(DEFAULT_BG, BG_ALPHA); pub const FONT_SIZE_PT: f32 = 14.0; pub const WINDOW_WIDTH: u32 = 960; @@ -119,6 +141,13 @@ mod tests { assert!(out.r >= 49 && out.r <= 51, "expected ~50, got {}", out.r); } + #[test] + fn background_appearance_alpha_helpers_match_byte_value() { + let appearance = BackgroundAppearance::new(Color::new(1, 2, 3), 128); + assert!((appearance.alpha_f32() - (128.0 / 255.0)).abs() < f32::EPSILON); + assert!((appearance.alpha_f64() - (128.0 / 255.0)).abs() < f64::EPSILON); + } + // ── 256-colour lookup ───────────────────────────────────────────────────── #[test] diff --git a/src/main.rs b/src/main.rs index 955da54..931ed69 100644 --- a/src/main.rs +++ b/src/main.rs @@ -18,7 +18,7 @@ use winit::keyboard::{Key, KeyCode, ModifiersState, NamedKey, PhysicalKey}; use winit::window::{CursorIcon, Window, WindowAttributes, WindowId}; use completion::Engine; -use config::{WINDOW_HEIGHT, WINDOW_WIDTH}; +use config::{BackgroundAppearance, DEFAULT_BACKGROUND_APPEARANCE, WINDOW_HEIGHT, WINDOW_WIDTH}; use renderer::{PaneView, Renderer}; use terminal::Terminal; @@ -27,6 +27,8 @@ enum AppEvent { PtyData { pane_id: u64, data: Vec }, PtyExit { pane_id: u64 }, NewTab, + ShowBackgroundAppearancePanel, + BackgroundAppearanceChanged(BackgroundAppearance), } // ── Split direction ─────────────────────────────────────────────────────────── @@ -387,6 +389,18 @@ struct WgpuShared { surface_format: wgpu::TextureFormat, } +fn choose_surface_alpha_mode(modes: &[wgpu::CompositeAlphaMode]) -> wgpu::CompositeAlphaMode { + [ + wgpu::CompositeAlphaMode::PreMultiplied, + wgpu::CompositeAlphaMode::PostMultiplied, + wgpu::CompositeAlphaMode::Auto, + ] + .into_iter() + .find(|mode| modes.contains(mode)) + .or_else(|| modes.first().copied()) + .unwrap_or(wgpu::CompositeAlphaMode::Auto) +} + // ── TerminalWindow ──────────────────────────────────────────────────────────── struct TerminalWindow { @@ -1024,6 +1038,7 @@ struct App { next_pane_id: u64, proxy: EventLoopProxy, tabbing_id: String, + background: BackgroundAppearance, } impl App { @@ -1035,6 +1050,15 @@ impl App { next_pane_id: 0, proxy, tabbing_id: format!("term-{}", std::process::id()), + background: DEFAULT_BACKGROUND_APPEARANCE, + } + } + + fn apply_background_appearance(&mut self, background: BackgroundAppearance) { + self.background = background; + for tw in self.windows.values_mut() { + tw.renderer.set_background(background); + tw.window.request_redraw(); } } @@ -1148,6 +1172,7 @@ impl App { let mut attrs = WindowAttributes::default() .with_title("term") + .with_transparent(true) .with_inner_size(winit::dpi::LogicalSize::new(WINDOW_WIDTH, WINDOW_HEIGHT)); #[cfg(target_os = "macos")] @@ -1162,6 +1187,7 @@ impl App { // Set NSWindowTabbingModePreferred so tab bar always shows #[cfg(target_os = "macos")] if let Some(ns_view) = ns_view_ptr(&window) { + platform::configure_window_background(ns_view); use objc2::{msg_send, runtime::AnyObject}; unsafe { let view = ns_view as *mut AnyObject; @@ -1193,11 +1219,7 @@ impl App { width: size.width.max(1), height: size.height.max(1), present_mode: wgpu::PresentMode::AutoVsync, - alpha_mode: caps - .alpha_modes - .first() - .copied() - .unwrap_or(wgpu::CompositeAlphaMode::Auto), + alpha_mode: choose_surface_alpha_mode(&caps.alpha_modes), view_formats: vec![], desired_maximum_frame_latency: 2, }; @@ -1209,6 +1231,7 @@ impl App { wgpu.queue.clone(), surface_format, scale, + self.background, ); let (cols, rows) = { @@ -1386,6 +1409,7 @@ impl ApplicationHandler for App { let mut attrs = WindowAttributes::default() .with_title("term") + .with_transparent(true) .with_inner_size(winit::dpi::LogicalSize::new(WINDOW_WIDTH, WINDOW_HEIGHT)); #[cfg(target_os = "macos")] @@ -1441,11 +1465,7 @@ impl ApplicationHandler for App { width: size.width.max(1), height: size.height.max(1), present_mode: wgpu::PresentMode::AutoVsync, - alpha_mode: caps - .alpha_modes - .first() - .copied() - .unwrap_or(wgpu::CompositeAlphaMode::Auto), + alpha_mode: choose_surface_alpha_mode(&caps.alpha_modes), view_formats: vec![], desired_maximum_frame_latency: 2, }; @@ -1454,6 +1474,7 @@ impl ApplicationHandler for App { // Set NSWindowTabbingModePreferred #[cfg(target_os = "macos")] if let Some(ns_view) = ns_view_ptr(&window) { + platform::configure_window_background(ns_view); use objc2::{msg_send, runtime::AnyObject}; unsafe { let view = ns_view as *mut AnyObject; @@ -1471,10 +1492,25 @@ impl ApplicationHandler for App { platform::set_new_tab_callback(move || { let _ = proxy.send_event(AppEvent::NewTab); }); + let proxy = self.proxy.clone(); + platform::set_show_background_panel_callback(move || { + let _ = proxy.send_event(AppEvent::ShowBackgroundAppearancePanel); + }); + let proxy = self.proxy.clone(); + platform::set_background_changed_callback(move |background| { + let _ = proxy.send_event(AppEvent::BackgroundAppearanceChanged(background)); + }); + platform::install_background_menu_item(); } let scale = window.scale_factor(); - let renderer = Renderer::new(device.clone(), queue.clone(), surface_format, scale); + let renderer = Renderer::new( + device.clone(), + queue.clone(), + surface_format, + scale, + self.background, + ); let (cols, rows) = { let cw = renderer.cell_width; @@ -1577,7 +1613,8 @@ impl ApplicationHandler for App { if let Some(tw) = self.windows.get_mut(&window_id) { let fmt = tw.renderer.surface_format; let device = tw.renderer.device.clone(); - tw.renderer = Renderer::new(device, queue, fmt, scale_factor); + tw.renderer = + Renderer::new(device, queue, fmt, scale_factor, self.background); } } } @@ -1989,6 +2026,13 @@ impl ApplicationHandler for App { AppEvent::NewTab => { self.open_tab(event_loop); } + AppEvent::ShowBackgroundAppearancePanel => { + #[cfg(target_os = "macos")] + platform::show_background_panel(self.background); + } + AppEvent::BackgroundAppearanceChanged(background) => { + self.apply_background_appearance(background); + } } } } @@ -2881,4 +2925,20 @@ mod tests { files.zshrc ); } + + #[test] + fn choose_surface_alpha_mode_prefers_premultiplied() { + let chosen = choose_surface_alpha_mode(&[ + wgpu::CompositeAlphaMode::Opaque, + wgpu::CompositeAlphaMode::PreMultiplied, + wgpu::CompositeAlphaMode::Auto, + ]); + assert_eq!(chosen, wgpu::CompositeAlphaMode::PreMultiplied); + } + + #[test] + fn choose_surface_alpha_mode_falls_back_to_first_supported_mode() { + let chosen = choose_surface_alpha_mode(&[wgpu::CompositeAlphaMode::Opaque]); + assert_eq!(chosen, wgpu::CompositeAlphaMode::Opaque); + } } diff --git a/src/platform.rs b/src/platform.rs index d692823..24bc19f 100644 --- a/src/platform.rs +++ b/src/platform.rs @@ -1,18 +1,27 @@ /// Platform-specific helpers. - -// ── New-tab callback (drives the native + button) ───────────────────────────── - +// ── Native callbacks + menu actions ──────────────────────────────────────────── +use crate::config::{BackgroundAppearance, Color}; use std::sync::OnceLock; static NEW_TAB_CB: OnceLock> = OnceLock::new(); +static SHOW_BACKGROUND_PANEL_CB: OnceLock> = OnceLock::new(); +static BACKGROUND_CHANGED_CB: OnceLock> = + OnceLock::new(); /// Register the function to call when the native tab bar "+" button is clicked. -/// Must be called before `setup_add_tab_button`. Can only be set once. +/// Must be called before `setup_add_tab_button`. Can only be set once. pub fn set_new_tab_callback(f: impl Fn() + Send + Sync + 'static) { let _ = NEW_TAB_CB.set(Box::new(f)); } -// ObjC method: handles the `newWindowForTab:` action sent by the native "+" button. +pub fn set_show_background_panel_callback(f: impl Fn() + Send + Sync + 'static) { + let _ = SHOW_BACKGROUND_PANEL_CB.set(Box::new(f)); +} + +pub fn set_background_changed_callback(f: impl Fn(BackgroundAppearance) + Send + Sync + 'static) { + let _ = BACKGROUND_CHANGED_CB.set(Box::new(f)); +} + extern "C" fn new_tab_action( _this: *mut objc2::runtime::AnyObject, _cmd: objc2::runtime::Sel, @@ -23,14 +32,33 @@ extern "C" fn new_tab_action( } } -fn tab_target_class() -> Option<&'static objc2::runtime::AnyClass> { +extern "C" fn show_background_panel_action( + _this: *mut objc2::runtime::AnyObject, + _cmd: objc2::runtime::Sel, + _sender: *mut objc2::runtime::AnyObject, +) { + if let Some(cb) = SHOW_BACKGROUND_PANEL_CB.get() { + cb(); + } +} + +extern "C" fn background_changed_action( + _this: *mut objc2::runtime::AnyObject, + _cmd: objc2::runtime::Sel, + sender: *mut objc2::runtime::AnyObject, +) { + if let (Some(cb), Some(background)) = (BACKGROUND_CHANGED_CB.get(), panel_background(sender)) { + cb(background); + } +} + +fn action_target_class() -> Option<&'static objc2::runtime::AnyClass> { use objc2::runtime::{AnyClass, AnyObject, ClassBuilder}; use objc2::sel; static CLASS: OnceLock> = OnceLock::new(); *CLASS.get_or_init(|| { - // Subclass NSResponder so we can be inserted into the responder chain. let superclass = objc2::class!(NSResponder); - match ClassBuilder::new("TermTabTarget", superclass) { + match ClassBuilder::new("TermActionTarget", superclass) { Some(mut builder) => { unsafe { builder.add_method( @@ -38,23 +66,141 @@ fn tab_target_class() -> Option<&'static objc2::runtime::AnyClass> { new_tab_action as extern "C" fn(*mut AnyObject, objc2::runtime::Sel, *mut AnyObject), ); + builder.add_method( + sel!(openBackgroundAppearancePanel:), + show_background_panel_action + as extern "C" fn(*mut AnyObject, objc2::runtime::Sel, *mut AnyObject), + ); + builder.add_method( + sel!(changeColor:), + background_changed_action + as extern "C" fn(*mut AnyObject, objc2::runtime::Sel, *mut AnyObject), + ); } Some(builder.register()) } - None => AnyClass::get("TermTabTarget"), + None => AnyClass::get("TermActionTarget"), } }) } -/// Insert a TermTabTarget NSResponder into this window's responder chain so that +#[cfg(target_os = "macos")] +fn make_action_target() -> Option<*mut objc2::runtime::AnyObject> { + use objc2::{msg_send, runtime::AnyObject}; + let cls = action_target_class()?; + unsafe { + let obj: *mut AnyObject = msg_send![cls, alloc]; + let obj: *mut AnyObject = msg_send![obj, init]; + if obj.is_null() { + return None; + } + let _: *mut AnyObject = msg_send![obj, retain]; + Some(obj) + } +} + +#[cfg(target_os = "macos")] +fn shared_action_target() -> Option<*mut objc2::runtime::AnyObject> { + static TARGET: OnceLock> = OnceLock::new(); + TARGET + .get_or_init(|| make_action_target().map(|obj| obj as usize)) + .map(|ptr| ptr as *mut objc2::runtime::AnyObject) +} + +#[cfg(target_os = "macos")] +fn ns_string(text: &str) -> Option<*mut objc2::runtime::AnyObject> { + use objc2::{class, msg_send, runtime::AnyObject}; + use std::ffi::CString; + let c_text = CString::new(text).ok()?; + unsafe { + let s: *mut AnyObject = msg_send![class!(NSString), stringWithUTF8String: c_text.as_ptr()]; + (!s.is_null()).then_some(s) + } +} + +#[cfg(target_os = "macos")] +fn ns_string_value(obj: *mut objc2::runtime::AnyObject) -> Option { + use objc2::{msg_send, runtime::AnyObject}; + use std::ffi::CStr; + use std::os::raw::c_char; + if obj.is_null() { + return None; + } + unsafe { + let s: *mut AnyObject = msg_send![obj, title]; + if s.is_null() { + return None; + } + let c_str: *const c_char = msg_send![s, UTF8String]; + if c_str.is_null() { + return None; + } + Some(CStr::from_ptr(c_str).to_string_lossy().into_owned()) + } +} + +#[cfg(target_os = "macos")] +fn find_menu_item( + menu: *mut objc2::runtime::AnyObject, + title: &str, +) -> Option<*mut objc2::runtime::AnyObject> { + use objc2::{msg_send, runtime::AnyObject}; + if menu.is_null() { + return None; + } + unsafe { + let count: usize = msg_send![menu, numberOfItems]; + for i in 0..count { + let item: *mut AnyObject = msg_send![menu, itemAtIndex: i]; + if ns_string_value(item).as_deref() == Some(title) { + return Some(item); + } + } + } + None +} + +#[cfg(target_os = "macos")] +fn panel_background(sender: *mut objc2::runtime::AnyObject) -> Option { + use objc2::{class, msg_send, runtime::AnyObject}; + unsafe { + if sender.is_null() { + return None; + } + let color: *mut AnyObject = msg_send![sender, color]; + if color.is_null() { + return None; + } + let srgb_space: *mut AnyObject = msg_send![class!(NSColorSpace), sRGBColorSpace]; + let converted: *mut AnyObject = msg_send![color, colorUsingColorSpace: srgb_space]; + let color = if converted.is_null() { + color + } else { + converted + }; + let r: f64 = msg_send![color, redComponent]; + let g: f64 = msg_send![color, greenComponent]; + let b: f64 = msg_send![color, blueComponent]; + let a: f64 = msg_send![color, alphaComponent]; + Some(BackgroundAppearance::new( + Color::new( + (r.clamp(0.0, 1.0) * 255.0).round() as u8, + (g.clamp(0.0, 1.0) * 255.0).round() as u8, + (b.clamp(0.0, 1.0) * 255.0).round() as u8, + ), + (a.clamp(0.0, 1.0) * 255.0).round() as u8, + )) + } +} + +/// Insert a TermActionTarget NSResponder into this window's responder chain so that /// the native tab bar "+" button's `newWindowForTab:` action reaches our callback. /// Called once per window after the window is fully set up. #[cfg(target_os = "macos")] pub fn setup_add_tab_button(ns_view: *mut std::ffi::c_void) { use objc2::{msg_send, runtime::AnyObject}; - let cls = match tab_target_class() { - Some(c) => c, - None => return, + let Some(obj) = make_action_target() else { + return; }; unsafe { let view = ns_view as *mut AnyObject; @@ -62,20 +208,134 @@ pub fn setup_add_tab_button(ns_view: *mut std::ffi::c_void) { if win.is_null() { return; } - // Allocate a fresh NSResponder for this window and retain it permanently. - let obj: *mut AnyObject = msg_send![cls, alloc]; - let obj: *mut AnyObject = msg_send![obj, init]; - if obj.is_null() { - return; - } - let _: *mut AnyObject = msg_send![obj, retain]; - // Splice into the chain: win → obj → old_next_responder. let old_next: *mut AnyObject = msg_send![win, nextResponder]; let _: () = msg_send![obj, setNextResponder: old_next]; let _: () = msg_send![win, setNextResponder: obj]; } } +#[cfg(target_os = "macos")] +pub fn configure_window_background(ns_view: *mut std::ffi::c_void) { + use objc2::{class, msg_send, runtime::AnyObject}; + unsafe { + let view = ns_view as *mut AnyObject; + let win: *mut AnyObject = msg_send![view, window]; + if win.is_null() { + return; + } + let _: () = msg_send![win, setOpaque: false]; + let clear: *mut AnyObject = msg_send![class!(NSColor), clearColor]; + if !clear.is_null() { + let _: () = msg_send![win, setBackgroundColor: clear]; + } + } +} + +#[cfg(target_os = "macos")] +pub fn install_background_menu_item() { + use objc2::{class, msg_send, runtime::AnyObject, sel}; + unsafe { + let app: *mut AnyObject = msg_send![class!(NSApplication), sharedApplication]; + if app.is_null() { + return; + } + let main_menu: *mut AnyObject = msg_send![app, mainMenu]; + if main_menu.is_null() { + return; + } + + let appearance_title = match ns_string("Appearance") { + Some(s) => s, + None => return, + }; + let background_title = match ns_string("Background...") { + Some(s) => s, + None => return, + }; + let empty = match ns_string("") { + Some(s) => s, + None => return, + }; + + let appearance_item = if let Some(existing) = find_menu_item(main_menu, "Appearance") { + existing + } else { + let item: *mut AnyObject = msg_send![class!(NSMenuItem), alloc]; + let item: *mut AnyObject = msg_send![ + item, + initWithTitle: appearance_title + action: std::ptr::null::() + keyEquivalent: empty + ]; + if item.is_null() { + return; + } + let submenu: *mut AnyObject = msg_send![class!(NSMenu), alloc]; + let submenu: *mut AnyObject = msg_send![submenu, initWithTitle: appearance_title]; + if submenu.is_null() { + return; + } + let _: () = msg_send![item, setSubmenu: submenu]; + let _: () = msg_send![main_menu, addItem: item]; + item + }; + + let submenu: *mut AnyObject = msg_send![appearance_item, submenu]; + if submenu.is_null() || find_menu_item(submenu, "Background...").is_some() { + return; + } + + let item: *mut AnyObject = msg_send![class!(NSMenuItem), alloc]; + let item: *mut AnyObject = msg_send![ + item, + initWithTitle: background_title + action: sel!(openBackgroundAppearancePanel:) + keyEquivalent: empty + ]; + if item.is_null() { + return; + } + let Some(target) = shared_action_target() else { + return; + }; + let _: () = msg_send![item, setTarget: target]; + let _: () = msg_send![submenu, addItem: item]; + } +} + +#[cfg(target_os = "macos")] +pub fn show_background_panel(background: BackgroundAppearance) { + use objc2::{class, msg_send, runtime::AnyObject, sel}; + unsafe { + let app: *mut AnyObject = msg_send![class!(NSApplication), sharedApplication]; + if app.is_null() { + return; + } + let panel: *mut AnyObject = msg_send![class!(NSColorPanel), sharedColorPanel]; + if panel.is_null() { + return; + } + let Some(target) = shared_action_target() else { + return; + }; + let color: *mut AnyObject = msg_send![ + class!(NSColor), + colorWithSRGBRed: background.color.r as f64 / 255.0 + green: background.color.g as f64 / 255.0 + blue: background.color.b as f64 / 255.0 + alpha: background.alpha as f64 / 255.0 + ]; + if !color.is_null() { + let _: () = msg_send![panel, setColor: color]; + } + let _: () = msg_send![panel, setShowsAlpha: true]; + let _: () = msg_send![panel, setTarget: target]; + let _: () = msg_send![panel, setAction: sel!(changeColor:)]; + let _: () = msg_send![panel, orderFront: std::ptr::null::()]; + let _: () = msg_send![app, activateIgnoringOtherApps: true]; + } +} + // ── Clipboard ────────────────────────────────────────────────────────────────── /// Read the macOS clipboard and return PNG-encoded image bytes, or `None` if @@ -167,7 +427,11 @@ pub fn clipboard_text() -> Option { if c_str.is_null() { return None; } - Some(std::ffi::CStr::from_ptr(c_str).to_string_lossy().into_owned()) + Some( + std::ffi::CStr::from_ptr(c_str) + .to_string_lossy() + .into_owned(), + ) } } @@ -176,14 +440,19 @@ pub fn clipboard_text() -> Option { /// Safe to call immediately after window creation. /// The new tab is always appended at the end of the tab bar. #[cfg(target_os = "macos")] -pub fn add_window_as_tab(existing_ns_view: *mut std::ffi::c_void, new_ns_view: *mut std::ffi::c_void) { +pub fn add_window_as_tab( + existing_ns_view: *mut std::ffi::c_void, + new_ns_view: *mut std::ffi::c_void, +) { use objc2::{msg_send, runtime::AnyObject}; unsafe { let ev = existing_ns_view as *mut AnyObject; - let nv = new_ns_view as *mut AnyObject; + let nv = new_ns_view as *mut AnyObject; let ew: *mut AnyObject = msg_send![ev, window]; let nw: *mut AnyObject = msg_send![nv, window]; - if ew.is_null() || nw.is_null() { return; } + if ew.is_null() || nw.is_null() { + return; + } // Find the last window in the tab group so we append after it, // rather than inserting after whichever window we happened to pick. @@ -210,7 +479,7 @@ pub fn select_next_tab(ns_view: *mut std::ffi::c_void) { use objc2::{msg_send, runtime::AnyObject}; unsafe { let view: *mut AnyObject = ns_view as *mut AnyObject; - let win: *mut AnyObject = msg_send![view, window]; + let win: *mut AnyObject = msg_send![view, window]; if !win.is_null() { let _: () = msg_send![win, selectNextTab: std::ptr::null::()]; } @@ -223,7 +492,7 @@ pub fn select_prev_tab(ns_view: *mut std::ffi::c_void) { use objc2::{msg_send, runtime::AnyObject}; unsafe { let view: *mut AnyObject = ns_view as *mut AnyObject; - let win: *mut AnyObject = msg_send![view, window]; + let win: *mut AnyObject = msg_send![view, window]; if !win.is_null() { let _: () = msg_send![win, selectPreviousTab: std::ptr::null::()]; } @@ -238,12 +507,18 @@ pub fn select_tab_at_index(ns_view: *mut std::ffi::c_void, n: usize) { unsafe { let view: *mut AnyObject = ns_view as *mut AnyObject; let win: *mut AnyObject = msg_send![view, window]; - if win.is_null() { return; } + if win.is_null() { + return; + } let tabs: *mut AnyObject = msg_send![win, tabbedWindows]; - if tabs.is_null() { return; } + if tabs.is_null() { + return; + } let count: usize = msg_send![tabs, count]; let idx = n.saturating_sub(1); - if idx >= count { return; } + if idx >= count { + return; + } let target: *mut AnyObject = msg_send![tabs, objectAtIndex: idx]; if !target.is_null() { let _: () = msg_send![target, makeKeyAndOrderFront: std::ptr::null::()]; @@ -259,11 +534,17 @@ pub fn tab_index_and_count(ns_view: *mut std::ffi::c_void) -> (usize, usize) { unsafe { let view: *mut AnyObject = ns_view as *mut AnyObject; let win: *mut AnyObject = msg_send![view, window]; - if win.is_null() { return (1, 1); } + if win.is_null() { + return (1, 1); + } let tabs: *mut AnyObject = msg_send![win, tabbedWindows]; - if tabs.is_null() { return (1, 1); } + if tabs.is_null() { + return (1, 1); + } let count: usize = msg_send![tabs, count]; - if count == 0 { return (1, 1); } + if count == 0 { + return (1, 1); + } for i in 0..count { let w: *mut AnyObject = msg_send![tabs, objectAtIndex: i]; // Compare by pointer identity (both are NSWindow*). @@ -282,18 +563,26 @@ pub fn tab_index_and_count(ns_view: *mut std::ffi::c_void) -> (usize, usize) { pub fn set_tab_title(ns_view: *mut std::ffi::c_void, title: &str) { use objc2::{msg_send, runtime::AnyObject}; use std::ffi::CString; - let Ok(c_title) = CString::new(title) else { return }; + let Ok(c_title) = CString::new(title) else { + return; + }; unsafe { let view: *mut AnyObject = ns_view as *mut AnyObject; - let win: *mut AnyObject = msg_send![view, window]; - if win.is_null() { return; } + let win: *mut AnyObject = msg_send![view, window]; + if win.is_null() { + return; + } let tab: *mut AnyObject = msg_send![win, tab]; - if tab.is_null() { return; } + if tab.is_null() { + return; + } let ns_str: *mut AnyObject = msg_send![ objc2::class!(NSString), stringWithUTF8String: c_title.as_ptr() ]; - if ns_str.is_null() { return; } + if ns_str.is_null() { + return; + } let _: () = msg_send![tab, setTitle: ns_str]; } } diff --git a/src/renderer.rs b/src/renderer.rs index f5565f5..f9bfabf 100644 --- a/src/renderer.rs +++ b/src/renderer.rs @@ -247,6 +247,23 @@ fn c2fa(c: Color, a: f32) -> [f32; 4] { [c.r as f32 / 255., c.g as f32 / 255., c.b as f32 / 255., a] } +fn default_background_fill(appearance: BackgroundAppearance, cell_bg: Color) -> [f32; 4] { + if cell_bg == DEFAULT_BG { + c2fa(appearance.color, appearance.alpha_f32()) + } else { + c2f(cell_bg) + } +} + +fn background_clear_color(appearance: BackgroundAppearance) -> wgpu::Color { + wgpu::Color { + r: appearance.color.r as f64 / 255., + g: appearance.color.g as f64 / 255., + b: appearance.color.b as f64 / 255., + a: appearance.alpha_f64(), + } +} + pub fn rgb_f(r: u8, g: u8, b: u8) -> [f32; 4] { [r as f32 / 255., g as f32 / 255., b as f32 / 255., 1.] } @@ -539,6 +556,7 @@ pub struct Renderer { pub baseline: i32, font_size: f32, pub surface_format: wgpu::TextureFormat, + background: BackgroundAppearance, } impl Renderer { @@ -547,6 +565,7 @@ impl Renderer { queue: Arc, surface_format: wgpu::TextureFormat, scale_factor: f64, + background: BackgroundAppearance, ) -> Self { // ── Font ────────────────────────────────────────────────────────────── let font_size = (FONT_SIZE_PT * scale_factor as f32).round(); @@ -710,9 +729,14 @@ impl Renderer { baseline: ascent, font_size, surface_format, + background, } } + pub fn set_background(&mut self, background: BackgroundAppearance) { + self.background = background; + } + // ── Pipeline constructors ───────────────────────────────────────────────── fn make_rect_pipeline( @@ -1142,12 +1166,7 @@ impl Renderer { let bg = if selected { sel_bg } else { - let a = if bg_color == DEFAULT_BG { - BG_ALPHA as f32 / 255. - } else { - 1. - }; - c2fa(bg_color, a) + default_background_fill(self.background, bg_color) }; push_rect(&mut self.bg_rects, px, py, cw, ch, bg); @@ -1326,12 +1345,7 @@ impl Renderer { view, resolve_target: None, ops: wgpu::Operations { - load: wgpu::LoadOp::Clear(wgpu::Color { - r: DEFAULT_BG.r as f64 / 255., - g: DEFAULT_BG.g as f64 / 255., - b: DEFAULT_BG.b as f64 / 255., - a: 1., - }), + load: wgpu::LoadOp::Clear(background_clear_color(self.background)), store: wgpu::StoreOp::Store, }, })], @@ -1910,6 +1924,24 @@ mod tests { assert_eq!(third, vec![GlyphOp::Glyph(9)]); } + #[test] + fn default_background_fill_uses_custom_color_and_alpha() { + let appearance = BackgroundAppearance::new(Color::new(10, 20, 30), 128); + assert_eq!( + default_background_fill(appearance, DEFAULT_BG), + [10.0 / 255.0, 20.0 / 255.0, 30.0 / 255.0, 128.0 / 255.0] + ); + } + + #[test] + fn default_background_fill_keeps_explicit_cell_background_opaque() { + let appearance = BackgroundAppearance::new(Color::new(10, 20, 30), 64); + assert_eq!( + default_background_fill(appearance, Color::new(40, 50, 60)), + [40.0 / 255.0, 50.0 / 255.0, 60.0 / 255.0, 1.0] + ); + } + // ── emit_underline_rects ────────────────────────────────────────────────── const RED: [f32; 4] = [1., 0., 0., 1.];