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

+4
View File
@@ -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;
+144 -30
View File
@@ -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<Surface>,
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.
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<Point>,
outcome: Option<Option<Choice>>,
}
@@ -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<PixelRect>,
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<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 {
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<State>) {
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<PixelRect> {
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<State>) {
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<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>) {
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"
);
}
}
+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.
#[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)))
);
}