diff --git a/crates/unmapper-gui/src/main.rs b/crates/unmapper-gui/src/main.rs index 5ea6a10..68c91b6 100644 --- a/crates/unmapper-gui/src/main.rs +++ b/crates/unmapper-gui/src/main.rs @@ -38,7 +38,40 @@ use winit::window::{Window, WindowId}; use state::{App, ViewMode}; +/// What the GUI says when asked from a terminal, and why it must say anything. +/// +/// A window that ignores `--help` and opens anyway is not merely impolite: the +/// release script proves the bundle holds the GUI rather than the CLI by running +/// `Contents/MacOS/UnMapper --help` and checking the output is *not* clap's, and +/// a binary that treats `--help` as a filename opens a window and never returns. +/// That hung the build with no error, which is the worst way for a check to +/// fail — the safety net was the thing that broke. +const USAGE: &str = "\ +UnMapper — recreate an LED rig and play NDI onto it. + +Usage: UnMapper [STAGE FILE] + +Opens the window, on the stage file if one is given. The command-line tool is a +separate binary: `unmapper` (bundled beside this one as `unmapper-cli`). +"; + fn main() -> Result<()> { + // Answered before anything is initialised, so this stays true of a machine + // with no GPU, no NDI runtime and no window server. + if let Some(arg) = std::env::args().nth(1) { + match arg.as_str() { + "-h" | "--help" => { + println!("{USAGE}"); + return Ok(()); + } + "-V" | "--version" => { + println!("UnMapper {}", env!("CARGO_PKG_VERSION")); + return Ok(()); + } + _ => {} + } + } + let _guard = diag::init( diag::Options::new("unmapper-gui", "UNMAPPER", env!("CARGO_PKG_VERSION")) .with_default_filter("info,wgpu_core=warn,wgpu_hal=warn,naga=warn"), diff --git a/crates/unmapper-gui/src/ui.rs b/crates/unmapper-gui/src/ui.rs index ba85a39..2a5b1a5 100644 --- a/crates/unmapper-gui/src/ui.rs +++ b/crates/unmapper-gui/src/ui.rs @@ -1310,10 +1310,18 @@ pub fn viewport( } /// Screen point → canvas pixel. -fn to_canvas(app: &App, rect: egui::Rect, p: egui::Pos2) -> Vec2 { +/// +/// `ppp` is the display's points-per-pixel scale, and it is not optional. `zoom` +/// is **target pixels** per canvas pixel — that is the renderer's definition, and +/// what the vertex shader multiplies by — while a pointer arrives in egui +/// *points*. On a Retina screen those differ by two, so dropping the conversion +/// halves every click's distance from the top-left corner: panels drawn on the +/// right of the view hit-test near the middle, a grab lands on empty canvas, and +/// the drag becomes a pan. Which is exactly what it did. +fn to_canvas(app: &App, rect: egui::Rect, p: egui::Pos2, ppp: f32) -> Vec2 { Vec2::new( - (p.x - rect.left()) / app.zoom + app.pan.x, - (p.y - rect.top()) / app.zoom + app.pan.y, + (p.x - rect.left()) * ppp / app.zoom + app.pan.x, + (p.y - rect.top()) * ppp / app.zoom + app.pan.y, ) } @@ -1324,22 +1332,29 @@ fn canvas_interaction( response: &egui::Response, _target: (u32, u32), ) { + let ppp = ui.ctx().pixels_per_point(); // Zoom about the cursor, so the thing under the pointer stays under it. if response.hovered() { let scroll = ui.input(|i| i.smooth_scroll_delta.y); if scroll.abs() > 0.01 { if let Some(pointer) = response.hover_pos() { - let before = to_canvas(app, rect, pointer); + let before = to_canvas(app, rect, pointer, ppp); app.zoom = (app.zoom * (1.0 + scroll * 0.002)).clamp(0.02, 8.0); - let after = to_canvas(app, rect, pointer); + let after = to_canvas(app, rect, pointer, ppp); app.pan += before - after; } } } if response.drag_started() { - if let Some(pointer) = response.interact_pointer_pos() { - let canvas = to_canvas(app, rect, pointer); + // Where the button went *down*. egui only calls a press a drag once the + // pointer has travelled, and by then it may have left the panel it was + // aimed at — a quick flick would grab the empty canvas behind it and pan. + let origin = ui + .input(|i| i.pointer.press_origin()) + .or_else(|| response.interact_pointer_pos()); + if let Some(pointer) = origin { + let canvas = to_canvas(app, rect, pointer, ppp); // Shift-drag pans even over a panel, so a dense rig is still navigable. let pan_modifier = ui.input(|i| i.modifiers.shift); match (pan_modifier, app.panel_at(canvas)) { @@ -1365,13 +1380,13 @@ fn canvas_interaction( Some(Drag::Panel { id, grab }) => { if let Some(pointer) = response.interact_pointer_pos() { let (id, grab) = (id.clone(), *grab); - let canvas = to_canvas(app, rect, pointer); + let canvas = to_canvas(app, rect, pointer, ppp); app.move_panel(&id, canvas - grab); } } Some(Drag::Pan) => { let d = response.drag_delta(); - app.pan -= Vec2::new(d.x, d.y) / app.zoom; + app.pan -= Vec2::new(d.x, d.y) * ppp / app.zoom; } // A surface handle belongs to the previz view; nothing to do here. _ => {} @@ -1385,7 +1400,7 @@ fn canvas_interaction( // A plain click on empty canvas clears the selection. if response.clicked() { if let Some(pointer) = response.interact_pointer_pos() { - let canvas = to_canvas(app, rect, pointer); + let canvas = to_canvas(app, rect, pointer, ppp); let hit = app.panel_at(canvas); app.select_panel(hit); } @@ -1631,6 +1646,129 @@ mod tests { egui::pos2(at.x, at.y) } + /// One panel on the emulation canvas, on a **Retina** screen. + /// + /// The scale is the point of the test. `zoom` is target *pixels* per canvas + /// pixel and the pointer arrives in *points*, so at 2x they differ by two — + /// and every value here is chosen so that getting the conversion wrong misses + /// the panel entirely rather than landing slightly off it. + fn canvas_app() -> App { + let mut app = App::headless(); + app.show.panels.push(Panel::from_layout( + "a", + "A", + Size::new(200, 100), + Rect::new(400.0, 200.0, 200.0, 100.0), + 2.6, + )); + app.mode = ViewMode::Canvas; + app.zoom = 2.0; + app.pan = Vec2::ZERO; + app.dirty = false; + app + } + + fn retina() -> egui::Context { + let ctx = egui::Context::default(); + ctx.set_pixels_per_point(2.0); + ctx + } + + /// Where a canvas pixel is drawn, in screen points: the mapping the shader + /// performs, read forwards. + fn canvas_on_screen(app: &App, rect: egui::Rect, canvas: Vec2, ppp: f32) -> egui::Pos2 { + rect.min + + egui::vec2( + (canvas.x - app.pan.x) * app.zoom / ppp, + (canvas.y - app.pan.y) * app.zoom / ppp, + ) + } + + #[test] + fn a_panel_can_be_grabbed_and_dragged_on_a_retina_screen() { + let ctx = retina(); + let mut app = canvas_app(); + let rect = frame(&ctx, &mut app, Vec::new()); + + // The middle of the panel: canvas (500, 250), drawn at (500, 250) points. + // Halve that — which is what dropping the points-to-pixels conversion + // does — and you are at canvas (250, 125), off the panel entirely. + let grab = canvas_on_screen(&app, rect, Vec2::new(500.0, 250.0), 2.0); + frame(&ctx, &mut app, press(grab)); + let to = grab + egui::vec2(40.0, 20.0); + frame(&ctx, &mut app, vec![egui::Event::PointerMoved(to)]); + assert_eq!( + app.selected.as_deref(), + Some("a"), + "the press missed the panel it was aimed at" + ); + frame(&ctx, &mut app, release(to)); + + // 40 points at 2x with zoom 2 is 40 canvas pixels: the panel goes exactly + // where the pointer took it, and keeps its size. + let layout = app.show.panel("a").unwrap().layout; + assert_eq!(layout, Rect::new(440.0, 220.0, 200.0, 100.0)); + assert!(app.dirty); + } + + #[test] + fn dragging_empty_canvas_pans_by_what_the_pointer_travelled() { + let ctx = retina(); + let mut app = canvas_app(); + let rect = frame(&ctx, &mut app, Vec::new()); + + // Well clear of the panel, which is drawn at (400, 200)..(600, 300). + let from = rect.min + egui::vec2(60.0, 60.0); + frame(&ctx, &mut app, press(from)); + frame( + &ctx, + &mut app, + vec![egui::Event::PointerMoved(from + egui::vec2(-30.0, -15.0))], + ); + + // Dragging the canvas left moves the view right: pan goes up by the + // distance travelled, in canvas pixels. + assert!( + (app.pan - Vec2::new(30.0, 15.0)).length() < 0.01, + "panned to {:?}", + app.pan + ); + assert_eq!( + app.show.panel("a").unwrap().layout.x, + 400.0, + "the panel moved" + ); + } + + #[test] + fn what_is_under_the_pointer_stays_there_when_zooming() { + let ctx = retina(); + let mut app = canvas_app(); + let rect = frame(&ctx, &mut app, Vec::new()); + + let at = canvas_on_screen(&app, rect, Vec2::new(500.0, 250.0), 2.0); + frame( + &ctx, + &mut app, + vec![ + egui::Event::PointerMoved(at), + egui::Event::MouseWheel { + unit: egui::MouseWheelUnit::Point, + delta: egui::vec2(0.0, 20.0), + phase: egui::TouchPhase::Move, + modifiers: egui::Modifiers::default(), + }, + ], + ); + + assert!(app.zoom > 2.0, "the wheel should have zoomed in"); + let now = canvas_on_screen(&app, rect, Vec2::new(500.0, 250.0), 2.0); + assert!( + (now - at).length() < 1.0, + "canvas (500,250) slid from {at:?} to {now:?} under the cursor" + ); + } + #[test] fn dragging_a_control_point_in_previz_moves_the_surface_under_the_pointer() { let ctx = egui::Context::default(); diff --git a/docs/NOTES.md b/docs/NOTES.md index b992e7e..5660ad9 100644 --- a/docs/NOTES.md +++ b/docs/NOTES.md @@ -55,11 +55,26 @@ takes a `RawInput`, so feeding it `PointerMoved` / `PointerButton` events drives the real widget code with no window, no GPU and no NDI. The surface designer's whole pointer path — pick a control point, pull it, watch the surface change, and orbit when the drag starts anywhere else — is tested that way in -`unmapper-gui/src/ui.rs`, and it caught the `press_origin` bug above on its first -run. Worth reaching for in any egui app in the fleet. - -**Still not clicked:** drag-to-place on the emulation canvas, the file dialogs and -the rescan button — osascript has no assistive access on this Mac, and the dialogs +`unmapper-gui/src/ui.rs`, along with the emulation canvas's grab, drag, pan and +zoom-about-the-cursor, and it caught the `press_origin` bug above on its first +run. `Context::set_pixels_per_point` matters as much as the events: a harness at +1x cannot see a points-versus-pixels bug, which is precisely the one that was +sitting in the canvas drag. Worth reaching for in any egui app in the fleet. + +**Points are not pixels, and `zoom` is in pixels.** The emulation canvas's `zoom` +is target *pixels* per canvas pixel — that is what the vertex shader multiplies +by — while a pointer arrives from egui in *points*. On a Retina Mac those differ +by two, so hit-testing without the conversion halved every click's distance from +the top-left corner: a panel drawn on the right of the view tested near the +middle, the grab landed on empty canvas, and **drag-to-place silently became a +pan**. Found by dragging panels in the real app, on 2026-09-04, the first time +anyone tried — exactly the first-contact bug this file kept predicting. The +regression test drives the widgets headlessly *at 2x*, because at 1x the bug does +not exist. Same fix as the previz handles for the other half: grab from +`press_origin`, since a quick flick has left the panel by the time egui calls the +press a drag. + +**Still not clicked:** the file dialogs and the rescan button — osascript has no assistive access on this Mac, and the dialogs are OS windows rather than egui widgets, so the trick above does not reach them. See **screenshot capture** (working-practice note, kept in Claude memory). @@ -137,6 +152,16 @@ panels. Slice `orientation`, a non-identity `Homography`, and any `Point Mode` other than `PM_LINEAR` are parsed and warned about but not applied. Nothing has ever run on a real LED wall. +**The release script hung on its own safety check, 2026-09-04.** It proves the +bundle holds the GUI and not the CLI by running `Contents/MacOS/UnMapper --help` +and checking the output is not clap's — but the GUI took any argument as a stage +file to open, so `--help` opened a *window* and the script waited for it for +ever, having printed nothing. It looked like a slow build. Worse, the first run +appeared to succeed: removing the worktree out from under it killed the window, +the pipeline completed, and it exited 0. The GUI now answers `--help` and +`--version` and exits, and the check runs under a `perl alarm` where a timeout +is a failure rather than a pass. + Release: `scripts/release-local.sh` → universal macOS binary + `dist-release/UnMapper.app`. macOS only on purpose — never run on Windows/Linux. There is no release CI, but there is CI: `f848a50` added `.github/workflows/ci.yml`, diff --git a/scripts/release-local.sh b/scripts/release-local.sh index f87f22d..cd7a765 100755 --- a/scripts/release-local.sh +++ b/scripts/release-local.sh @@ -88,7 +88,18 @@ cp README.md LICENSE "$APP/Contents/Resources/" echo # Prove the bundle launches the GUI and not the CLI — the case-insensitivity # trap above produced exactly that, and only checking the file catches it. -if "$APP/Contents/MacOS/UnMapper" --help 2>&1 | grep -q "Usage: UnMapper "; then +# +# Under an alarm, because this check is the one thing here that runs a GUI +# binary: a version that treats `--help` as a filename opens a window and never +# returns, and this script then hangs for ever having printed nothing at all. +# It has done exactly that. A timeout is a failure, not a pass — a check that +# cannot complete has not succeeded. +if ! HELP=$(perl -e 'alarm 20; exec @ARGV' "$APP/Contents/MacOS/UnMapper" --help 2>&1); then + echo "ERROR: Contents/MacOS/UnMapper did not answer --help and exit" >&2 + echo " (a GUI that opens a window here hangs the build)" >&2 + exit 1 +fi +if printf '%s\n' "$HELP" | grep -q "Usage: UnMapper "; then echo "ERROR: Contents/MacOS/UnMapper is the CLI, not the GUI" >&2 exit 1 fi