From 61f6e036198634a28ef6116ea5cf3ec5d8cd638e Mon Sep 17 00:00:00 2001 From: Owen McGirr Date: Mon, 14 Sep 2026 15:57:25 +0100 Subject: [PATCH] Match macOS scan menu titles to action tiles MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Align menu title strips to the tile grid with rounded dark backgrounds and centred semibold text. - Reuse native title views and skip unchanged frames to avoid scan-driven redraws. - Preserve other prompts and Windows presentation; test scaled title geometry. 🤖 Auto-generated --- src-tauri/Cargo.lock | 2 + src-tauri/Cargo.toml | 1 + src-tauri/src/scan_host.rs | 178 ++++++++++++++++++++++++++++++ src-tauri/src/scanning_runtime.rs | 11 +- 4 files changed, 188 insertions(+), 4 deletions(-) diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 8492bd68..319350ee 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -2725,6 +2725,7 @@ dependencies = [ "bitflags 2.13.1", "objc2", "objc2-core-foundation", + "objc2-core-graphics", "objc2-foundation", ] @@ -4002,6 +4003,7 @@ dependencies = [ "objc2-app-kit", "objc2-core-graphics", "objc2-foundation", + "objc2-quartz-core", "rand 0.8.7", "reqwest", "serde", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index b4c204bd..582d2331 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -40,6 +40,7 @@ core-foundation = "0.10" core-graphics = { version = "0.25", features = ["highsierra"] } libc = "0.2" objc2 = "0.6.4" +objc2-quartz-core = { version = "0.3.2", default-features = false, features = ["CALayer", "objc2-core-foundation", "objc2-core-graphics"] } objc2-app-kit = { version = "0.3.2", features = ["NSBitmapImageRep", "NSColor", "NSControl", "NSEvent", "NSGraphics", "NSImage", "NSImageRep", "NSImageView", "NSPanel", "NSTextField", "NSFont", "NSResponder", "NSScreen", "NSView", "NSWindow", "NSWorkspace", "NSRunningApplication", "objc2-core-foundation"] } objc2-core-graphics = { version = "0.3.2", default-features = false, features = ["CGEventSource", "CGEventTypes"] } objc2-foundation = { version = "0.3.2", features = ["NSArray", "NSGeometry", "NSHost", "NSNotification", "NSObject", "NSOperation", "NSString", "NSThread", "NSURL", "block2"] } diff --git a/src-tauri/src/scan_host.rs b/src-tauri/src/scan_host.rs index eadc879a..7d74b90e 100644 --- a/src-tauri/src/scan_host.rs +++ b/src-tauri/src/scan_host.rs @@ -134,12 +134,19 @@ mod platform { pub struct Host { panels: Vec>, last_rects: Vec, + last_title: Option<(String, Rect, f64)>, + title: Option<( + Retained, + Retained, + )>, } impl Host { pub fn new() -> Result { Ok(Self { panels: vec![], last_rects: vec![], + title: None, + last_title: None, }) } pub fn render(&mut self, rects: &[crate::scanning::PaintedRect]) -> Result<(), String> { @@ -180,9 +187,83 @@ mod platform { self.last_rects = rects.to_vec(); Ok(()) } + pub fn menu_title(&mut self, text: &str, rect: Rect, scale: f64) -> Result<(), String> { + use objc2_app_kit::{ + NSFont, NSFontWeightSemibold, NSTextAlignment, NSTextField, NSView, + }; + use objc2_foundation::NSString; + if self + .last_title + .as_ref() + .is_some_and(|(old_text, old_rect, old_scale)| { + old_text == text && *old_rect == rect && *old_scale == scale + }) + { + return Ok(()); + } + let mtm = MainThreadMarker::new().ok_or("Menu title requires the main thread.")?; + self.render(&[crate::scanning::PaintedRect { + rect, + color: [30, 35, 46], + opacity: 0, + role: crate::scanning::VisualRole::Accent, + }])?; + let bounds = NSRect::new(NSPoint::new(0.0, 0.0), NSSize::new(rect.width, rect.height)); + if self.title.is_none() { + let view = NSView::initWithFrame(NSView::alloc(mtm), bounds); + view.setWantsLayer(true); + let label = NSTextField::wrappingLabelWithString(&NSString::from_str(text), mtm); + label.setTextColor(Some(&NSColor::whiteColor())); + label.setAlignment(NSTextAlignment::Center); + view.addSubview(&label); + self.title = Some((view, label)); + } + let (view, label) = self.title.as_ref().unwrap(); + view.setFrame(bounds); + let layer = view + .layer() + .ok_or("Menu title background is unavailable.")?; + layer.setBackgroundColor(Some( + &NSColor::colorWithSRGBRed_green_blue_alpha( + 30.0 / 255.0, + 35.0 / 255.0, + 46.0 / 255.0, + 1.0, + ) + .CGColor(), + )); + layer.setCornerRadius(10.0 * scale); + let value = NSString::from_str(text); + if label.stringValue() != value { + label.setStringValue(&value); + } + label.setFont(Some(&NSFont::systemFontOfSize_weight( + 18.0 * scale, + unsafe { NSFontWeightSemibold }, + ))); + let width = (rect.width - 24.0 * scale).max(1.0); + let measured = label + .cell() + .ok_or("Menu title text is unavailable.")? + .cellSizeForBounds(NSRect::new( + NSPoint::new(0.0, 0.0), + NSSize::new(width, rect.height), + )); + let height = measured.height.min(rect.height); + label.setFrame(NSRect::new( + NSPoint::new(12.0 * scale, (rect.height - height) / 2.0), + NSSize::new(width, height), + )); + if self.panels[0].contentView().as_deref() != Some(view.as_ref()) { + self.panels[0].setContentView(Some(view)); + } + self.last_title = Some((text.to_owned(), rect, scale)); + Ok(()) + } pub fn prompt(&mut self, text: &str, rect: Rect, scale: f64) -> Result<(), String> { use objc2_app_kit::{NSFont, NSTextField}; use objc2_foundation::NSString; + self.last_title = None; let mtm = MainThreadMarker::new().ok_or("Prompt requires the main thread.")?; self.render(&[crate::scanning::PaintedRect { rect, @@ -243,6 +324,7 @@ mod platform { Ok(()) } pub fn hide(&mut self) { + self.last_title = None; self.last_rects.clear(); for panel in &self.panels { panel.orderOut(None); @@ -360,3 +442,99 @@ pub fn close_foreground_window() -> Result<(), String> { } Ok(()) } + +impl Host { + pub fn label( + &mut self, + label: &crate::scanning::FrameLabel, + menu_title: Option, + ) -> Result<(), String> { + #[cfg(target_os = "macos")] + if let Some(MenuTitle { rect, scale }) = menu_title { + return self.menu_title(&label.text, rect, scale); + } + #[cfg(not(target_os = "macos"))] + if let Some(MenuTitle { rect, scale }) = menu_title { + let _ = (rect, scale); + } + self.prompt(&label.text, label.rect, label.scale) + } +} + +#[derive(Clone, Copy)] +pub struct MenuTitle { + rect: Rect, + scale: f64, +} + +pub fn menu_title_geometry( + label: &crate::scanning::FrameLabel, + tiles: &[crate::scanning::FrameTile], +) -> Option { + let first = tiles.first()?; + let left = tiles + .iter() + .map(|tile| tile.rect.x) + .fold(f64::INFINITY, f64::min); + let right = tiles + .iter() + .map(|tile| tile.rect.x + tile.rect.width) + .fold(f64::NEG_INFINITY, f64::max); + Some(MenuTitle { + rect: Rect { + x: left, + width: right - left, + ..label.rect + }, + scale: first.scale, + }) +} + +#[cfg(test)] +mod title_tests { + use super::*; + use crate::scan_menu::{Kind, Menu}; + + #[test] + fn titles_follow_tile_edges_without_changing_header_spacing() { + for screen in [ + Rect { + x: 0.0, + y: 0.0, + width: 320.0, + height: 240.0, + }, + Rect { + x: -2048.0, + y: -100.0, + width: 2048.0, + height: 1109.0, + }, + ] { + for units in [1.0, 2.0] { + let mut menu = Menu::new(Kind::Actions, 500); + for paused in [false, true] { + menu.suspended = paused; + let frame = menu.frame((0, 0), screen, units); + let label = frame.label.as_ref().unwrap(); + let MenuTitle { rect, scale } = + menu_title_geometry(label, &frame.tiles).unwrap(); + assert_eq!(rect.x, frame.tiles[0].rect.x); + let last = &frame.tiles[2].rect; + assert!((rect.x + rect.width - last.x - last.width).abs() < 0.001); + assert_eq!(rect.y, label.rect.y); + assert_eq!(rect.height, label.rect.height); + assert!( + (frame.tiles[0].rect.y - rect.y - rect.height - 8.0 * scale).abs() < 0.001 + ); + assert!(rect.x >= screen.x && rect.x + rect.width <= screen.x + screen.width); + assert!(rect.y >= screen.y && rect.y + rect.height <= screen.y + screen.height); + if paused { + assert_eq!(label.text, "Select to resume"); + } + assert!(menu_title_geometry(label, &[]).is_none()); + } + } + } + } +} diff --git a/src-tauri/src/scanning_runtime.rs b/src-tauri/src/scanning_runtime.rs index c10e719d..7b553a8b 100644 --- a/src-tauri/src/scanning_runtime.rs +++ b/src-tauri/src/scanning_runtime.rs @@ -453,7 +453,7 @@ fn render( } })?; render_tiles(&frame.tiles)?; - render_label(frame.label_for_prompt(prompt.is_some())) + render_label(frame.label_for_prompt(prompt.is_some()), &frame.tiles) } fn tick(app: &AppHandle) { let c = app.state::>(); @@ -656,7 +656,10 @@ pub fn install(app: &AppHandle) { }); } -fn render_label(label: Option<&crate::scanning::FrameLabel>) -> Result<(), String> { +fn render_label( + label: Option<&crate::scanning::FrameLabel>, + tiles: &[crate::scanning::FrameTile], +) -> Result<(), String> { LABEL.with(|slot| { let mut host = slot.borrow_mut(); if let Some(label) = label { @@ -665,7 +668,7 @@ fn render_label(label: Option<&crate::scanning::FrameLabel>) -> Result<(), Strin } host.as_mut() .unwrap() - .prompt(&label.text, label.rect, label.scale)?; + .label(label, crate::scan_host::menu_title_geometry(label, tiles))?; } else if let Some(host) = host.as_mut() { host.hide(); } @@ -673,7 +676,7 @@ fn render_label(label: Option<&crate::scanning::FrameLabel>) -> Result<(), Strin }) } fn hide_prompt() { - let _ = render_label(None); + let _ = render_label(None, &[]); hide_hold_prompt(); } fn hide_hold_prompt() {