region: a selection across monitors, stitched into one image

This commit is contained in:
elwrcl committed 2026-10-04 20:47:06 +03:00
1 parent 3495b42afa
commit 857c1f0b65
4 files changed
+155 -45

No files matched your search

+2
View File
@@ -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. - [ ] 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). - [ ] `[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) ### Known minors (deferred from the polish review)
- Releasing Shift mid-drag only updates the rectangle on the next pointer motion. - Releasing Shift mid-drag only updates the rectangle on the next pointer motion.
+4
View File
@@ -310,6 +310,10 @@ fn capture(mode: Mode, common: Common) -> Result<()> {
let source = frames[i].output.name.clone(); let source = frames[i].output.name.clone();
(vec![(frames[i].to_rgba(r), None)], 0, source) (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 => { region::Choice::Window => {
drop(frames); drop(frames);
windowed = true; windowed = true;
+144 -30
View File
@@ -38,6 +38,9 @@ pub const NAMESPACE: &str = "valw-overlay";
pub enum Choice { pub enum Choice {
/// A rectangle in the frame of output `.0`. /// A rectangle in the frame of output `.0`.
Region(usize, PixelRect), 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. /// Space before dragging: capture a window instead.
Window, Window,
} }
@@ -46,12 +49,12 @@ pub enum Choice {
pub struct Overlay { pub struct Overlay {
surfaces: Vec<Surface>, surfaces: Vec<Surface>,
selection: Selection, selection: Selection,
/// Output where the current drag started; the capture is clipped to it.
anchor: Option<usize>,
/// Shift, Alt and Space as the keyboard last reported them. /// Shift, Alt and Space as the keyboard last reported them.
mods: Mods, mods: Mods,
/// The surface the pointer is on and its surface-local position. /// The pointer in global logical coordinates. A drag's events keep
pointer: Option<(usize, (f64, f64))>, /// arriving on the surface it started on, with positions past its
/// edges, so this stays right across outputs.
pointer: Option<Point>,
outcome: Option<Option<Choice>>, outcome: Option<Option<Choice>>,
} }
@@ -68,6 +71,9 @@ struct Surface {
configured: bool, configured: bool,
frame_pending: bool, frame_pending: bool,
dirty: bool, dirty: bool,
/// The selection part and whether the label were last drawn here.
shown: Option<PixelRect>,
labelled: bool,
} }
/// Shows the overlay on every output and waits for a selection. /// 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 { wl.state.overlay = Some(Overlay {
surfaces, surfaces,
selection: Selection::default(), selection: Selection::default(),
anchor: None,
mods: Mods::default(), mods: Mods::default(),
pointer: None, pointer: None,
outcome: 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()) 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<Choice> {
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 { impl Surface {
fn new( fn new(
layer: LayerSurface, layer: LayerSurface,
@@ -151,6 +171,8 @@ impl Surface {
configured: false, configured: false,
frame_pending: false, frame_pending: false,
dirty: true, dirty: true,
shown: None,
labelled: false,
}) })
} }
@@ -227,9 +249,7 @@ impl Overlay {
fn apply_mods(&mut self, qh: &QueueHandle<State>) { fn apply_mods(&mut self, qh: &QueueHandle<State>) {
self.selection.set_mods(self.mods); self.selection.set_mods(self.mods);
if let Some(a) = self.anchor { self.redraw_changed(qh);
self.redraw(a, qh);
}
} }
pub fn switch_to_window(&mut self) { pub fn switch_to_window(&mut self) {
@@ -245,26 +265,64 @@ impl Overlay {
.position(|s| s.layer.wl_surface() == surface) .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<PixelRect> { fn selection_on(&self, i: usize) -> Option<PixelRect> {
let (start, end) = self.selection.corners()?; let (start, end) = self.selection.corners()?;
let s = &self.surfaces[i]; let s = &self.surfaces[i];
(self.anchor == Some(i)) selection::clip(start, end, &s.geom, s.width, s.height)
.then(|| selection::clip(start, end, &s.geom, s.width, s.height)) }
.flatten()
/// 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<State>) { fn redraw(&mut self, i: usize, qh: &QueueHandle<State>) {
let sel = self.selection_on(i); let sel = self.selection_on(i);
// The size label goes next to the pointer, on the output it is on. let label = self.label_on(i);
let label = sel.zip(self.pointer.filter(|(on, _)| *on == i).map(|(_, at)| at));
let s = &mut self.surfaces[i]; let s = &mut self.surfaces[i];
s.shown = sel;
s.labelled = label.is_some();
s.dirty = true; s.dirty = true;
if s.configured && !s.frame_pending { if s.configured && !s.frame_pending {
s.draw(sel, label, qh); s.draw(sel, label, qh);
} }
} }
/// Redraws the surfaces whose part of the selection or label changed.
fn redraw_changed(&mut self, qh: &QueueHandle<State>) {
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<State>) { pub fn configure(&mut self, layer: &LayerSurface, (w, h): (u32, u32), qh: &QueueHandle<State>) {
let Some(i) = self.index_of(layer.wl_surface()) else { let Some(i) = self.index_of(layer.wl_surface()) else {
return; return;
@@ -303,12 +361,12 @@ impl Overlay {
let Some(i) = self.index_of(&event.surface) else { let Some(i) = self.index_of(&event.surface) else {
continue; continue;
}; };
self.pointer = Some((i, event.position));
let g = &self.surfaces[i].geom; let g = &self.surfaces[i].geom;
let p = Point { let p = Point {
x: g.x as f64 + event.position.0, x: g.x as f64 + event.position.0,
y: g.y as f64 + event.position.1, y: g.y as f64 + event.position.1,
}; };
self.pointer = Some(p);
match event.kind { match event.kind {
PointerEventKind::Enter { serial } => { PointerEventKind::Enter { serial } => {
if let Some(cursor) = cursor { if let Some(cursor) = cursor {
@@ -320,35 +378,29 @@ impl Overlay {
} => { } => {
// Modifiers already held apply from the start. // Modifiers already held apply from the start.
self.selection.press_with(p, self.mods); self.selection.press_with(p, self.mods);
self.anchor = Some(i); self.redraw_changed(qh);
} }
PointerEventKind::Press { PointerEventKind::Press {
button: BTN_RIGHT, .. button: BTN_RIGHT, ..
} => self.cancel(), } => self.cancel(),
PointerEventKind::Motion { .. } => { PointerEventKind::Motion { .. } => {
self.selection.motion(p); self.selection.motion(p);
if let Some(a) = self.anchor { self.redraw_changed(qh);
self.redraw(a, qh);
}
} }
PointerEventKind::Release { PointerEventKind::Release {
button: BTN_LEFT, .. button: BTN_LEFT, ..
} => { } => {
let (Some((start, end)), Some(a)) = (self.selection.release(p), self.anchor) let Some((start, end)) = self.selection.release(p) else {
else {
continue; continue;
}; };
let s = &self.surfaces[a]; let choice = choose(start, end, &self.outputs(), p);
match selection::resolve(start, end, &s.geom, s.width, s.height) { match choice {
Some(rect) => { Some(choice) => {
tracing::info!( tracing::info!("selected {choice:?} (logical {start:?} to {end:?})");
"selected {rect:?} on {} (logical {start:?} to {end:?})", self.outcome = Some(Some(choice));
s.geom.name
);
self.outcome = Some(Some(Choice::Region(a, rect)));
} }
// A click, not a drag: clear it and keep waiting. // 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"
);
}
}
+5 -15
View File
@@ -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<PixelRect> {
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. /// One output's share of a selection and where it goes in the whole image.
#[derive(Debug, Clone, PartialEq)] #[derive(Debug, Clone, PartialEq)]
pub struct Part { pub struct Part {
@@ -412,11 +401,12 @@ mod tests {
#[test] #[test]
fn click_is_not_a_selection() { fn click_is_not_a_selection() {
let o = out("A", 0, 0, 1920, 1080); 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); let outputs = [(&o, (1920, 1080))];
assert_eq!(resolve(p(10.0, 10.0), p(11.0, 30.0), &o, 1920, 1080), None); 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!( assert_eq!(
resolve(p(10.0, 10.0), p(12.0, 12.0), &o, 1920, 1080), span(p(10.0, 10.0), p(12.0, 12.0), &outputs).and_then(|s| s.single()),
Some(rect(10, 10, 2, 2)) Some((0, rect(10, 10, 2, 2)))
); );
} }