From 7b769b14772f09f07507d014256d146a44fe94d3 Mon Sep 17 00:00:00 2001 From: Codex Bot Date: Sun, 25 Oct 2026 15:01:32 +0800 Subject: [PATCH] fix: restore comfortable tab height with native control alignment --- Cargo.lock | 3 ++ Cargo.toml | 5 ++++ docs/ui-workspace-audit.md | 41 ++++++++++++++++++++++++---- src/app.rs | 6 ++++ src/main.rs | 2 ++ src/native_chrome.rs | 56 ++++++++++++++++++++++++++++++++++++++ src/software_renderer.rs | 11 +++++--- src/tabs.rs | 15 ++++++---- 8 files changed, 125 insertions(+), 14 deletions(-) create mode 100644 src/native_chrome.rs diff --git a/Cargo.lock b/Cargo.lock index 4afd4f8..5e54432 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1652,6 +1652,9 @@ dependencies = [ "core", "cosmic-text", "frontend", + "objc2 0.5.2", + "objc2-app-kit", + "objc2-foundation 0.2.2", "serde", "serde_json", "softbuffer", diff --git a/Cargo.toml b/Cargo.toml index a9506a9..4b722ee 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -19,6 +19,11 @@ tiny_http = "0.12" unicode-segmentation = "1.12" winit = "0.30.13" +[target.'cfg(target_os = "macos")'.dependencies] +objc2 = "0.5.2" +objc2-app-kit = { version = "0.2.2", default-features = false, features = ["NSButton", "NSView", "NSWindow"] } +objc2-foundation = { version = "0.2.2", default-features = false, features = ["NSGeometry"] } + [profile.release] lto = "thin" codegen-units = 1 diff --git a/docs/ui-workspace-audit.md b/docs/ui-workspace-audit.md index 1121797..9f6a571 100644 --- a/docs/ui-workspace-audit.md +++ b/docs/ui-workspace-audit.md @@ -4,9 +4,9 @@ This audit follows the full conversation, including later corrections. Implement | Requirement | Implementation | Evidence / limits | | --- | --- | --- | -| One top row with native macOS controls | Transparent/full-size native title bar; reserved control space; 28 px tab strip centered with AppKit traffic lights | Actual Retina macOS windows viewed; native close/minimize/fullscreen controls retained | +| One top row with native macOS controls | Transparent/full-size native title bar; reserved control space; 40 px tab strip; native AppKit controls repositioned to its y=20 center | Actual Retina macOS windows viewed; native close/minimize/fullscreen controls retained | | Continuous left/top chrome | One neutral background; 56 px left rail; inset content panel | Actual main and detached windows viewed; no Tools label or rail launchers | -| Compact symmetric round tabs, softer active state | 88–208 px width; 24 px pill with equal 2 px vertical insets; 13 px labels and matching 18 px icon/close slots; no hard active stroke | Native settled UI viewed; hover and selected use the same bounds | +| Compact symmetric round tabs, softer active state | 88–208 px width; 32 px pill with equal 4 px vertical insets; 13 px labels and matching 18 px icon/close slots; no hard active stroke | Native settled UI viewed; hover and selected use the same bounds | | + and startup default page | One startup request guarded against duplicate resume; + release-inside; Cmd/Ctrl+T | Immediately after restart: pages().count = 1. Two native Cmd+T actions produced three tabs | | New-tab motion, filled and unfilled | 220 ms cubic ease; insertion grows from the previous trailing edge; full-strip geometry and + interpolate; removed tabs shrink without remaining interactive | Real native new/close/fill operations completed; settled compression/overflow viewed. No frame-by-frame recording or Chrome timing comparison | | Overflow and scrolling | Separate + reservation; no arrow buttons; logical-pixel wheel/trackpad scrolling and clipped offscreen tabs; invisible drag edge dwell retained | Native overflow reached; + in overflow increased VM count 7 → 8. Dragging out of + kept count at 7 | @@ -130,9 +130,9 @@ scrolling follows input directly, while insertion, closure and automatic reveal use the 220 ms transition. OS momentum remains ordinary subsequent wheel input. No touchscreen hardware verification is claimed. -The 28 px row centers tabs at y=14, matching the retained AppKit controls in the -native macOS window. Pills are 24 px high, with 2 px top/bottom margins and 13 px -labels. A 1 × 12 px divider separates adjacent inactive tabs; it is hidden beside +The initial 28 px row was too shallow. The revised 40 px row centers tabs at +y=20 and repositions the retained native AppKit controls onto the same center +line. Pills are 32 px high, with 4 px top/bottom margins and 13 px labels. A 1 × 12 px divider separates adjacent inactive tabs; it is hidden beside an active or hovered tab. Hover and selection retain the same pill geometry. Manual native checks covered startup, + and repeated Cmd+T, dense overflow, @@ -146,3 +146,34 @@ Formatting, all-target offline Clippy with warnings denied, release app bundling and whitespace checks passed. Existing geometry test fixtures were updated for pixel offsets and the denser layout; they were type-checked by Clippy but no cargo test, automated smoke script or Docker task was run. + + +### Comfortable row height with native alignment — 2026-10-25 + +The current Chromium [layout constants](https://github.com/chromium/chromium/blob/main/chrome/browser/ui/layout_constants.cc) +define a 35 px tab (34 + 1 px toolbar overlap) and 6 px strip padding, totaling +41 px. Tessera uses a 40 px row with a symmetric 32 px capsule, retaining its +88–208 px width range, separators, gesture scrolling and insertion motion. +Only the in-app web view is available for agent-owned browser access, so this +is a source/reference-image comparison, not a live measurement of Chrome. + +A macOS-only adapter obtains the winit-owned content NSView and positions the +three real standard window buttons at the row's center. It preserves their +native sizes, horizontal positions and actions, and reapplies alignment before +drawing after native layout changes. Fullscreen floating controls stay under +AppKit management. Native bindings were already cached transitively; direct +macOS-only dependencies now declare this use explicitly. + +Manual resizing followed by repeated tab creation exposed a renderer panic: +rounding at large absolute x positions could invert a rounded rectangle's +clamp bounds while a tab grew from almost zero width. Rounded hit coverage now +clamps in local coordinates with a radius bounded by half the rectangle size. + +Final native checks passed for 18 consecutive Cmd+T insertions, dense overflow, +wheel scrolling, five consecutive Cmd+W closures and native window zoom. The +final process stayed running without stderr errors. Initial native screenshots +showed the traffic lights, tab capsules and + centered at the same y position. +Subsequent computer-control captures can display macOS's capture badge over the +traffic lights. Fullscreen floating controls and mixed-DPI moves were not tested. +Offline all-target Clippy with warnings denied, formatting, release app bundling +and git whitespace checks passed; no cargo test or automated smoke script ran. diff --git a/src/app.rs b/src/app.rs index 5f1b2d3..61bdc79 100644 --- a/src/app.rs +++ b/src/app.rs @@ -1071,6 +1071,8 @@ impl TesseraApp { .with_fullsize_content_view(true) }; let window = Arc::new(event_loop.create_window(attributes)?); + #[cfg(target_os = "macos")] + crate::native_chrome::align_controls(&window); let context = Context::new(window.clone()).map_err(|error| anyhow::anyhow!("{error}"))?; let surface = Surface::new(&context, window.clone()).map_err(|error| anyhow::anyhow!("{error}"))?; @@ -1111,6 +1113,10 @@ impl TesseraApp { } fn redraw(&mut self) { + #[cfg(target_os = "macos")] + if let Some(window) = self.window.as_ref() { + crate::native_chrome::align_controls(window); + } match self.try_redraw() { Ok(()) => { if self.window.is_some() && self.surface.is_some() { diff --git a/src/main.rs b/src/main.rs index bae186a..943c3e9 100644 --- a/src/main.rs +++ b/src/main.rs @@ -15,6 +15,8 @@ mod geometry; mod io_tasks; mod layout; mod mount; +#[cfg(target_os = "macos")] +mod native_chrome; mod native_visual; mod object_list; mod presentation; diff --git a/src/native_chrome.rs b/src/native_chrome.rs new file mode 100644 index 0000000..793f90c --- /dev/null +++ b/src/native_chrome.rs @@ -0,0 +1,56 @@ +//! Keep AppKit's real window buttons aligned with the custom tab strip. +use objc2_app_kit::{NSView, NSWindowButton, NSWindowStyleMask}; +use objc2_foundation::NSPoint; +use winit::{ + raw_window_handle::{HasWindowHandle, RawWindowHandle}, + window::Window, +}; + +/// Called only on winit's main thread, after native creation and before drawing. +/// AppKit can restore its default button positions when laying out the title bar. +pub fn align_controls(window: &Window) { + let Ok(handle) = window.window_handle() else { + return; + }; + let RawWindowHandle::AppKit(handle) = handle.as_raw() else { + return; + }; + // SAFETY: winit owns this NSView for the lifetime of the borrowed Window. + // All AppKit access is on the event-loop thread; no native pointers escape. + unsafe { + let view = &*handle.ns_view.as_ptr().cast::(); + let Some(native) = view.window() else { + return; + }; + // Fullscreen traffic lights belong to AppKit's separate floating toolbar. + if native.styleMask().contains(NSWindowStyleMask::FullScreen) { + return; + } + let bounds = view.bounds(); + let center = f64::from(crate::tabs::HEIGHT) / 2.0; + let content_y = if view.isFlipped() { + bounds.origin.y + center + } else { + bounds.origin.y + bounds.size.height - center + }; + for kind in [ + NSWindowButton::NSWindowCloseButton, + NSWindowButton::NSWindowMiniaturizeButton, + NSWindowButton::NSWindowZoomButton, + ] { + let Some(button) = native.standardWindowButton(kind) else { + continue; + }; + let Some(parent) = button.superview() else { + continue; + }; + let target = parent.convertPoint_fromView(NSPoint::new(0.0, content_y), Some(view)); + let frame = button.frame(); + let y = target.y - frame.size.height / 2.0; + // Preserve native button size, horizontal spacing and behavior. + if (frame.origin.y - y).abs() > 0.01 { + button.setFrameOrigin(NSPoint::new(frame.origin.x, y)); + } + } + } +} diff --git a/src/software_renderer.rs b/src/software_renderer.rs index 75a35f0..14cc4d3 100644 --- a/src/software_renderer.rs +++ b/src/software_renderer.rs @@ -203,10 +203,13 @@ fn rounded_rect_contains(rect: Rect, radius: f32, x: f32, y: f32) -> bool { return x >= rect.x && x <= rect.x + rect.width && y >= rect.y && y <= rect.y + rect.height; } - let closest_x = x.clamp(rect.x + radius, rect.x + rect.width - radius); - let closest_y = y.clamp(rect.y + radius, rect.y + rect.height - radius); - let dx = x - closest_x; - let dy = y - closest_y; + // Clamp in local coordinates: adding a large origin to a shrinking tab's + // half-width can otherwise round the lower bound above the upper bound. + let radius = radius.min(rect.width / 2.0).min(rect.height / 2.0); + let local_x = x - rect.x; + let local_y = y - rect.y; + let dx = local_x - local_x.clamp(radius, rect.width - radius); + let dy = local_y - local_y.clamp(radius, rect.height - radius); dx * dx + dy * dy <= radius * radius } diff --git a/src/tabs.rs b/src/tabs.rs index 28d7e29..15f5900 100644 --- a/src/tabs.rs +++ b/src/tabs.rs @@ -9,8 +9,8 @@ use serde::{Deserialize, Serialize}; mod motion; pub use motion::{Motion, Visual}; -// AppKit traffic lights are centered 14 logical pixels below the top edge. -pub const HEIGHT: f32 = 28.0; +// Native traffic lights and all tab controls share this row's center line. +pub const HEIGHT: f32 = 40.0; pub const INSET: f32 = 8.0; const NEW_TAB_SPACE: f32 = 44.0; const MIN_WIDTH: f32 = 88.0; @@ -129,7 +129,7 @@ fn items(start: Option, tabs: &[Tab], width: f32) -> Vec> { .collect() } fn pill_rect(rect: Rect) -> Rect { - Rect::new(rect.x + 2.0, 2.0, (rect.width - 4.0).max(0.0), HEIGHT - 4.0) + Rect::new(rect.x + 2.0, 4.0, (rect.width - 4.0).max(0.0), HEIGHT - 8.0) } fn close_rect(rect: Rect) -> Rect { Rect::new( @@ -236,13 +236,18 @@ pub fn draw_insertion( pub fn plus_rect(start: Option, tabs: &[Tab], width: f32) -> Rect { if tabs.len() as f32 * tab_width(tabs, width) > viewport(width).width { - return Rect::new(width - 34.0, 2.0, 24.0, HEIGHT - 4.0); + return Rect::new(width - 34.0, (HEIGHT - 28.0) / 2.0, 24.0, 28.0); } let end = items(start, tabs, width) .iter() .map(|item| item.rect.x + item.rect.width) .fold(LEADING, f32::max); - Rect::new((end + 4.0).min(width - 34.0), 2.0, 24.0, HEIGHT - 4.0) + Rect::new( + (end + 4.0).min(width - 34.0), + (HEIGHT - 28.0) / 2.0, + 24.0, + 28.0, + ) } fn tab_style() -> TextStyle {