Skip to content

feat(backend): capture regions spanning multiple outputs - #223

Merged
hthienloc merged 1 commit into
hthienloc:mainfrom
corrm:feat/spanning-region-capture
Aug 19, 2026
Merged

feat(backend): capture regions spanning multiple outputs#223
hthienloc merged 1 commit into
hthienloc:mainfrom
corrm:feat/spanning-region-capture

Conversation

@corrm

@corrm corrm commented Aug 19, 2026

Copy link
Copy Markdown
Contributor

Problem

On multi-output setups, region selections expressed in global coordinates that span more than one output are rejected:

  • Interactive selection: region crosses output boundaries — a drag that starts on one output and extends past its edge produces a spanning rect (the pointer grab is retained past the surface edge), which the capture then refuses.
  • Saved region (last): resolved to the single output containing the region's center; a spanning region is clipped to that output, and regions whose center has no output fail outright. Saved regions can end up spanning after a monitor layout change.

Fix

Composite the intersecting outputs into a single image:

  • crop_frozen_global_region (wayland.rs): pure composition over per-output captures. Each intersecting output contributes its slice; the result scale follows the output containing the region's center; pieces from outputs at a different scale are resampled into the canvas (Triangle filter). Outputs without position info are skipped (they cannot be placed in the global workspace); no intersection at all returns a clean error.
  • capture_global_region: single-output regions keep the existing compositor-side region capture path (zero behavior change). Only spanning regions capture the intersecting outputs — no extra screencopy sessions in the common case.
  • Interactive flow (main.rs): the region crosses output boundaries rejection is replaced by composing the frozen captures, so the result matches exactly what the user saw while selecting.

Verification

  • Spanning region with the center on either output: 0-pixel diff against the corresponding crop of an all-outputs composite capture.
  • Single-output saved region, full, all, list: unchanged.
  • Stale saved region (no intersecting output): clean JSON error, exit 1.
  • cargo clippy --all-targets --all-features -- -D warnings (the CI configuration added in 4d4084f): clean.

Notes

  • Mixed-DPI spanning crops: pieces are resampled with a Triangle filter — slight softening at output boundaries (same trade-off as the existing multi-scale composition in capture_all).
  • Fractional-scale outputs: the selector's integer-scale blit limitation is unchanged (dimmed overlay on that output).

Summary by Sourcery

Support global region capture across output boundaries while preserving existing single-output behavior.

New Features:

  • Capture regions spanning multiple outputs by compositing the intersecting output images.
  • Preserve the center output's scale while resampling mixed-scale output segments into the final region image.

Bug Fixes:

  • Allow interactive selections and saved regions crossing output boundaries instead of rejecting or clipping them to a single output.
  • Return a clean error when a saved region no longer intersects any positioned output.

Enhancements:

  • Keep the existing compositor-side capture path for regions contained within a single output.

Related issues

Closes #222 — the non-focused-output failure (region is outside the frozen capture) reported there is already fixed on main by 70dd4b8 (per-output frozen captures); this PR completes the multi-output region story by handling selections that span several outputs.

@sourcery-ai

sourcery-ai Bot commented Aug 19, 2026

Copy link
Copy Markdown

Reviewer's Guide

Implement multi-output global region capture by compositing per-output images, while preserving the existing single-output path and wiring interactive selection to use the new compositor logic instead of rejecting spanning regions.

Sequence diagram for multi-output global region capture

sequenceDiagram
    participant Caller
    participant Wayland as capture_global_region
    participant Outputs as list_outputs
    participant Resolver as resolve_global_region_in
    participant OutputCap as capture_output_region_logical
    participant PerOutput as capture_output
    participant Composer as crop_frozen_global_region

    Caller->>Wayland: capture_global_region(requested, cursor)
    Wayland->>Outputs: list_outputs()
    Outputs-->>Wayland: outputs
    Wayland->>Resolver: resolve_global_region_in(outputs, requested)
    Resolver-->>Wayland: (output_name, local)
    Wayland->>Wayland: compute fits
    alt fits in single output
        Wayland->>OutputCap: capture_output_region_logical(Some(output_name), local, cursor)
        OutputCap-->>Wayland: CapturedImage
        Wayland-->>Caller: CapturedImage
    else spanning region
        loop intersecting outputs
            Wayland->>PerOutput: capture_output(Some(name), cursor)
            PerOutput-->>Wayland: CapturedOutput
        end
        Wayland->>Composer: crop_frozen_global_region(captured, outputs, requested)
        Composer-->>Wayland: CapturedImage
        Wayland-->>Caller: CapturedImage
    end
Loading

File-Level Changes

Change Details Files
Extend global region capture to support regions spanning multiple Wayland outputs via per-output captures and composition.
  • Modify capture_global_region to resolve the region against a provided outputs list and determine whether it fits within a single output in logical coordinates.
  • Add a multi-output path in capture_global_region that enumerates outputs, finds intersecting ones, captures each, and delegates composition to a new crop_frozen_global_region helper.
  • Refactor resolve_global_region into resolve_global_region and resolve_global_region_in to avoid re-listing outputs and share center-based output resolution logic.
dms-screenshot-rs/src/wayland.rs
Introduce utilities for output bounds and multi-output cropping/composition of global regions, including scaling and resampling between different DPI outputs.
  • Add output_logical_bounds to compute an output’s logical-space rectangle from its position, physical size, and scale.
  • Add bounds_intersect to identify which outputs intersect a requested global region.
  • Implement crop_frozen_global_region to compute per-output pieces of the requested region, select a primary output to define the canvas scale, crop and optionally resample each piece, and overlay them onto a single canvas image, returning a CapturedImage with normalized origin.
  • Handle error cases in crop_frozen_global_region when the region does not intersect any output or when outputs lack position information.
dms-screenshot-rs/src/wayland.rs
Update interactive region capture to reuse the existing single-output frozen capture path when possible and fall back to multi-output composition when the selection spans outputs.
  • Switch capture_interactive_region to get outputs once, find the selected output by name, and compute local coordinates relative to the output’s position.
  • Replace the prior hard error when the region exceeded the output’s bounds with a fits check that keeps the old single-output crop path when the region fits.
  • Call crop_frozen_global_region with frozen captures and outputs when the interactive region spans outputs, so the user gets a composite of exactly what was visible at selection time.
dms-screenshot-rs/src/main.rs

Possibly linked issues

  • #: PR changes region capture to composite intersecting outputs, fixing failures when selecting on non-focused or spanning monitors.

Tips and commands

Interacting with Sourcery

  • Trigger a new review: Comment @sourcery-ai review on the pull request.
  • Continue discussions: Reply directly to Sourcery's review comments.
  • Generate a GitHub issue from a review comment: Ask Sourcery to create an
    issue from a review comment by replying to it. You can also reply to a
    review comment with @sourcery-ai issue to create an issue from it.
  • Generate a pull request title: Write @sourcery-ai anywhere in the pull
    request title to generate a title at any time. You can also comment
    @sourcery-ai title on the pull request to (re-)generate the title at any time.
  • Generate a pull request summary: Write @sourcery-ai summary anywhere in
    the pull request body to generate a PR summary at any time exactly where you
    want it. You can also comment @sourcery-ai summary on the pull request to
    (re-)generate the summary at any time.
  • Generate reviewer's guide: Comment @sourcery-ai guide on the pull
    request to (re-)generate the reviewer's guide at any time.
  • Resolve all Sourcery comments: Comment @sourcery-ai resolve on the
    pull request to resolve all Sourcery comments. Useful if you've already
    addressed all the comments and don't want to see them anymore.
  • Dismiss all Sourcery reviews: Comment @sourcery-ai dismiss on the pull
    request to dismiss all existing Sourcery reviews. Especially useful if you
    want to start fresh with a new review - don't forget to comment
    @sourcery-ai review to trigger a new review!

Customizing Your Experience

Access your dashboard to:

  • Enable or disable review features such as the Sourcery-generated pull request
    summary, the reviewer's guide, and others.
  • Change the review language.
  • Add, remove or edit custom review instructions.
  • Adjust other review settings.

Getting Help

@sourcery-ai sourcery-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey - I've found 1 issue, and left some high level feedback:

  • The capture_global_region logic for checking whether a region fits within a single output duplicates the bounds calculation used elsewhere; consider extracting a small helper (e.g., region_fits_output(output, local)) to keep this logic centralized and less error-prone.
  • In crop_frozen_global_region, you perform multiple outputs.iter().find(|output| output.name == capture.name) lookups inside loops; caching a HashMap<String, &OutputInfo> upfront would simplify the code and avoid repeated linear searches.
  • Error messages for non-intersecting regions are now emitted in several places (capture_global_region, crop_frozen_global_region); it may be clearer to unify the wording and ensure they are emitted consistently from a single pathway.
Prompt for AI Agents
Please address the comments from this code review:

## Overall Comments
- The `capture_global_region` logic for checking whether a region fits within a single output duplicates the bounds calculation used elsewhere; consider extracting a small helper (e.g., `region_fits_output(output, local)`) to keep this logic centralized and less error-prone.
- In `crop_frozen_global_region`, you perform multiple `outputs.iter().find(|output| output.name == capture.name)` lookups inside loops; caching a `HashMap<String, &OutputInfo>` upfront would simplify the code and avoid repeated linear searches.
- Error messages for non-intersecting regions are now emitted in several places (`capture_global_region`, `crop_frozen_global_region`); it may be clearer to unify the wording and ensure they are emitted consistently from a single pathway.

## Individual Comments

### Comment 1
<location path="dms-screenshot-rs/src/main.rs" line_range="153-154" />
<code_context>
+        .iter()
         .find(|output| output.name == output_name)
         .ok_or_else(|| format!("output disappeared during selection: {output_name}"))?;
+    let (output_x, output_y) = output.position.unwrap_or((0, 0));
     let local = Rect {
-        x: rect.x - output.position.map(|position| position.0).unwrap_or(0),
-        y: rect.y - output.position.map(|position| position.1).unwrap_or(0),
</code_context>
<issue_to_address>
**suggestion (bug_risk):** Defaulting missing output positions to (0,0) can silently mask configuration issues.

Using `unwrap_or((0, 0))` causes outputs with no position to be treated as if they were at the origin, affecting region fitting and compositing. Since multi-output compositing relies on correct global coordinates, consider returning an explicit error when `position` is `None` (like `crop_frozen_global_region` does) instead of defaulting to (0,0), so misconfigurations are detected rather than silently hidden.

Suggested implementation:

```rust
    let outputs = wayland::list_outputs()?;
    let output = outputs
        .iter()
        .find(|output| output.name == output_name)
        .ok_or_else(|| format!("output disappeared during selection: {output_name}"))?;
    let (output_x, output_y) = output
        .position
        .ok_or_else(|| format!("output has no position configured: {output_name}"))?;
    let local = Rect {
        x: rect.x - output_x,
        y: rect.y - output_y,
        width: rect.width,
        height: rect.height,
    };
    let logical_width = (output.width as f64 / output.scale.max(1.0)).round() as i32;
    let logical_height = (output.height as f64 / output.scale.max(1.0)).round() as i32;

```

If this function's return type is not already `Result<_, String>` (or otherwise compatible with the `format!`-produced error), you may need to adjust the error type or mapping to fit your existing error handling conventions. For consistency with `crop_frozen_global_region`, consider using the same error type and message style that function uses for missing positions.
</issue_to_address>

Sourcery is free for open source - if you like our reviews please consider sharing them ✨
Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.

Comment thread dms-screenshot-rs/src/main.rs
Global-logical regions that intersect more than one output are now
composited into a single image instead of being rejected or clipped:

- crop_frozen_global_region: composite per-output captures for a
  global region; result scale follows the output containing the
  region's center, mixed-DPI pieces resampled into the canvas
- capture_global_region: single-output regions keep the existing
  compositor-side region capture; only spanning regions capture the
  intersecting outputs
- interactive flow: 'region crosses output boundaries' replaced by
  compositing the frozen captures, so the result matches what was
  selected
@corrm
corrm force-pushed the feat/spanning-region-capture branch from cd02672 to 7b0e9f0 Compare August 19, 2026 02:16
@corrm

corrm commented Aug 19, 2026

Copy link
Copy Markdown
Contributor Author

Addressed the review — pushed 7b0e9f0 (amended). Point by point:

1. Duplicated fits check → helper (adopted). Extracted region_fits_output(output, local) in wayland.rs; both capture_global_region and the interactive flow in main.rs now use it.

2. Repeated name lookups → HashMap (skipped). The outputs slice holds one entry per monitor (2–4 in practice), so the linear lookups are O(N·M) over a handful of items — a HashMap would add an allocation and a std::collections import to save a couple of string comparisons per capture. Not worth it at this scale; happy to revisit if the API ever grows batch lookups.

3. Unified error pathway (partially adopted). The wording was already identical in all spots ("region does not intersect a Wayland output"). The real structural wart was in capture_global_region: it re-looked-up the output by name after resolve_global_region_in had already found it (with an unreachable fallback arm). resolve_global_region_in now returns (&OutputInfo, Rect) directly, so there is a single resolution pass and the dead branch is gone. The public resolve_global_region signature is unchanged (still used by the selector).

4. unwrap_or((0, 0)) for missing position (kept, with a comment). See the inline reply — the default is consistent with the selector's own origin assumption when the compositor reports no position, and erroring would break the working degraded mode. Added a short comment in main.rs documenting this.

Re-verified after the refactor: single-output and spanning (both center placements) captures are 0-pixel-diff against the all-outputs composite, stale-region error unchanged, and cargo clippy --all-targets --all-features -- -D warnings is clean.

@hthienloc
hthienloc merged commit 4695958 into hthienloc:main Aug 19, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Multi-monitor region capture fails with "region is outside the frozen capture" on non-focused outputs

2 participants