Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 33 additions & 0 deletions crates/unmapper-gui/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
Expand Down
158 changes: 148 additions & 10 deletions crates/unmapper-gui/src/ui.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
)
}

Expand All @@ -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)) {
Expand All @@ -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.
_ => {}
Expand All @@ -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);
}
Expand Down Expand Up @@ -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();
Expand Down
35 changes: 30 additions & 5 deletions docs/NOTES.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).

Expand Down Expand Up @@ -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`,
Expand Down
13 changes: 12 additions & 1 deletion scripts/release-local.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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 <COMMAND>"; 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 <COMMAND>"; then
echo "ERROR: Contents/MacOS/UnMapper is the CLI, not the GUI" >&2
exit 1
fi
Expand Down