Confirmed facts (runtime)
- Single monitor works — pig appears on the selected monitor correctly.
- With both selected, pigs hit a barrier in the middle (not at a monitor edge) when walking and when dragged.
- Pigs walk off the right edge of one display and off the left edge of the other — no boundary there. They disappear.
- Display names in tray are identical — no disambiguation.
- Hardware: 270° rotated portrait monitor to the left of the main landscape display.
Root cause
Mixed-unit bounding box (core bug)
overlay_manager::apply computes the spanning window bounds by mixing logical positions with physical sizes:
// Current — WRONG
let min_x = enabled.iter().map(|m| m.position.x).min(); // logical pixels
let max_x = enabled.iter().map(|m| m.position.x + m.size.width as i32).max(); // logical + physical
Monitor::position() on macOS returns logical coordinates (points).
Monitor::size() returns physical pixels.
At 2× DPR: a 1920-wide monitor has position.x = 1920 (logical) but size.width = 3840 (physical).
max_x = 1920 + 3840 = 5760 → wrong. Correct is 3840 + 3840 = 7680 physical, or 1920 + 1920 = 3840 logical.
The window ends up smaller than the actual span and offset inside it.
Result: open edges on the outside, invisible barrier in the middle.
Rotated monitor compounds the error
macOS reports the 270°-rotated portrait monitor with swapped logical dimensions (height > width).
The position origin for a rotated monitor may be anchored at a non-top-left corner in the global coordinate frame.
Current code makes no attempt to handle orientation — bounding box is further wrong for the portrait monitor.
Hit-test thread unit mismatch
let origin = win_clone.outer_position(); // may return logical on macOS
let local_x = cursor.x - origin.x; // cursor is physical
Same logical/physical confusion in the click-through polling thread.
Fix: display/ module tree
Replace app/overlay_manager.rs and app/pig_hittest.rs with a dedicated display/ module:
src-tauri/src/display/
mod.rs — DisplayManager (public surface; re-exports pub types; impls RectUpdater)
monitor.rs — pure types + math: LogicalMonitor, compute_span, disambiguate_names
overlay.rs — window lifecycle: create, resize, show, destroy
hit_test.rs — PigHitTester + polling thread (move from app/pig_hittest.rs)
display/monitor.rs — pure, fully testable
pub struct LogicalMonitor {
pub index: usize,
pub label: String, // disambiguated display name
pub scale_factor: f64,
pub position: (f64, f64), // logical pixels, top-left origin
pub size: (f64, f64), // logical pixels (width, height)
}
impl LogicalMonitor {
/// Normalise a Tauri Monitor: divide physical size+position by scale_factor.
pub fn from_tauri(index: usize, m: &tauri::Monitor) -> Self;
}
pub struct SpanBounds {
pub x: f64, // logical
pub y: f64,
pub width: f64,
pub height: f64,
}
/// Union bounding box of all monitors in logical pixels.
pub fn compute_span(monitors: &[LogicalMonitor]) -> SpanBounds;
/// Append " (x,y)" to names that appear more than once.
pub fn disambiguate_names(monitors: &mut [LogicalMonitor]);
All arithmetic stays in logical space. Window calls use LogicalSize/LogicalPosition — Tauri converts to physical internally.
display/hit_test.rs
Move app/pig_hittest.rs here unchanged. Update polling thread to use outer_position() converted to logical (divide by window's scale factor) so it matches CSS-pixel pig rects.
display/overlay.rs
Extract window create/resize/show/destroy from overlay_manager::ensure_shown. Uses LogicalSize + LogicalPosition for all window calls.
display/mod.rs — DisplayManager
pub struct DisplayManager { entries: Arc<Mutex<HashMap<String, OverlayEntry>>> }
pub struct DisplayManagerState(pub DisplayManager);
impl DisplayManager {
pub fn apply<R>(&self, app: &AppHandle<R>, monitors: &[LogicalMonitor], config: &DisplayConfig);
}
impl RectUpdater for DisplayManager { ... }
What moves / stays
| Symbol |
From |
To |
PigHitTester |
app/pig_hittest.rs |
display/hit_test.rs |
OverlayManager |
app/overlay_manager.rs |
display/mod.rs as DisplayManager |
MonitorInfo |
app/overlay_manager.rs |
display/monitor.rs as LogicalMonitor |
| Bounding-box math |
app/overlay_manager.rs |
display/monitor.rs::compute_span |
| Window lifecycle |
app/overlay_manager.rs |
display/overlay.rs |
MonitorsState |
app/mod.rs |
stays, wraps Vec<LogicalMonitor> |
DisplayConfigState |
app/mod.rs |
stays |
| Tray display menu |
app/tray.rs |
stays, uses LogicalMonitor.label |
PigHitState |
ui_bridge/mod.rs |
stays |
update_pig_rects |
ui_bridge/mod.rs |
stays |
DisplayConfig, PigRect, RectUpdater |
crates/domain |
stays |
Test plan (pure logic in monitor.rs)
compute_span single monitor → bounds equal monitor bounds
compute_span two landscape monitors side by side → width = sum of widths, x = leftmost
compute_span portrait monitor (h > w) to the left of landscape → correct origin + total width
compute_span monitor at negative x (secondary left of primary) → min_x is negative
disambiguate_names identical names → suffix with "(x, y)"
disambiguate_names unique names → unchanged
Acceptance criteria
Files touched
src-tauri/src/display/ — new module tree (4 files)
src-tauri/src/app/overlay_manager.rs — deleted
src-tauri/src/app/pig_hittest.rs — deleted
src-tauri/src/app/mod.rs — update imports, MonitorInfo → LogicalMonitor
src-tauri/src/app/tray.rs — update display menu to use LogicalMonitor.label
src-tauri/src/main.rs or lib.rs — add mod display
Confirmed facts (runtime)
Root cause
Mixed-unit bounding box (core bug)
overlay_manager::applycomputes the spanning window bounds by mixing logical positions with physical sizes:Monitor::position()on macOS returns logical coordinates (points).Monitor::size()returns physical pixels.At 2× DPR: a 1920-wide monitor has
position.x = 1920(logical) butsize.width = 3840(physical).max_x = 1920 + 3840 = 5760→ wrong. Correct is3840 + 3840 = 7680physical, or1920 + 1920 = 3840logical.The window ends up smaller than the actual span and offset inside it.
Result: open edges on the outside, invisible barrier in the middle.
Rotated monitor compounds the error
macOS reports the 270°-rotated portrait monitor with swapped logical dimensions (height > width).
The position origin for a rotated monitor may be anchored at a non-top-left corner in the global coordinate frame.
Current code makes no attempt to handle orientation — bounding box is further wrong for the portrait monitor.
Hit-test thread unit mismatch
Same logical/physical confusion in the click-through polling thread.
Fix: display/ module tree
Replace
app/overlay_manager.rsandapp/pig_hittest.rswith a dedicateddisplay/module:display/monitor.rs— pure, fully testableAll arithmetic stays in logical space. Window calls use
LogicalSize/LogicalPosition— Tauri converts to physical internally.display/hit_test.rsMove
app/pig_hittest.rshere unchanged. Update polling thread to useouter_position()converted to logical (divide by window's scale factor) so it matches CSS-pixel pig rects.display/overlay.rsExtract window create/resize/show/destroy from
overlay_manager::ensure_shown. UsesLogicalSize+LogicalPositionfor all window calls.display/mod.rs— DisplayManagerWhat moves / stays
PigHitTesterapp/pig_hittest.rsdisplay/hit_test.rsOverlayManagerapp/overlay_manager.rsdisplay/mod.rsasDisplayManagerMonitorInfoapp/overlay_manager.rsdisplay/monitor.rsasLogicalMonitorapp/overlay_manager.rsdisplay/monitor.rs::compute_spanapp/overlay_manager.rsdisplay/overlay.rsMonitorsStateapp/mod.rsVec<LogicalMonitor>DisplayConfigStateapp/mod.rsapp/tray.rsLogicalMonitor.labelPigHitStateui_bridge/mod.rsupdate_pig_rectsui_bridge/mod.rsDisplayConfig,PigRect,RectUpdatercrates/domainTest plan (pure logic in monitor.rs)
compute_spansingle monitor → bounds equal monitor boundscompute_spantwo landscape monitors side by side → width = sum of widths, x = leftmostcompute_spanportrait monitor (h > w) to the left of landscape → correct origin + total widthcompute_spanmonitor at negative x (secondary left of primary) → min_x is negativedisambiguate_namesidentical names → suffix with "(x, y)"disambiguate_namesunique names → unchangedAcceptance criteria
display/module tree exists;app/overlay_manager.rsandapp/pig_hittest.rsremovedcompute_spananddisambiguate_namescovered by unit teststask checkgreenFiles touched
src-tauri/src/display/— new module tree (4 files)src-tauri/src/app/overlay_manager.rs— deletedsrc-tauri/src/app/pig_hittest.rs— deletedsrc-tauri/src/app/mod.rs— update imports, MonitorInfo → LogicalMonitorsrc-tauri/src/app/tray.rs— update display menu to use LogicalMonitor.labelsrc-tauri/src/main.rsorlib.rs— addmod display