Skip to content

Commit 1b7b167

Browse files
enaboappsOwen McGirr
andauthored
Unify macOS scanning HUD message styling (#769)
- 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 Co-authored-by: Owen McGirr <owenmcgirr@Owens-Mac-Studio-2.local>
1 parent 7a4e1ac commit 1b7b167

6 files changed

Lines changed: 176 additions & 59 deletions

File tree

src-tauri/src/point_scan.rs

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -418,6 +418,10 @@ impl Technique for Engine {
418418
let height = (64.0 * scale).min(self.screen.height);
419419
FrameLabel {
420420
text: "Back to rows".into(),
421+
hud: Some(crate::scanning::HudPresentation {
422+
screen: self.screen,
423+
scale,
424+
}),
421425
rect: Rect {
422426
x: self.screen.x + (self.screen.width - width) / 2.0,
423427
y: (self.row_rect().y + 8.0 * scale)
@@ -542,6 +546,8 @@ mod tests {
542546
e.action(Action::Select);
543547
e.action(Action::Back);
544548
let frame = e.frame();
549+
let original_label = frame.label.as_ref().unwrap().clone();
550+
assert!(original_label.hud.is_some());
545551
for hold_actions in [vec![], vec![Action::Pause]] {
546552
let settings = crate::switches::Settings {
547553
bindings: vec![crate::switches::Binding {
@@ -566,9 +572,10 @@ mod tests {
566572
hold_actions.is_empty()
567573
);
568574
gestures.released("test", 1001);
569-
assert!(frame
570-
.label_for_prompt(gestures.prompt(1001).is_some())
571-
.is_some());
575+
assert_eq!(
576+
frame.label_for_prompt(gestures.prompt(1001).is_some()),
577+
Some(&original_label)
578+
);
572579
gestures.cancel();
573580
e.action(Action::Cancel);
574581
assert!(e.frame().label_for_prompt(false).is_none());

src-tauri/src/point_workflow.rs

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -343,6 +343,10 @@ impl Technique for Workflow {
343343
label.text = format!("{error}\nSelect to return");
344344
label.rect.height = 100.0 * self.point.units_per_logical_pixel;
345345
label.scale *= 0.65;
346+
label.hud = Some(crate::scanning::HudPresentation {
347+
screen: self.point.screen,
348+
scale: self.point.units_per_logical_pixel,
349+
});
346350
}
347351
}
348352
if matches!(self.stage, Stage::Menu | Stage::Destination) {
@@ -359,6 +363,10 @@ impl Technique for Workflow {
359363
if self.stage == Stage::Destination {
360364
frame.label = Some(FrameLabel {
361365
text: "Choose drag destination".into(),
366+
hud: Some(crate::scanning::HudPresentation {
367+
screen: self.point.screen,
368+
scale: s,
369+
}),
362370
rect: Rect {
363371
x: self.point.screen.x,
364372
y: self.point.screen.y,
@@ -469,7 +477,11 @@ mod tests {
469477
Some(Request::Command { .. })
470478
));
471479
w.execution_failed("Action failed.".into());
472-
assert!(w.frame().label.unwrap().text.contains("Select to return"));
480+
let label = w.frame().label.unwrap();
481+
assert!(label.text.contains("Select to return"));
482+
assert_eq!(label.hud.as_ref().unwrap().scale, 1.0);
483+
assert_eq!(label.hud.as_ref().unwrap().screen, w.point.screen);
484+
assert!(label.scale < label.hud.as_ref().unwrap().scale);
473485
assert!(w.handle(Action::Select).is_none());
474486
assert_eq!(w.selected(Item::LeftClick), Some(default_click((120, 80))));
475487
}

src-tauri/src/scan_host.rs

Lines changed: 135 additions & 54 deletions
Original file line numberDiff line numberDiff line change
@@ -134,7 +134,7 @@ mod platform {
134134
pub struct Host {
135135
panels: Vec<Retained<NSPanel>>,
136136
last_rects: Vec<crate::scanning::PaintedRect>,
137-
last_title: Option<(String, Rect, f64)>,
137+
last_title: Option<(String, Rect, f64, Option<Rect>)>,
138138
title: Option<(
139139
Retained<objc2_app_kit::NSView>,
140140
Retained<objc2_app_kit::NSTextField>,
@@ -188,26 +188,32 @@ mod platform {
188188
Ok(())
189189
}
190190
pub fn menu_title(&mut self, text: &str, rect: Rect, scale: f64) -> Result<(), String> {
191+
self.text_panel(text, rect, scale, None)
192+
}
193+
pub fn text_panel(
194+
&mut self,
195+
text: &str,
196+
requested: Rect,
197+
scale: f64,
198+
screen: Option<Rect>,
199+
) -> Result<(), String> {
191200
use objc2_app_kit::{
192201
NSFont, NSFontWeightSemibold, NSTextAlignment, NSTextField, NSView,
193202
};
194203
use objc2_foundation::NSString;
195-
if self
196-
.last_title
197-
.as_ref()
198-
.is_some_and(|(old_text, old_rect, old_scale)| {
199-
old_text == text && *old_rect == rect && *old_scale == scale
200-
})
201-
{
204+
if self.last_title.as_ref().is_some_and(
205+
|(old_text, old_rect, old_scale, old_screen)| {
206+
old_text == text
207+
&& *old_rect == requested
208+
&& *old_scale == scale
209+
&& *old_screen == screen
210+
},
211+
) {
202212
return Ok(());
203213
}
204214
let mtm = MainThreadMarker::new().ok_or("Menu title requires the main thread.")?;
205-
self.render(&[crate::scanning::PaintedRect {
206-
rect,
207-
color: [30, 35, 46],
208-
opacity: 0,
209-
role: crate::scanning::VisualRole::Accent,
210-
}])?;
215+
let mut rect =
216+
screen.map_or(requested, |screen| hud_rect(requested, screen, 0.0, scale));
211217
let bounds = NSRect::new(NSPoint::new(0.0, 0.0), NSSize::new(rect.width, rect.height));
212218
if self.title.is_none() {
213219
let view = NSView::initWithFrame(NSView::alloc(mtm), bounds);
@@ -220,19 +226,6 @@ mod platform {
220226
}
221227
let (view, label) = self.title.as_ref().unwrap();
222228
view.setFrame(bounds);
223-
let layer = view
224-
.layer()
225-
.ok_or("Menu title background is unavailable.")?;
226-
layer.setBackgroundColor(Some(
227-
&NSColor::colorWithSRGBRed_green_blue_alpha(
228-
30.0 / 255.0,
229-
35.0 / 255.0,
230-
46.0 / 255.0,
231-
1.0,
232-
)
233-
.CGColor(),
234-
));
235-
layer.setCornerRadius(10.0 * scale);
236229
let value = NSString::from_str(text);
237230
if label.stringValue() != value {
238231
label.setStringValue(&value);
@@ -247,45 +240,50 @@ mod platform {
247240
.ok_or("Menu title text is unavailable.")?
248241
.cellSizeForBounds(NSRect::new(
249242
NSPoint::new(0.0, 0.0),
250-
NSSize::new(width, rect.height),
243+
NSSize::new(width, f64::MAX),
251244
));
252-
let height = measured.height.min(rect.height);
245+
if let Some(screen) = screen {
246+
rect = hud_rect(requested, screen, measured.height, scale);
247+
}
248+
let height = measured.height.min((rect.height - 24.0 * scale).max(1.0));
249+
view.setFrame(NSRect::new(
250+
NSPoint::new(0.0, 0.0),
251+
NSSize::new(rect.width, rect.height),
252+
));
253+
let layer = view
254+
.layer()
255+
.ok_or("Menu title background is unavailable.")?;
256+
layer.setBackgroundColor(Some(
257+
&NSColor::colorWithSRGBRed_green_blue_alpha(
258+
30.0 / 255.0,
259+
35.0 / 255.0,
260+
46.0 / 255.0,
261+
1.0,
262+
)
263+
.CGColor(),
264+
));
265+
layer.setCornerRadius(10.0 * scale);
266+
253267
label.setFrame(NSRect::new(
254268
NSPoint::new(12.0 * scale, (rect.height - height) / 2.0),
255269
NSSize::new(width, height),
256270
));
257-
if self.panels[0].contentView().as_deref() != Some(view.as_ref()) {
258-
self.panels[0].setContentView(Some(view));
259-
}
260-
self.last_title = Some((text.to_owned(), rect, scale));
261-
Ok(())
262-
}
263-
pub fn prompt(&mut self, text: &str, rect: Rect, scale: f64) -> Result<(), String> {
264-
use objc2_app_kit::{NSFont, NSTextField};
265-
use objc2_foundation::NSString;
266-
self.last_title = None;
267-
let mtm = MainThreadMarker::new().ok_or("Prompt requires the main thread.")?;
271+
let view = view.clone();
268272
self.render(&[crate::scanning::PaintedRect {
269273
rect,
270274
color: [30, 35, 46],
271-
opacity: 255,
275+
opacity: 0,
272276
role: crate::scanning::VisualRole::Accent,
273277
}])?;
274-
let label = NSTextField::wrappingLabelWithString(&NSString::from_str(text), mtm);
275-
label.setFont(Some(&NSFont::systemFontOfSize(20.0 * scale)));
276-
label.setTextColor(Some(&NSColor::whiteColor()));
277-
label.setFrame(NSRect::new(
278-
NSPoint::new(12.0 * scale, 12.0 * scale),
279-
NSSize::new(
280-
(rect.width - 24.0 * scale).max(1.0),
281-
(rect.height - 24.0 * scale).max(1.0),
282-
),
283-
));
284-
self.panels[0].setBackgroundColor(Some(&NSColor::blackColor()));
285-
self.panels[0].setContentView(Some(&label));
286-
self.panels[0].orderFrontRegardless();
278+
if self.panels[0].contentView().as_deref() != Some(view.as_ref()) {
279+
self.panels[0].setContentView(Some(&view));
280+
}
281+
self.last_title = Some((text.to_owned(), requested, scale, screen));
287282
Ok(())
288283
}
284+
pub fn prompt(&mut self, text: &str, rect: Rect, scale: f64) -> Result<(), String> {
285+
self.text_panel(text, rect, scale, None)
286+
}
289287
pub fn tile(&mut self, tile: &crate::scanning::FrameTile) -> Result<(), String> {
290288
use objc2_app_kit::{NSFont, NSImageView, NSTextAlignment, NSTextField, NSView};
291289
use objc2_foundation::NSString;
@@ -444,6 +442,24 @@ pub fn close_foreground_window() -> Result<(), String> {
444442
}
445443

446444
impl Host {
445+
pub fn hud_prompt(
446+
&mut self,
447+
text: &str,
448+
rect: Rect,
449+
legacy_scale: f64,
450+
presentation: crate::scanning::HudPresentation,
451+
) -> Result<(), String> {
452+
#[cfg(target_os = "macos")]
453+
{
454+
let _ = legacy_scale;
455+
self.text_panel(text, rect, presentation.scale, Some(presentation.screen))
456+
}
457+
#[cfg(not(target_os = "macos"))]
458+
{
459+
let _ = presentation;
460+
self.prompt(text, rect, legacy_scale)
461+
}
462+
}
447463
pub fn label(
448464
&mut self,
449465
label: &crate::scanning::FrameLabel,
@@ -457,6 +473,9 @@ impl Host {
457473
if let Some(MenuTitle { rect, scale }) = menu_title {
458474
let _ = (rect, scale);
459475
}
476+
if let Some(hud) = &label.hud {
477+
return self.hud_prompt(&label.text, label.rect, label.scale, hud.clone());
478+
}
460479
self.prompt(&label.text, label.rect, label.scale)
461480
}
462481
}
@@ -490,11 +509,73 @@ pub fn menu_title_geometry(
490509
})
491510
}
492511

512+
#[cfg(any(target_os = "macos", test))]
513+
fn hud_rect(requested: Rect, screen: Rect, text_height: f64, scale: f64) -> Rect {
514+
let width = requested.width.min(screen.width).max(1.0);
515+
let height = requested
516+
.height
517+
.max(text_height + 24.0 * scale)
518+
.min(screen.height)
519+
.max(1.0);
520+
Rect {
521+
x: requested.x.clamp(screen.x, screen.x + screen.width - width),
522+
y: requested
523+
.y
524+
.clamp(screen.y, screen.y + screen.height - height),
525+
width,
526+
height,
527+
}
528+
}
529+
493530
#[cfg(test)]
494531
mod title_tests {
495532
use super::*;
496533
use crate::scan_menu::{Kind, Menu};
497534

535+
#[test]
536+
fn wrapped_hud_grows_and_stays_inside_scaled_negative_displays() {
537+
for scale in [1.0, 2.0] {
538+
let screen = Rect {
539+
x: -640.0 * scale,
540+
y: -200.0 * scale,
541+
width: 640.0 * scale,
542+
height: 480.0 * scale,
543+
};
544+
let requested = Rect {
545+
x: screen.x + 20.0 * scale,
546+
y: screen.y + 420.0 * scale,
547+
width: 360.0 * scale,
548+
height: 64.0 * scale,
549+
};
550+
let rect = hud_rect(requested, screen, 120.0 * scale, scale);
551+
assert_eq!(rect.width, requested.width);
552+
assert_eq!(rect.x, requested.x);
553+
assert_eq!(rect.height, 144.0 * scale);
554+
assert_eq!(rect.y + rect.height, screen.y + screen.height);
555+
assert_eq!(
556+
hud_rect(requested, screen, 20.0 * scale, scale).height,
557+
requested.height
558+
);
559+
}
560+
}
561+
562+
#[test]
563+
fn tiny_display_bounds_cap_long_hud_and_preserve_visible_geometry() {
564+
let screen = Rect {
565+
x: 100.0,
566+
y: -50.0,
567+
width: 160.0,
568+
height: 100.0,
569+
};
570+
let requested = Rect {
571+
x: 110.0,
572+
y: -30.0,
573+
width: 720.0,
574+
height: 64.0,
575+
};
576+
assert_eq!(hud_rect(requested, screen, 400.0, 1.0), screen);
577+
}
578+
498579
#[test]
499580
fn titles_follow_tile_edges_without_changing_header_spacing() {
500581
for screen in [

src-tauri/src/scan_menu.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -199,6 +199,7 @@ impl Menu {
199199
..panel
200200
},
201201
scale: scale * 0.75,
202+
hud: None,
202203
});
203204
frame
204205
}

src-tauri/src/scanning.rs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -212,11 +212,18 @@ pub struct FrameTile {
212212
pub icon: crate::scan_menu::Item,
213213
pub selected: bool,
214214
}
215+
#[derive(Debug, Clone, PartialEq)]
216+
pub struct HudPresentation {
217+
pub screen: Rect,
218+
pub scale: f64,
219+
}
220+
215221
#[derive(Debug, Clone, PartialEq)]
216222
pub struct FrameLabel {
217223
pub text: String,
218224
pub rect: Rect,
219225
pub scale: f64,
226+
pub hud: Option<HudPresentation>,
220227
}
221228

222229
/// Automatic movement gives up after this many full passes of the current

src-tauri/src/scanning_runtime.rs

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -725,14 +725,23 @@ fn show_prompt(
725725
if p.is_none() {
726726
*p = Some(Host::new()?);
727727
}
728-
p.as_mut().unwrap().prompt(
728+
p.as_mut().unwrap().hud_prompt(
729729
&format!(
730730
"Release {} for {}",
731731
prompt.switch_name,
732732
prompt.action.label()
733733
),
734734
rect,
735735
scale,
736+
crate::scanning::HudPresentation {
737+
screen: crate::scanning::Rect {
738+
x: f64::from(display.x),
739+
y: f64::from(display.y),
740+
width: f64::from(display.width),
741+
height: f64::from(display.height),
742+
},
743+
scale,
744+
},
736745
)
737746
})
738747
}

0 commit comments

Comments
 (0)