Skip to content

feat: span fullscreen="all" across all monitors on Wayland (layer-shell)#546

Open
FedotCompot wants to merge 9 commits into
Satty-org:mainfrom
FedotCompot:feat/fullscreen-all-wayland-layer-shell
Open

feat: span fullscreen="all" across all monitors on Wayland (layer-shell)#546
FedotCompot wants to merge 9 commits into
Satty-org:mainfrom
FedotCompot:feat/fullscreen-all-wayland-layer-shell

Conversation

@FedotCompot

@FedotCompot FedotCompot commented Jun 16, 2026

Copy link
Copy Markdown

Disclaimer

The whole PR was vibe-coded with Opus 4.8

Summary

fullscreen = "all" previously only ever covered a single monitor on Wayland. The implementation relied on GDK's FullscreenMode::AllMonitors, which is a no-op on the Wayland backend — and a single Wayland surface cannot span multiple outputs by design.

This PR makes fullscreen = "all" actually span every monitor on Wayland by using the wlr-layer-shell protocol: the app's root window becomes the primary layer-shell surface, and one additional layer-shell surface is created per remaining output. Each surface renders its own native-scale slice of the screenshot, so together they cover the whole layout 1:1.

Behaviour is unchanged on X11 (still uses GDK's native all-monitor fullscreen) and it falls back gracefully to a single screen on compositors without layer-shell and for single-monitor setups.

Related to #118 — this introduces wlr-layer-shell support to the codebase, a prerequisite for the layer-shell / live-annotation ideas discussed there.

How it works

  • A new per-area "layout view" transform pins each FemtoVGArea to a fixed image slice at native scale (image_origin + image-pixels-per-device-pixel) instead of the usual fit/center.
  • The App root window is configured as the primary layer-shell overlay (anchored to all edges of its monitor); SketchBoard creates one more layer-shell window per other monitor.
  • Each monitor's area renders the shared scene on its own GL context. Committed drawables are broadcast (cloned) to every area; undo/redo/reset/refresh are broadcast too. This keeps per-canvas state independent (e.g. the blur tool's cached image), so each output renders correctly.
  • Secondary monitor areas convert pointer events to image coordinates locally and forward them as image-space events, so drawing works on every monitor.
  • Save/copy continues to render the full image on the primary area, so the exported result contains annotations from all monitors.
  • The blur tool screenshots the canvas, so it now clamps the sampled region to the framebuffer to avoid out-of-bounds sampling on surfaces that only show a slice.

New dependency

  • gtk4-layer-shell (Rust crate 0.7, targeting gtk4-rs 0.10). Requires the gtk4-layer-shell C library at build and runtime.

Testing

Verified on Hyprland with a 3-monitor layout (1920×1080 @ -1920,0 · 2560×1440 @ 0,0 · 2560×1440 @ 2560,0):

  • All three monitors covered, each showing its correct, seamlessly-aligned slice; toolbars overlaid on the primary monitor.
  • Drawing verified on every monitor (primary and both secondaries), correctly positioned.
  • Save/copy produces the full 7040×1440 image containing annotations drawn on all three monitors.
  • Blur verified on a secondary monitor with real content — renders correctly, no crash.
  • Single-window mode unchanged (normal toplevel, no layer-shell); cargo test passes, cargo clippy clean.

Known limitations (initial version)

  • Keyboard / text-tool input is routed to the primary monitor.
  • A blur dragged across a monitor seam may look slightly off.
  • Mixed-DPI monitors are not fully handled yet (uniform scale — the common case — is correct).

GDK's all-monitor fullscreen (FullscreenMode::AllMonitors) is a no-op on
Wayland, so fullscreen="all" only ever covered a single output. A single
Wayland surface also cannot span multiple outputs.

Realize it instead with one wlr-layer-shell surface per monitor: the App
root window becomes the primary surface and SketchBoard creates an extra
layer-shell surface for every other output. Each surface renders its own
native-scale slice of the screenshot via a fixed "layout view" transform.

Drawables are broadcast (cloned) to every area so each renders the shared
scene on its own GL context; secondary areas forward pointer input in image
coordinates. Save/copy keeps rendering the full image on the primary, so the
exported result contains annotations from every monitor.

The blur tool screenshots the canvas, so it now clamps the sampled region to
the framebuffer to avoid out-of-bounds sampling on surfaces that only show a
slice.

Falls back to the previous behaviour on X11, on compositors without
layer-shell, and for single-monitor setups.
The crop shadow was sized to the canvas and anchored at image origin (0,0),
so it only lined up on the monitor whose image slice starts at (0,0). On the
other layer-shell surfaces the layout-view offset shifted the overlay (and its
even-odd hole), producing inverted/misplaced dimming.

Dim the shared image bounds instead, so the overlay and the crop cut-out are
positioned in image coordinates and render correctly on every monitor.
@FedotCompot FedotCompot marked this pull request as draft June 16, 2026 14:01
The blur tool screenshots the canvas and samples the rect under it. With
fullscreen="all" each monitor's canvas only renders its own slice of the
image, so a blur belonging to another monitor maps entirely off this target.

The previous clamp forced the sampled width/height to a minimum of 1, so when
the rect sat past the right/bottom edge `left` was already at the framebuffer
width and `left + 1` ran one pixel out of bounds — imgref's sub_image asserts
`left + width <= width()` and aborted the whole process.

Clamp each edge independently (no forced minimum) so the bound always holds,
and when the rect doesn't overlap the target, return None without caching.
Leaving the cache empty lets the full-image render used for saving/copying
recompute the blur from real pixels, so a blur drawn on a non-primary monitor
still exports correctly instead of as an empty box. The overlap check now runs
before the screenshot read-back, skipping it entirely when there's nothing to
sample.
FullscreenChanged moves the toolbars between the outer box and the overlay.
For layer-shell fullscreen="all" it fires twice for the same state: once from
our explicit call after setting up the surfaces, and again from the window's
"fullscreened" notify once the compositor reports the surface fullscreen. The
second run called outer_box.remove() on toolbars already moved to the overlay,
tripping two gtk_box_remove assertions at startup.

Track whether the toolbars are currently overlaid and only move them when the
requested state actually differs.
In fullscreen="all" the per-monitor annotation surfaces live on the Overlay
layer. The color chooser and annotation-size dialogs are normal toplevels, so
they opened *behind* those surfaces and were invisible — the custom colour
palette looked like it never opened.

Promote both dialogs to their own Overlay layer-shell surface when
layershell_all_active(), with Exclusive keyboard so their inputs (palette,
hex entry, spin button, Enter/Escape) work. transient_for a layer-shell
surface is meaningless, so it's skipped in that mode. Outside fullscreen="all"
the dialogs are unchanged.

layershell_all_active() is now pub(crate) so the toolbars module can reuse it.
In fullscreen="all" every surface is pinned to a fixed per-monitor slice via
the layout view, so zooming/panning makes no sense there. The render-time
transform already ignored them, but the input handlers still mutated the
zoom/offset state, which could take effect during the brief window before the
layout view is applied — scrolling (with or without modifiers) appeared to zoom
the primary monitor.

Short-circuit set_zoom_scale, set_drag_offset and reset_size when
layershell_all_active(), covering every entry point (scroll, pinch, middle-drag
pan, arrow-key pan, and the 1:1/fit toolbar buttons).
`GtkColorChooserDialog` doesn't work well on the Overlay layer-shell surface
used in fullscreen="all": its Select/Cancel live in a header bar that isn't
rendered, so the dialog could only be dismissed with Esc and a custom colour
couldn't be confirmed; and it wraps the chooser in a scrolled window, so the
taller custom-editor view scrolled (hiding the alpha slider behind a scrollbar).

Build the picker ourselves for that mode: a bare GtkColorChooserWidget (sizes to
its content, no scrolled window) in an Overlay layer-shell window with in-content
Cancel/Select buttons. Confirms on Select or a double-clicked swatch, cancels on
Cancel or Esc. Non-fullscreen="all" keeps the stock dialog.
Replace the separate annotation-size dialog with controls directly in the style
toolbar: a minus and plus button (±0.1), a live value readout, and a reset
button that restores the configured default. Scrolling over the value still
fine-tunes by ±0.01; the scroll controller now uses DISCRETE so one wheel notch
is exactly one step instead of jumping erratically with smooth scrolling.

The value still propagates via ToolbarEvent::AnnotationSizeChanged, so nothing
downstream changes. Removes the AnnotationSizeDialog component, its input/output
enums, the controller field, the show_annotation_dialog method, and the
ShowAnnotationDialog/AnnotationDialogFinished messages.
@FedotCompot FedotCompot marked this pull request as ready for review June 17, 2026 09:16
@RobertMueller2

Copy link
Copy Markdown
Member

Thank you for this PR.

I can't speak for all of my maintainer colleagues of course, but personally I don't think that we are going to merge this into main.

My gut feeling is that Satty is not the correct place to solve this problem. This should be solved in Wayland, and it has been discussed in the past already: https://gitlab.freedesktop.org/wayland/wayland-protocols/-/work_items/99

Yes, that approach is (obviously) very veeeeeeerrrry sllllllllllloooooooooow, but it's the right one. My suggestion for anyone looking for a solution for this is to direct any effort towards the Wayland item mentioned above.

If it was a tiny change providing a workaround, I probably would not mind adding it to Satty. But it is not a tiny change. It would add considerable complexity, and brings along more changes required to even make it work. Take the need for a custom colour dialog, for example. Complexity we'd have to support once we merge. I prefer living with a known issue here.

That said, I think we might be looking into cherry-picking b6a1d3a, because that's actually a simplification and looks quite useful.

@FedotCompot

Copy link
Copy Markdown
Author

Ok, perfectly understandable, if you want I can make a PR with the tool size UI.

Regarding workaround options...

The first thing I tried was a single window spanning all monitors (7040x1440@-1920x0 in my case). But past a certain point the window rendering itself becomes inconsistent, colors shift so IDK. Maybe there is a way of making it work I'm not aware of, I'm really not an expert of rust or desktop applications in general, just a poor guy missing flameshot level ergonomics for screenshots after moving to hypland ahahahah

So I will keep my fork and continue doing some improvements from time to time based on my needs and time available and will use a local build on my machines. Really like the project, hope it will survive the maintainers crisis

@RobertMueller2

Copy link
Copy Markdown
Member

Ok, perfectly understandable, if you want I can make a PR with the tool size UI.

Thank you for understanding.

Yes, if you don't mind, a PR would be nice.

Just a small heads-up: we may revamp this again, so it may just be temporary. The S-M-L plus annotation size is possibly not the greatest UX, but we haven't really decided on anything yet, so I think we'd merge the commit for the time being.

@FedotCompot

Copy link
Copy Markdown
Author

Yeah... I also noticed that, me personally I REALLY loved the scrolling with a circle around the cursor as a preview to change the tool size without a specific numer on flameshot, really simple and intuitive.

I'm on vacation 20-28 as soon as I have time when I get back I will try to implement (vibe code) it on my fork, will try to do it on both my layer-shell and master so in case you like it you can consider merging.

Also regarding the color menu it could be an in place popover instead of a separate window. Just a thought...

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.

2 participants