From ffb49fa1f02621a827ee73f9629beaff33ae4379 Mon Sep 17 00:00:00 2001 From: Owen McGirr Date: Tue, 15 Sep 2026 10:03:41 +0100 Subject: [PATCH] Unify macOS scanning HUD message styling MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Reuse rounded native text panels for hold, escape, drag and error messages. - Measure wrapped text and grow panels within their display without shrinking errors. - Keep Windows presentation and switch behaviour unchanged; test layout bounds and prompt restoration. 🤖 Auto-generated --- src-tauri/src/point_scan.rs | 13 +- src-tauri/src/point_workflow.rs | 14 ++- src-tauri/src/scan_host.rs | 189 +++++++++++++++++++++--------- src-tauri/src/scan_menu.rs | 1 + src-tauri/src/scanning.rs | 7 ++ src-tauri/src/scanning_runtime.rs | 11 +- 6 files changed, 176 insertions(+), 59 deletions(-) diff --git a/src-tauri/src/point_scan.rs b/src-tauri/src/point_scan.rs index 1e82b4a9..9d04ed0e 100644 --- a/src-tauri/src/point_scan.rs +++ b/src-tauri/src/point_scan.rs @@ -418,6 +418,10 @@ impl Technique for Engine { let height = (64.0 * scale).min(self.screen.height); FrameLabel { text: "Back to rows".into(), + hud: Some(crate::scanning::HudPresentation { + screen: self.screen, + scale, + }), rect: Rect { x: self.screen.x + (self.screen.width - width) / 2.0, y: (self.row_rect().y + 8.0 * scale) @@ -542,6 +546,8 @@ mod tests { e.action(Action::Select); e.action(Action::Back); let frame = e.frame(); + let original_label = frame.label.as_ref().unwrap().clone(); + assert!(original_label.hud.is_some()); for hold_actions in [vec![], vec![Action::Pause]] { let settings = crate::switches::Settings { bindings: vec![crate::switches::Binding { @@ -566,9 +572,10 @@ mod tests { hold_actions.is_empty() ); gestures.released("test", 1001); - assert!(frame - .label_for_prompt(gestures.prompt(1001).is_some()) - .is_some()); + assert_eq!( + frame.label_for_prompt(gestures.prompt(1001).is_some()), + Some(&original_label) + ); gestures.cancel(); e.action(Action::Cancel); assert!(e.frame().label_for_prompt(false).is_none()); diff --git a/src-tauri/src/point_workflow.rs b/src-tauri/src/point_workflow.rs index d166b180..6639259a 100644 --- a/src-tauri/src/point_workflow.rs +++ b/src-tauri/src/point_workflow.rs @@ -343,6 +343,10 @@ impl Technique for Workflow { label.text = format!("{error}\nSelect to return"); label.rect.height = 100.0 * self.point.units_per_logical_pixel; label.scale *= 0.65; + label.hud = Some(crate::scanning::HudPresentation { + screen: self.point.screen, + scale: self.point.units_per_logical_pixel, + }); } } if matches!(self.stage, Stage::Menu | Stage::Destination) { @@ -359,6 +363,10 @@ impl Technique for Workflow { if self.stage == Stage::Destination { frame.label = Some(FrameLabel { text: "Choose drag destination".into(), + hud: Some(crate::scanning::HudPresentation { + screen: self.point.screen, + scale: s, + }), rect: Rect { x: self.point.screen.x, y: self.point.screen.y, @@ -469,7 +477,11 @@ mod tests { Some(Request::Command { .. }) )); w.execution_failed("Action failed.".into()); - assert!(w.frame().label.unwrap().text.contains("Select to return")); + let label = w.frame().label.unwrap(); + assert!(label.text.contains("Select to return")); + assert_eq!(label.hud.as_ref().unwrap().scale, 1.0); + assert_eq!(label.hud.as_ref().unwrap().screen, w.point.screen); + assert!(label.scale < label.hud.as_ref().unwrap().scale); assert!(w.handle(Action::Select).is_none()); assert_eq!(w.selected(Item::LeftClick), Some(default_click((120, 80)))); } diff --git a/src-tauri/src/scan_host.rs b/src-tauri/src/scan_host.rs index 7d74b90e..aa44f5da 100644 --- a/src-tauri/src/scan_host.rs +++ b/src-tauri/src/scan_host.rs @@ -134,7 +134,7 @@ mod platform { pub struct Host { panels: Vec>, last_rects: Vec, - last_title: Option<(String, Rect, f64)>, + last_title: Option<(String, Rect, f64, Option)>, title: Option<( Retained, Retained, @@ -188,26 +188,32 @@ mod platform { Ok(()) } pub fn menu_title(&mut self, text: &str, rect: Rect, scale: f64) -> Result<(), String> { + self.text_panel(text, rect, scale, None) + } + pub fn text_panel( + &mut self, + text: &str, + requested: Rect, + scale: f64, + screen: Option, + ) -> 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 - }) - { + if self.last_title.as_ref().is_some_and( + |(old_text, old_rect, old_scale, old_screen)| { + old_text == text + && *old_rect == requested + && *old_scale == scale + && *old_screen == screen + }, + ) { 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 mut rect = + screen.map_or(requested, |screen| hud_rect(requested, screen, 0.0, scale)); 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); @@ -220,19 +226,6 @@ mod platform { } 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); @@ -247,45 +240,50 @@ mod platform { .ok_or("Menu title text is unavailable.")? .cellSizeForBounds(NSRect::new( NSPoint::new(0.0, 0.0), - NSSize::new(width, rect.height), + NSSize::new(width, f64::MAX), )); - let height = measured.height.min(rect.height); + if let Some(screen) = screen { + rect = hud_rect(requested, screen, measured.height, scale); + } + let height = measured.height.min((rect.height - 24.0 * scale).max(1.0)); + view.setFrame(NSRect::new( + NSPoint::new(0.0, 0.0), + NSSize::new(rect.width, rect.height), + )); + 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); + 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.")?; + let view = view.clone(); self.render(&[crate::scanning::PaintedRect { rect, color: [30, 35, 46], - opacity: 255, + opacity: 0, role: crate::scanning::VisualRole::Accent, }])?; - let label = NSTextField::wrappingLabelWithString(&NSString::from_str(text), mtm); - label.setFont(Some(&NSFont::systemFontOfSize(20.0 * scale))); - label.setTextColor(Some(&NSColor::whiteColor())); - label.setFrame(NSRect::new( - NSPoint::new(12.0 * scale, 12.0 * scale), - NSSize::new( - (rect.width - 24.0 * scale).max(1.0), - (rect.height - 24.0 * scale).max(1.0), - ), - )); - self.panels[0].setBackgroundColor(Some(&NSColor::blackColor())); - self.panels[0].setContentView(Some(&label)); - self.panels[0].orderFrontRegardless(); + if self.panels[0].contentView().as_deref() != Some(view.as_ref()) { + self.panels[0].setContentView(Some(&view)); + } + self.last_title = Some((text.to_owned(), requested, scale, screen)); Ok(()) } + pub fn prompt(&mut self, text: &str, rect: Rect, scale: f64) -> Result<(), String> { + self.text_panel(text, rect, scale, None) + } pub fn tile(&mut self, tile: &crate::scanning::FrameTile) -> Result<(), String> { use objc2_app_kit::{NSFont, NSImageView, NSTextAlignment, NSTextField, NSView}; use objc2_foundation::NSString; @@ -444,6 +442,24 @@ pub fn close_foreground_window() -> Result<(), String> { } impl Host { + pub fn hud_prompt( + &mut self, + text: &str, + rect: Rect, + legacy_scale: f64, + presentation: crate::scanning::HudPresentation, + ) -> Result<(), String> { + #[cfg(target_os = "macos")] + { + let _ = legacy_scale; + self.text_panel(text, rect, presentation.scale, Some(presentation.screen)) + } + #[cfg(not(target_os = "macos"))] + { + let _ = presentation; + self.prompt(text, rect, legacy_scale) + } + } pub fn label( &mut self, label: &crate::scanning::FrameLabel, @@ -457,6 +473,9 @@ impl Host { if let Some(MenuTitle { rect, scale }) = menu_title { let _ = (rect, scale); } + if let Some(hud) = &label.hud { + return self.hud_prompt(&label.text, label.rect, label.scale, hud.clone()); + } self.prompt(&label.text, label.rect, label.scale) } } @@ -490,11 +509,73 @@ pub fn menu_title_geometry( }) } +#[cfg(any(target_os = "macos", test))] +fn hud_rect(requested: Rect, screen: Rect, text_height: f64, scale: f64) -> Rect { + let width = requested.width.min(screen.width).max(1.0); + let height = requested + .height + .max(text_height + 24.0 * scale) + .min(screen.height) + .max(1.0); + Rect { + x: requested.x.clamp(screen.x, screen.x + screen.width - width), + y: requested + .y + .clamp(screen.y, screen.y + screen.height - height), + width, + height, + } +} + #[cfg(test)] mod title_tests { use super::*; use crate::scan_menu::{Kind, Menu}; + #[test] + fn wrapped_hud_grows_and_stays_inside_scaled_negative_displays() { + for scale in [1.0, 2.0] { + let screen = Rect { + x: -640.0 * scale, + y: -200.0 * scale, + width: 640.0 * scale, + height: 480.0 * scale, + }; + let requested = Rect { + x: screen.x + 20.0 * scale, + y: screen.y + 420.0 * scale, + width: 360.0 * scale, + height: 64.0 * scale, + }; + let rect = hud_rect(requested, screen, 120.0 * scale, scale); + assert_eq!(rect.width, requested.width); + assert_eq!(rect.x, requested.x); + assert_eq!(rect.height, 144.0 * scale); + assert_eq!(rect.y + rect.height, screen.y + screen.height); + assert_eq!( + hud_rect(requested, screen, 20.0 * scale, scale).height, + requested.height + ); + } + } + + #[test] + fn tiny_display_bounds_cap_long_hud_and_preserve_visible_geometry() { + let screen = Rect { + x: 100.0, + y: -50.0, + width: 160.0, + height: 100.0, + }; + let requested = Rect { + x: 110.0, + y: -30.0, + width: 720.0, + height: 64.0, + }; + assert_eq!(hud_rect(requested, screen, 400.0, 1.0), screen); + } + #[test] fn titles_follow_tile_edges_without_changing_header_spacing() { for screen in [ diff --git a/src-tauri/src/scan_menu.rs b/src-tauri/src/scan_menu.rs index 4b9b23b0..44ce5c5f 100644 --- a/src-tauri/src/scan_menu.rs +++ b/src-tauri/src/scan_menu.rs @@ -199,6 +199,7 @@ impl Menu { ..panel }, scale: scale * 0.75, + hud: None, }); frame } diff --git a/src-tauri/src/scanning.rs b/src-tauri/src/scanning.rs index 0cfc6704..f9559c2b 100644 --- a/src-tauri/src/scanning.rs +++ b/src-tauri/src/scanning.rs @@ -212,11 +212,18 @@ pub struct FrameTile { pub icon: crate::scan_menu::Item, pub selected: bool, } +#[derive(Debug, Clone, PartialEq)] +pub struct HudPresentation { + pub screen: Rect, + pub scale: f64, +} + #[derive(Debug, Clone, PartialEq)] pub struct FrameLabel { pub text: String, pub rect: Rect, pub scale: f64, + pub hud: Option, } /// Automatic movement gives up after this many full passes of the current diff --git a/src-tauri/src/scanning_runtime.rs b/src-tauri/src/scanning_runtime.rs index 1ef01b0f..6857d6b2 100644 --- a/src-tauri/src/scanning_runtime.rs +++ b/src-tauri/src/scanning_runtime.rs @@ -725,7 +725,7 @@ fn show_prompt( if p.is_none() { *p = Some(Host::new()?); } - p.as_mut().unwrap().prompt( + p.as_mut().unwrap().hud_prompt( &format!( "Release {} for {}", prompt.switch_name, @@ -733,6 +733,15 @@ fn show_prompt( ), rect, scale, + crate::scanning::HudPresentation { + screen: crate::scanning::Rect { + x: f64::from(display.x), + y: f64::from(display.y), + width: f64::from(display.width), + height: f64::from(display.height), + }, + scale, + }, ) }) }