diff --git a/docs/test-checklist.md b/docs/test-checklist.md index 122d18a..c1f1869 100644 --- a/docs/test-checklist.md +++ b/docs/test-checklist.md @@ -147,6 +147,8 @@ image, a cancel brought the thumbnail back, and a swipe still dismissed it. - [ ] Each shot is a two-hit "ka-chunk" (macOS-like) with a metallic clatter that grows from shot 2; shots in quick succession play sounds 1 → 5 getting faster and brighter, then 6, 7, 6, 7 (a climbing bell arpeggio over a glide) rising endlessly; every shot about as loud; after a 5 s pause it starts at 1 again. `valw __combo-demo` plays the whole sequence for tuning. - [ ] `[sound] enabled = false`, `volume`, `combo_reset_secs` take effect; the toolbar's Play sound option overrides the config; no `pw-play` → no sound, capture still works (warning in the log). +- [ ] Region across monitors: drag from the laptop up onto the monitor and back; each screen shows its part, the label shows the whole size on the screen under the pointer; Shift, Alt and Space work across the edge; the PNG is the whole rectangle, seams exact, parts over no screen transparent; the preview appears on the screen where you let go; a box on one screen is exactly as before. + ### Known minors (deferred from the polish review) - Releasing Shift mid-drag only updates the rectangle on the next pointer motion. diff --git a/src/main.rs b/src/main.rs index 90fe397..95b8d0e 100644 --- a/src/main.rs +++ b/src/main.rs @@ -310,6 +310,10 @@ fn capture(mode: Mode, common: Common) -> Result<()> { let source = frames[i].output.name.clone(); (vec![(frames[i].to_rgba(r), None)], 0, source) } + region::Choice::Span(span, on) => { + let source = frames[on].output.name.clone(); + (vec![(stitch::stitch(&frames, &span), None)], 0, source) + } region::Choice::Window => { drop(frames); windowed = true; diff --git a/src/region.rs b/src/region.rs index c163919..3fb9fd6 100644 --- a/src/region.rs +++ b/src/region.rs @@ -38,6 +38,9 @@ pub const NAMESPACE: &str = "valw-overlay"; pub enum Choice { /// A rectangle in the frame of output `.0`. Region(usize, PixelRect), + /// A rectangle over several outputs (or past one's edge), with the + /// output the pointer was over at release (for the preview). + Span(selection::Span, usize), /// Space before dragging: capture a window instead. Window, } @@ -46,12 +49,12 @@ pub enum Choice { pub struct Overlay { surfaces: Vec, selection: Selection, - /// Output where the current drag started; the capture is clipped to it. - anchor: Option, /// Shift, Alt and Space as the keyboard last reported them. mods: Mods, - /// The surface the pointer is on and its surface-local position. - pointer: Option<(usize, (f64, f64))>, + /// The pointer in global logical coordinates. A drag's events keep + /// arriving on the surface it started on, with positions past its + /// edges, so this stays right across outputs. + pointer: Option, outcome: Option>, } @@ -68,6 +71,9 @@ struct Surface { configured: bool, frame_pending: bool, dirty: bool, + /// The selection part and whether the label were last drawn here. + shown: Option, + labelled: bool, } /// Shows the overlay on every output and waits for a selection. @@ -114,7 +120,6 @@ pub fn select(wl: &mut Wayland, outputs: &[Output], frames: &[Frame]) -> Result< wl.state.overlay = Some(Overlay { surfaces, selection: Selection::default(), - anchor: None, mods: Mods::default(), pointer: None, outcome: None, @@ -127,6 +132,21 @@ pub fn select(wl: &mut Wayland, outputs: &[Output], frames: &[Frame]) -> Result< outcome.flatten().ok_or_else(|| Cancelled.into()) } +/// What a finished drag from `a` to `b` captures: one output's crop as +/// before, or the whole rectangle to stitch; `None` for a click. +fn choose( + a: Point, + b: Point, + outputs: &[(&OutputGeom, (u32, u32))], + pointer: Point, +) -> Option { + let span = selection::span(a, b, outputs)?; + Some(match span.single() { + Some((i, rect)) => Choice::Region(i, rect), + None => Choice::Span(span, selection::locate(pointer, outputs)), + }) +} + impl Surface { fn new( layer: LayerSurface, @@ -151,6 +171,8 @@ impl Surface { configured: false, frame_pending: false, dirty: true, + shown: None, + labelled: false, }) } @@ -227,9 +249,7 @@ impl Overlay { fn apply_mods(&mut self, qh: &QueueHandle) { self.selection.set_mods(self.mods); - if let Some(a) = self.anchor { - self.redraw(a, qh); - } + self.redraw_changed(qh); } pub fn switch_to_window(&mut self) { @@ -245,26 +265,64 @@ impl Overlay { .position(|s| s.layer.wl_surface() == surface) } - /// The selection as drawn on surface `i`: only the anchor output shows it. + fn outputs(&self) -> Vec<(&OutputGeom, (u32, u32))> { + self.surfaces + .iter() + .map(|s| (&s.geom, (s.width, s.height))) + .collect() + } + + /// The selection's part on surface `i`. fn selection_on(&self, i: usize) -> Option { let (start, end) = self.selection.corners()?; let s = &self.surfaces[i]; - (self.anchor == Some(i)) - .then(|| selection::clip(start, end, &s.geom, s.width, s.height)) - .flatten() + selection::clip(start, end, &s.geom, s.width, s.height) + } + + /// The size label on surface `i`: the whole shot's size next to the + /// pointer, on the output the pointer is over. + fn label_on(&self, i: usize) -> Option<(PixelRect, (f64, f64))> { + let (start, end) = self.selection.corners()?; + let p = self.pointer?; + let outputs = self.outputs(); + if selection::locate(p, &outputs) != i { + return None; + } + let (width, height) = selection::span(start, end, &outputs)?.size; + let g = &self.surfaces[i].geom; + Some(( + PixelRect { + x: 0, + y: 0, + width, + height, + }, + (p.x - g.x as f64, p.y - g.y as f64), + )) } fn redraw(&mut self, i: usize, qh: &QueueHandle) { let sel = self.selection_on(i); - // The size label goes next to the pointer, on the output it is on. - let label = sel.zip(self.pointer.filter(|(on, _)| *on == i).map(|(_, at)| at)); + let label = self.label_on(i); let s = &mut self.surfaces[i]; + s.shown = sel; + s.labelled = label.is_some(); s.dirty = true; if s.configured && !s.frame_pending { s.draw(sel, label, qh); } } + /// Redraws the surfaces whose part of the selection or label changed. + fn redraw_changed(&mut self, qh: &QueueHandle) { + for i in 0..self.surfaces.len() { + let s = &self.surfaces[i]; + if self.selection_on(i) != s.shown || s.labelled || self.label_on(i).is_some() { + self.redraw(i, qh); + } + } + } + pub fn configure(&mut self, layer: &LayerSurface, (w, h): (u32, u32), qh: &QueueHandle) { let Some(i) = self.index_of(layer.wl_surface()) else { return; @@ -303,12 +361,12 @@ impl Overlay { let Some(i) = self.index_of(&event.surface) else { continue; }; - self.pointer = Some((i, event.position)); let g = &self.surfaces[i].geom; let p = Point { x: g.x as f64 + event.position.0, y: g.y as f64 + event.position.1, }; + self.pointer = Some(p); match event.kind { PointerEventKind::Enter { serial } => { if let Some(cursor) = cursor { @@ -320,35 +378,29 @@ impl Overlay { } => { // Modifiers already held apply from the start. self.selection.press_with(p, self.mods); - self.anchor = Some(i); + self.redraw_changed(qh); } PointerEventKind::Press { button: BTN_RIGHT, .. } => self.cancel(), PointerEventKind::Motion { .. } => { self.selection.motion(p); - if let Some(a) = self.anchor { - self.redraw(a, qh); - } + self.redraw_changed(qh); } PointerEventKind::Release { button: BTN_LEFT, .. } => { - let (Some((start, end)), Some(a)) = (self.selection.release(p), self.anchor) - else { + let Some((start, end)) = self.selection.release(p) else { continue; }; - let s = &self.surfaces[a]; - match selection::resolve(start, end, &s.geom, s.width, s.height) { - Some(rect) => { - tracing::info!( - "selected {rect:?} on {} (logical {start:?} to {end:?})", - s.geom.name - ); - self.outcome = Some(Some(Choice::Region(a, rect))); + let choice = choose(start, end, &self.outputs(), p); + match choice { + Some(choice) => { + tracing::info!("selected {choice:?} (logical {start:?} to {end:?})"); + self.outcome = Some(Some(choice)); } // A click, not a drag: clear it and keep waiting. - None => self.redraw(a, qh), + None => self.redraw_changed(qh), } } _ => {} @@ -356,3 +408,65 @@ impl Overlay { } } } + +#[cfg(test)] +mod tests { + use super::*; + + fn out(name: &str, x: i32, y: i32, width: i32, height: i32) -> OutputGeom { + OutputGeom { + name: name.into(), + x, + y, + width, + height, + } + } + + fn p(x: f64, y: f64) -> Point { + Point { x, y } + } + + #[test] + fn one_output_is_todays_region_and_several_a_span() { + let laptop = out("LVDS-1", 0, 0, 1366, 768); + let monitor = out("HDMI-A-1", -277, -1080, 1920, 1080); + let outputs = [(&laptop, (1366, 768)), (&monitor, (1920, 1080))]; + assert_eq!( + choose(p(100.0, 100.0), p(300.0, 250.0), &outputs, p(300.0, 250.0)), + Some(Choice::Region( + 0, + PixelRect { + x: 100, + y: 100, + width: 200, + height: 150 + } + )) + ); + let across = choose( + p(100.0, 500.0), + p(600.0, -300.0), + &outputs, + p(600.0, -300.0), + ); + let Some(Choice::Span(span, on)) = across else { + panic!("expected a span, got {across:?}"); + }; + assert_eq!( + (span.size, span.parts.len(), on), + ((500, 800), 2, 1), + "preview on the monitor" + ); + // A box touching only the laptop but reaching past its left edge. + assert!(matches!( + choose(p(-50.0, 100.0), p(200.0, 300.0), &outputs, p(200.0, 300.0)), + Some(Choice::Span(_, 0)) + )); + assert_eq!( + choose(p(10.0, 10.0), p(11.0, 30.0), &outputs, p(11.0, 30.0)), + None, + "a click" + ); + } +} diff --git a/src/selection.rs b/src/selection.rs index 380a558..7792cab 100644 --- a/src/selection.rs +++ b/src/selection.rs @@ -183,17 +183,6 @@ pub fn clip(a: Point, b: Point, out: &OutputGeom, frame_w: u32, frame_h: u32) -> }) } -/// Like [`clip`], but treats anything under [`MIN_PIXELS`] as a click. -pub fn resolve( - a: Point, - b: Point, - out: &OutputGeom, - frame_w: u32, - frame_h: u32, -) -> Option { - clip(a, b, out, frame_w, frame_h).filter(|r| r.width >= MIN_PIXELS && r.height >= MIN_PIXELS) -} - /// One output's share of a selection and where it goes in the whole image. #[derive(Debug, Clone, PartialEq)] pub struct Part { @@ -412,11 +401,12 @@ mod tests { #[test] fn click_is_not_a_selection() { let o = out("A", 0, 0, 1920, 1080); - assert_eq!(resolve(p(10.0, 10.0), p(10.0, 10.0), &o, 1920, 1080), None); - assert_eq!(resolve(p(10.0, 10.0), p(11.0, 30.0), &o, 1920, 1080), None); + let outputs = [(&o, (1920, 1080))]; + assert_eq!(span(p(10.0, 10.0), p(10.0, 10.0), &outputs), None); + assert_eq!(span(p(10.0, 10.0), p(11.0, 30.0), &outputs), None); assert_eq!( - resolve(p(10.0, 10.0), p(12.0, 12.0), &o, 1920, 1080), - Some(rect(10, 10, 2, 2)) + span(p(10.0, 10.0), p(12.0, 12.0), &outputs).and_then(|s| s.single()), + Some((0, rect(10, 10, 2, 2))) ); }