From e718d8335160b3c8384422524f10c55ea8d98318 Mon Sep 17 00:00:00 2001 From: cartermp Date: Thu, 26 Mar 2026 10:25:00 -0700 Subject: [PATCH] rendering and scrolling better --- Cargo.lock | 1 + Cargo.toml | 3 + src/config.rs | 14 ++++- src/main.rs | 147 +++++++++++++++++++++++++++++++++++++++--------- src/platform.rs | 92 ++++++++++++++++++++++++++++++ src/renderer.rs | 52 ++++++++++------- 6 files changed, 262 insertions(+), 47 deletions(-) create mode 100644 src/platform.rs diff --git a/Cargo.lock b/Cargo.lock index cd0ab83..11134b2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1572,6 +1572,7 @@ name = "term" version = "0.1.0" dependencies = [ "fontdue", + "objc2 0.5.2", "portable-pty", "softbuffer", "syntect", diff --git a/Cargo.toml b/Cargo.toml index 770637b..ac5710a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -15,6 +15,9 @@ path = "src/bin/tcat.rs" [dependencies] winit = "0.30" + +[target.'cfg(target_os = "macos")'.dependencies] +objc2 = "0.5" softbuffer = "0.4" vte = "0.13" portable-pty = "0.8" diff --git a/src/config.rs b/src/config.rs index 0c011c1..0601c66 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1,4 +1,4 @@ -#[derive(Clone, Copy, Debug)] +#[derive(Clone, Copy, Debug, PartialEq)] pub struct Color { pub r: u8, pub g: u8, @@ -14,6 +14,14 @@ impl Color { ((self.r as u32) << 16) | ((self.g as u32) << 8) | (self.b as u32) } + /// Pack as 0xFF_RR_GG_BB — fully opaque pixel for the softbuffer framebuffer. + /// On macOS the framebuffer format is BGRA; the top byte is the alpha channel. + /// Using 0xFF makes the pixel opaque; 0x00 (from `to_u32`) makes it transparent, + /// letting the NSVisualEffectView vibrancy show through. + pub fn to_u32_opaque(self) -> u32 { + 0xFF_00_00_00 | self.to_u32() + } + pub fn blend(self, fg: Color, alpha: u8) -> Color { let a = alpha as u32; let ia = 255 - a; @@ -69,6 +77,10 @@ pub fn ansi_256_color(index: u8) -> Color { // Ghost text — dim gray, clearly below regular text brightness pub const GHOST_COLOR: Color = Color::new(0x55, 0x55, 0x55); +/// Alpha applied to terminal background cells (0x00 = pure vibrancy, 0xFF = opaque). +/// At 0xCC (80 %) the blur shows through while text remains fully readable. +pub const BG_ALPHA: u8 = 0xCC; + pub const FONT_SIZE_PT: f32 = 14.0; pub const WINDOW_WIDTH: u32 = 960; pub const WINDOW_HEIGHT: u32 = 640; diff --git a/src/main.rs b/src/main.rs index 0d6c2b7..9d7c467 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,5 +1,6 @@ mod completion; mod config; +mod platform; mod renderer; mod terminal; @@ -37,15 +38,19 @@ struct TabDrag { // ── Selection ───────────────────────────────────────────────────────────────── -#[derive(Clone, Copy)] +#[derive(Clone, Copy, PartialEq)] struct Selection { - start: (usize, usize), // (row, col) in viewport coordinates - end: (usize, usize), + // grid_row = visual_row as i64 - viewport_offset as i64. + // Negative = rows in scrollback above the live grid; 0 = top of live grid. + // Stable across viewport changes: scrolling shifts visual_row and viewport_offset + // by the same amount, so grid_row for a given cell never changes. + start: (i64, usize), // (grid_row, col) + end: (i64, usize), } impl Selection { - /// Returns (r0, c0, r1, c1) with r0≤r1 (and c0≤c1 when r0==r1). - fn normalized(self) -> (usize, usize, usize, usize) { + /// Returns (r0, c0, r1, c1) with r0≤r1 (and c0≤c1 when r0==r1), in grid_row space. + fn normalized(self) -> (i64, usize, i64, usize) { let (r0, c0) = self.start; let (r1, c1) = self.end; if r0 < r1 || (r0 == r1 && c0 <= c1) { @@ -54,6 +59,17 @@ impl Selection { (r1, c1, r0, c0) } } + + /// Convert to viewport-relative row range for rendering. + /// `viewport_offset` is the current scroll position. + /// Returns (r0, c0, r1, c1) in visual (viewport) row coordinates. + fn to_viewport(self, viewport_offset: usize) -> (usize, usize, usize, usize) { + let vo = viewport_offset as i64; + let (gr0, c0, gr1, c1) = self.normalized(); + let r0 = (gr0 + vo).max(0) as usize; + let r1 = (gr1 + vo).max(0) as usize; + (r0, c0, r1, c1) + } } // ── Tab ─────────────────────────────────────────────────────────────────────── @@ -162,9 +178,10 @@ struct App { tab_drag: Option, // Selection - sel_anchor: Option<(usize, usize)>, + sel_anchor: Option<(i64, usize)>, // grid_row coords, stable across scroll selection: Option, selecting: bool, + sel_scroll: i32, // non-zero while dragging outside terminal bounds (auto-scroll) // Cursor blink cursor_visible: bool, @@ -191,6 +208,7 @@ impl App { sel_anchor: None, selection: None, selecting: false, + sel_scroll: 0, cursor_visible: true, last_blink: Instant::now(), engine: Engine::new(), @@ -350,6 +368,14 @@ impl App { Some((row.min(rows.saturating_sub(1)), col.min(cols.saturating_sub(1)))) } + /// Like `pixel_to_cell` but returns grid-relative row coordinates stable across scrolling. + /// `grid_row = visual_row - viewport_offset`; negative values are scrollback rows. + fn pixel_to_grid_cell(&self, mx: f64, my: f64) -> Option<(i64, usize)> { + let (vrow, col) = self.pixel_to_cell(mx, my)?; + let vo = self.active().terminal.state.viewport_offset as i64; + Some((vrow as i64 - vo, col)) + } + /// Set the cursor icon based on whether Cmd is held and a URL is under the pointer. fn update_cursor_icon(&self) { let icon = if self.modifiers.super_key() { @@ -378,7 +404,8 @@ impl App { None => return String::new(), }; let state = &self.active().terminal.state; - let (r0, c0, r1, c1) = sel.normalized(); + // Convert from grid-relative to viewport-relative rows for visual_cell(). + let (r0, c0, r1, c1) = sel.to_viewport(state.viewport_offset); let mut result = String::new(); for row in r0..=r1 { let col_start = if row == r0 { c0 } else { 0 }; @@ -391,12 +418,7 @@ impl App { if row > r0 { result.push('\n'); } - // Trim trailing spaces on wrapped lines, keep them on the last line - if row < r1 { - result.push_str(line.trim_end()); - } else { - result.push_str(line.trim_end()); - } + result.push_str(line.trim_end()); } result } @@ -694,7 +716,9 @@ impl App { let state_ptr: *const _ = &self.tabs[ai].terminal.state; let ghost_owned: Option = self.tabs[ai].ghost_text.clone(); let show_cur = self.cursor_visible; - let sel = self.selection.map(|s| s.normalized()); + let sel = self.selection.map(|s| { + s.to_viewport(self.tabs[ai].terminal.state.viewport_offset) + }); // Compute URL underlines (only when Cmd is held so they don't render every frame) let url_underlines: Vec<(usize, usize, usize)> = if self.modifiers.super_key() { let (vis_cols, vis_rows) = self.term_size(); @@ -809,6 +833,7 @@ impl ApplicationHandler for App { self.context = Some(context); self.surface = Some(surface); self.renderer = Some(renderer); + platform::setup_vibrancy(&window); self.window = Some(window); } @@ -868,15 +893,46 @@ impl ApplicationHandler for App { let drag_active = self.tab_drag.as_ref().map(|d| d.active).unwrap_or(false); if self.selecting { - if let (Some(anchor), Some(cell)) = - (self.sel_anchor, self.pixel_to_cell(position.x, position.y)) - { - self.selection = if cell != anchor { - Some(Selection { start: anchor, end: cell }) - } else { - None - }; + if let Some(anchor) = self.sel_anchor { + // pixel_to_grid_cell returns None outside the terminal area; + // clamp to the nearest edge row so the selection can extend + // past the top/bottom while the user drags. + let cell = self.pixel_to_grid_cell(position.x, position.y) + .or_else(|| { + let r = self.renderer.as_ref()?; + let (vis_cols, vis_rows) = self.term_size(); + let vo = self.active().terminal.state.viewport_offset as i64; + let col = (position.x.max(0.0) as usize / r.cell_width) + .min(vis_cols.saturating_sub(1)); + if position.y < r.tab_bar_height as f64 { + Some((-vo, col)) // visual row 0 + } else { + Some((vis_rows as i64 - 1 - vo, col)) // last visible row + } + }); + if let Some(c) = cell { + self.selection = if c != anchor { + Some(Selection { start: anchor, end: c }) + } else { + None + }; + } } + // Auto-scroll when the cursor is dragged outside the terminal area. + let tby = self.renderer.as_ref().map(|r| r.tab_bar_height as f64).unwrap_or(0.0); + let (_, vis_rows) = self.term_size(); + let ch = self.renderer.as_ref().map(|r| r.cell_height as f64).unwrap_or(1.0); + let term_bottom = tby + vis_rows as f64 * ch; + // Positive sel_scroll → scroll_viewport(positive) → viewport_offset + // increases → older content revealed at top (scroll up). + // Negative → scroll down (toward live output). + self.sel_scroll = if position.y < tby { + 1 // cursor above terminal: scroll up toward older content + } else if position.y >= term_bottom { + -1 // cursor below terminal: scroll down toward live output + } else { + 0 + }; if let Some(w) = &self.window { w.request_redraw(); } @@ -962,7 +1018,7 @@ impl ApplicationHandler for App { if !opened { self.tab_drag = None; self.selection = None; - self.sel_anchor = self.pixel_to_cell(mx, my); + self.sel_anchor = self.pixel_to_grid_cell(mx, my); self.selecting = true; } if let Some(w) = &self.window { @@ -984,6 +1040,7 @@ impl ApplicationHandler for App { } } self.selecting = false; + self.sel_scroll = 0; if self.selection.is_none() { self.sel_anchor = None; } @@ -1015,16 +1072,54 @@ impl ApplicationHandler for App { } fn about_to_wait(&mut self, event_loop: &ActiveEventLoop) { - const PERIOD: Duration = Duration::from_millis(530); + const BLINK_PERIOD: Duration = Duration::from_millis(530); + const SCROLL_PERIOD: Duration = Duration::from_millis(50); let now = Instant::now(); - if now.duration_since(self.last_blink) >= PERIOD { + + // Cursor blink + if now.duration_since(self.last_blink) >= BLINK_PERIOD { self.cursor_visible = !self.cursor_visible; self.last_blink = now; if let Some(w) = &self.window { w.request_redraw(); } } - event_loop.set_control_flow(ControlFlow::WaitUntil(self.last_blink + PERIOD)); + + // Auto-scroll while selection drag extends beyond the terminal area + if self.selecting && self.sel_scroll != 0 { + let dir = self.sel_scroll; + self.active_mut().terminal.state.scroll_viewport(dir); + // Extend selection endpoint to the edge row that's now scrolled into view + if let Some(anchor) = self.sel_anchor { + let (_, vis_rows) = self.term_size(); + let vo = self.active().terminal.state.viewport_offset as i64; + let (mx, _) = self.cursor_pos; + let col = self.renderer.as_ref() + .map(|r| (mx.max(0.0) as usize / r.cell_width) + .min(self.active().terminal.state.cols.saturating_sub(1))) + .unwrap_or(0); + // dir > 0 = scrolled up (older content) → endpoint at top of viewport + // dir < 0 = scrolled down (newer content) → endpoint at bottom + let edge_row = if dir > 0 { -vo } else { vis_rows as i64 - 1 - vo }; + let c = (edge_row, col); + self.selection = if c != anchor { + Some(Selection { start: anchor, end: c }) + } else { + None + }; + } + if let Some(w) = &self.window { + w.request_redraw(); + } + } + + let next_blink = self.last_blink + BLINK_PERIOD; + let deadline = if self.selecting && self.sel_scroll != 0 { + now + SCROLL_PERIOD + } else { + next_blink + }; + event_loop.set_control_flow(ControlFlow::WaitUntil(deadline.min(next_blink))); } fn user_event(&mut self, event_loop: &ActiveEventLoop, event: AppEvent) { diff --git a/src/platform.rs b/src/platform.rs new file mode 100644 index 0000000..3b3bb10 --- /dev/null +++ b/src/platform.rs @@ -0,0 +1,92 @@ +/// Platform-specific window setup. +/// +/// On macOS this wires up NSVisualEffectView (vibrancy/blur) so that terminal +/// background pixels — which are rendered with alpha=0 — show the blurred +/// desktop through them. Everything else (glyphs, tab bar, cursor) is +/// rendered with alpha=0xFF and appears fully opaque. +/// +/// On other platforms this is a no-op. + +#[cfg(target_os = "macos")] +pub fn setup_vibrancy(window: &winit::window::Window) { + use objc2::{class, msg_send, runtime::AnyObject}; + use winit::raw_window_handle::{HasWindowHandle, RawWindowHandle}; + + // Get the NSView from winit's raw window handle + let ns_view = match window.window_handle().unwrap().as_raw() { + RawWindowHandle::AppKit(h) => h.ns_view.as_ptr() as *mut AnyObject, + _ => return, + }; + + // Window size in points (logical coordinates — NSRect uses points, not pixels) + let scale = window.scale_factor(); + let phys = window.inner_size(); + let w = phys.width as f64 / scale; + let h = phys.height as f64 / scale; + + unsafe { + let ns_window: *mut AnyObject = msg_send![ns_view, window]; + if ns_window.is_null() { + return; + } + + // Make the window itself non-opaque with a clear background so + // alpha=0 pixels show the desktop through them. + let _: () = msg_send![ns_window, setOpaque: false]; + let clear: *mut AnyObject = msg_send![class!(NSColor), clearColor]; + let _: () = msg_send![ns_window, setBackgroundColor: clear]; + + // Make the content view's CALayer non-opaque so its alpha=0 pixels + // are actually transparent in compositing (not filled with black). + let _: () = msg_send![ns_view, setWantsLayer: true]; + let layer: *mut AnyObject = msg_send![ns_view, layer]; + if !layer.is_null() { + let _: () = msg_send![layer, setOpaque: false]; + let clear2: *mut AnyObject = msg_send![class!(NSColor), clearColor]; + let cg_color: *mut AnyObject = msg_send![clear2, CGColor]; + let _: () = msg_send![layer, setBackgroundColor: cg_color]; + } + + // ── Insert NSVisualEffectView as a sibling BEHIND ns_view ───────────── + // + // We deliberately do NOT call setContentView: because that would fire + // viewDidMoveToWindow: nil on WinitView, clearing winit's internal state + // and causing a panic in mouse_moved. + // + // Instead we insert VEV into ns_view's superview (NSThemeFrame) at a + // lower z-position. When ns_view's layer renders alpha=0 pixels for + // terminal background cells, those transparent holes reveal the VEV + // blur beneath via Core Animation's standard alpha compositing. + + // ns_view's superview inside NSWindow is NSThemeFrame (the internal + // window chrome view that also parents the title bar). + let frame_view: *mut AnyObject = msg_send![ns_view, superview]; + if frame_view.is_null() { + return; + } + + let vev: *mut AnyObject = msg_send![class!(NSVisualEffectView), new]; + + // NSRect as [f64; 4] = [origin.x, origin.y, size.width, size.height]. + // [f64; 4] has the same memory layout as NSRect and implements Encode, + // so it satisfies msg_send!'s type requirements without needing CGRect. + let rect: [f64; 4] = [0.0, 0.0, w, h]; + let _: () = msg_send![vev, setFrame: rect]; + + // NSViewWidthSizable(2) | NSViewHeightSizable(16) = 18 + let _: () = msg_send![vev, setAutoresizingMask: 18usize]; + // NSVisualEffectMaterial.underPageBackground = 21 — dark adaptive blur + let _: () = msg_send![vev, setMaterial: 21usize]; + // NSVisualEffectBlendingMode.behindWindow = 0 + let _: () = msg_send![vev, setBlendingMode: 0usize]; + // NSVisualEffectState.active = 1 — always-on blur + let _: () = msg_send![vev, setState: 1usize]; + + // addSubview:positioned:relativeTo: with NSWindowBelow(-1) places vev + // directly beneath ns_view in the frame_view subview stack. + let _: () = msg_send![frame_view, addSubview: vev positioned: -1i64 relativeTo: ns_view]; + } +} + +#[cfg(not(target_os = "macos"))] +pub fn setup_vibrancy(_window: &winit::window::Window) {} diff --git a/src/renderer.rs b/src/renderer.rs index 5f1b94a..c8a1f9e 100644 --- a/src/renderer.rs +++ b/src/renderer.rs @@ -150,7 +150,7 @@ impl Renderer { g: ((ex >> 8) & 0xff) as u8, b: (ex & 0xff) as u8, }; - buf[idx] = bg.blend(fg, alpha).to_u32(); + buf[idx] = bg.blend(fg, alpha).to_u32_opaque(); } } } @@ -398,11 +398,11 @@ impl Renderer { let ch = self.cell_height; // Neutral dark palette — mirrors terminal background aesthetic - let bar_bg = Color::new(0x14, 0x14, 0x14).to_u32(); // slightly darker than bg - let outline_col = Color::new(0x58, 0x58, 0x58).to_u32(); // medium gray — active outline - let hover_bg = Color::new(0x26, 0x26, 0x26).to_u32(); // subtle hover fill - let sep_col = Color::new(0x2e, 0x2e, 0x2e).to_u32(); // dim separator - let bottom_col = Color::new(0x2e, 0x2e, 0x2e).to_u32(); // bottom rule + let bar_bg = Color::new(0x14, 0x14, 0x14).to_u32_opaque(); // slightly darker than bg + let outline_col = Color::new(0x58, 0x58, 0x58).to_u32_opaque(); // medium gray — active outline + let hover_bg = Color::new(0x26, 0x26, 0x26).to_u32_opaque(); // subtle hover fill + let sep_col = Color::new(0x2e, 0x2e, 0x2e).to_u32_opaque(); // dim separator + let bottom_col = Color::new(0x2e, 0x2e, 0x2e).to_u32_opaque(); // bottom rule let fg_active = DEFAULT_FG; let fg_inactive = Color::new(0x66, 0x66, 0x66); // dim gray let fg_shortcut = Color::new(0x3a, 0x3a, 0x3a); // very dim @@ -471,7 +471,7 @@ impl Renderer { } } // Dim dashed-ish outline as drop-target hint - let ghost_col = Color::new(0x38, 0x38, 0x38).to_u32(); + let ghost_col = Color::new(0x38, 0x38, 0x38).to_u32_opaque(); if pill_w > 2 && pill_h > 2 { Self::fill_rounded(buf, bw, bh, pill_x, pad_v, pill_w, pill_h, 5, ghost_col); Self::fill_rounded(buf, bw, bh, pill_x + 1, pad_v + 1, pill_w - 2, pill_h - 2, 4, bar_bg); @@ -555,8 +555,8 @@ impl Renderer { let pill_w = tab_w.saturating_sub(8); // Bright lifted outline with a slightly lighter fill - let lifted_outline = Color::new(0xa0, 0xa0, 0xa0).to_u32(); - let lifted_bg = Color::new(0x20, 0x20, 0x20).to_u32(); + let lifted_outline = Color::new(0xa0, 0xa0, 0xa0).to_u32_opaque(); + let lifted_bg = Color::new(0x20, 0x20, 0x20).to_u32_opaque(); if pill_w > 2 && pill_h > 2 { Self::fill_rounded(buf, bw, bh, pill_x, pad_v, pill_w, pill_h, 5, lifted_outline); Self::fill_rounded(buf, bw, bh, pill_x + 1, pad_v + 1, pill_w - 2, pill_h - 2, 4, lifted_bg); @@ -583,9 +583,9 @@ impl Renderer { // ── + button (circle outline + cross) ──────────────────────────────── let plus_hover = hover == Some(tabs.len()); let plus_col = if plus_hover { - Color::new(0x88, 0x88, 0x88).to_u32() // hovered + Color::new(0x88, 0x88, 0x88).to_u32_opaque() // hovered } else { - Color::new(0x44, 0x44, 0x44).to_u32() // normal + Color::new(0x44, 0x44, 0x44).to_u32_opaque() // normal }; let plus_cx = bw.saturating_sub(plus_area / 2); let plus_cy = tby / 2; @@ -625,7 +625,11 @@ impl Renderer { drag: Option<(usize, usize, f64)>, url_underlines: &[(usize, usize, usize)], ) { - buf.fill(DEFAULT_BG.to_u32()); + // Start fully transparent. Cells with DEFAULT_BG will be filled with + // BG_ALPHA opacity; everything else (tab bar, glyphs, cursor…) is + // fully opaque. On macOS this lets the NSVisualEffectView blur show + // through in background areas. + buf.fill(0); // ── Tab bar ─────────────────────────────────────────────────────────── self.draw_tab_bar(buf, bw, bh, tabs, active_tab, hover, drag); @@ -638,7 +642,7 @@ impl Renderer { let vis_cols = (bw / cw).min(state.cols); // ── 1. Terminal grid ────────────────────────────────────────────────── - let sel_bg = Color::new(0x26, 0x4a, 0x7a).to_u32(); // muted blue selection + let sel_bg = Color::new(0x26, 0x4a, 0x7a).to_u32_opaque(); // muted blue selection (opaque) for row in 0..vis_rows { // Per-row: detect tcat gutter so it's excluded from selection highlight. @@ -668,7 +672,15 @@ impl Renderer { let px = col * cw; let py = tby + row * ch; - let bg32 = if selected { sel_bg } else { bg.to_u32() }; + // DEFAULT_BG cells are semi-transparent (vibrancy shows through). + // Any other background (custom colour, selection) is fully opaque. + let bg32 = if selected { + sel_bg + } else if bg == DEFAULT_BG { + ((BG_ALPHA as u32) << 24) | DEFAULT_BG.to_u32() + } else { + bg.to_u32_opaque() + }; for dy in 0..ch { let y = py + dy; if y >= bh { @@ -686,7 +698,7 @@ impl Renderer { if c != ' ' && c != '\0' { // Block elements are drawn as direct pixel fills — fontdue // can't rasterize them correctly. - if !Self::draw_block_char(buf, bw, bh, px, py, cw, ch, c, fg.to_u32()) { + if !Self::draw_block_char(buf, bw, bh, px, py, cw, ch, c, fg.to_u32_opaque()) { self.blit(buf, bw, bh, px, py, c, fg); } } @@ -695,7 +707,7 @@ impl Renderer { // ── 2. URL underlines (drawn when Cmd held) ─────────────────────────── if !url_underlines.is_empty() { - let u_col = Color::new(0x58, 0x9a, 0xdd).to_u32(); // muted blue + let u_col = Color::new(0x58, 0x9a, 0xdd).to_u32_opaque(); // muted blue for &(row, c0, c1) in url_underlines { if row >= vis_rows { continue; @@ -736,7 +748,7 @@ impl Renderer { { let px = state.cursor_col * cw; let py = tby + state.cursor_row * ch; - let c32 = CURSOR_COLOR.to_u32(); + let c32 = CURSOR_COLOR.to_u32_opaque(); for dy in 0..ch { let y = py + dy; if y >= bh { @@ -761,11 +773,11 @@ impl Renderer { let thumb_y = tby + (view_top * (term_h - thumb_h)) / total.max(1); let bar_x = bw.saturating_sub(3); - let track_col = Color::new(0x2a, 0x2a, 0x2a).to_u32(); + let track_col = Color::new(0x2a, 0x2a, 0x2a).to_u32_opaque(); let thumb_col = if state.is_scrolled_back() { - Color::new(0x66, 0x66, 0x66).to_u32() + Color::new(0x66, 0x66, 0x66).to_u32_opaque() } else { - Color::new(0x44, 0x44, 0x44).to_u32() + Color::new(0x44, 0x44, 0x44).to_u32_opaque() }; for y in tby..bh { let color = if y >= thumb_y && y < thumb_y + thumb_h { -- 2.51.2