detached b9da8c3

feat(capture): share a single window via ext-foreign-toplevel (#105)

* feat(capture): share a single window through ext-foreign-toplevel

Serve ext_foreign_toplevel_list_v1 with a per-mapping identifier and
ext_foreign_toplevel_image_capture_source_manager_v1. A window source is
rendered alone off screen at its client size, wherever the window is,
with a pill indicator on its output and its outline while visible. A
hidden captured window is configured Captured and sized from its real
layout. Sessions end when the window unmaps; sources of a closed handle
are dead.

Refs #101, #102

* fix(capture): size a stashed captured window from its stash configure

Drop the stash entries from the window-capture size fallback: their size
is stale for a former free float. Assert the closed event on unmap and
title updates after stop in the ext-foreign-toplevel tests.
commit
b9da8c3dc9bbced6631622b93c9b8af23d42afd7
author
bnema · · 2026-10-03 11:33 UTC
committer
GitHub · · 2026-10-03 11:33 UTC
parent
8ffaccb
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
24 files · +774 -44
M docs/capture.md +1 -1
@@ -82,6 +82,6 @@ A refused client is logged once per distinct executable, at info level: `capture
8282
8383 ## Portals
8484
85-For screen sharing in browsers and other applications, install `xdg-desktop-portal-wlr`; for screenshots and recordings, install [nefercap](https://github.com/bnema/nefercap). The packaged `neferwl-portals.conf` sends `ScreenCast` and `Screenshot` to the wlr portal and everything else to the GTK portal. The wlr portal is in the built-in list as `/usr/lib/xdg-desktop-portal-wlr` and `/usr/libexec/xdg-desktop-portal-wlr`; if it lives elsewhere, add its path in `/etc/neferwl/capture-allow`. What to share is picked with a chooser that the portal starts itself, an external tool such as slurp, wofi or bemenu (see `chooser_type` and `chooser_cmd` in the `xdg-desktop-portal-wlr` man page). NeferWL has no chooser of its own, so install one. Sandboxed applications use the same portal: they never capture directly.
85+For screen sharing in browsers and other applications, install `xdg-desktop-portal-wlr`; for screenshots and recordings, install [nefercap](https://github.com/bnema/nefercap). The packaged `neferwl-portals.conf` sends `ScreenCast` and `Screenshot` to the wlr portal and everything else to the GTK portal. The wlr portal is in the built-in list as `/usr/lib/xdg-desktop-portal-wlr` and `/usr/libexec/xdg-desktop-portal-wlr`; if it lives elsewhere, add its path in `/etc/neferwl/capture-allow`. A whole output or a single window can be shared: a shared window is captured at its own size, whether it is on screen, on another workspace or under a fullscreen window, and keeps drawing while it is shared. What to share is picked with a chooser that the portal starts itself, an external tool such as slurp, wofi or bemenu (see `chooser_type` and `chooser_cmd` in the `xdg-desktop-portal-wlr` man page). NeferWL has no chooser of its own, so install one. Sandboxed applications use the same portal: they never capture directly.
8686
8787 A listed program captures on behalf of whoever asks it. Any program running as your user can ask the portal over D-Bus, and the wlr portal takes screenshots without a dialog. The indicator still flashes for each of them.
@@ -82,6 +82,6 @@ A refused client is logged once per distinct executable, at info level: `capture
82 82
83 ## Portals83 ## Portals
84 84
85-For screen sharing in browsers and other applications, install `xdg-desktop-portal-wlr`; for screenshots and recordings, install [nefercap](https://github.com/bnema/nefercap). The packaged `neferwl-portals.conf` sends `ScreenCast` and `Screenshot` to the wlr portal and everything else to the GTK portal. The wlr portal is in the built-in list as `/usr/lib/xdg-desktop-portal-wlr` and `/usr/libexec/xdg-desktop-portal-wlr`; if it lives elsewhere, add its path in `/etc/neferwl/capture-allow`. What to share is picked with a chooser that the portal starts itself, an external tool such as slurp, wofi or bemenu (see `chooser_type` and `chooser_cmd` in the `xdg-desktop-portal-wlr` man page). NeferWL has no chooser of its own, so install one. Sandboxed applications use the same portal: they never capture directly.85+For screen sharing in browsers and other applications, install `xdg-desktop-portal-wlr`; for screenshots and recordings, install [nefercap](https://github.com/bnema/nefercap). The packaged `neferwl-portals.conf` sends `ScreenCast` and `Screenshot` to the wlr portal and everything else to the GTK portal. The wlr portal is in the built-in list as `/usr/lib/xdg-desktop-portal-wlr` and `/usr/libexec/xdg-desktop-portal-wlr`; if it lives elsewhere, add its path in `/etc/neferwl/capture-allow`. A whole output or a single window can be shared: a shared window is captured at its own size, whether it is on screen, on another workspace or under a fullscreen window, and keeps drawing while it is shared. What to share is picked with a chooser that the portal starts itself, an external tool such as slurp, wofi or bemenu (see `chooser_type` and `chooser_cmd` in the `xdg-desktop-portal-wlr` man page). NeferWL has no chooser of its own, so install one. Sandboxed applications use the same portal: they never capture directly.
86 86
87 A listed program captures on behalf of whoever asks it. Any program running as your user can ask the portal over D-Bus, and the wlr portal takes screenshots without a dialog. The indicator still flashes for each of them.87 A listed program captures on behalf of whoever asks it. Any program running as your user can ask the portal over D-Bus, and the wlr portal takes screenshots without a dialog. The indicator still flashes for each of them.
M docs/desktop.md +1 -1
@@ -97,7 +97,7 @@ exec wlopm --off '*'
9797
9898 NeferWL supports `ext_workspace_manager_v1` for bars such as Waybar 0.13+ (`ext/workspaces`) and ironbar. Bars receive workspace updates and can switch workspaces without polling. Every workspace sends an `id`, the same string as its `workspace_id` in the state file. A configured (named) workspace has `name:<configured name>`, stable across launches; renaming it in the config removes its handle and sends a new one. A numbered or dynamic workspace has `<prefix>-<n>`: the prefix is 8 lowercase hex characters drawn once per compositor launch, so the `id` is unique per launch, stable while the workspace exists (across reordering and moves between outputs), and never repeats in the next launch. Numbered workspaces carry an `id` too, so a bar or script can target them during the session, but it is unique for the current launch only: do not store preferences keyed on it across launches. Only named workspaces have an `id` that is stable across launches.
9999
100-NeferWL also supports `zwlr_foreign_toplevel_manager_v1`. Taskbars can list, focus, close and fullscreen windows, and notification daemons such as Dunst can detect fullscreen windows. While unlocked, clients can see window titles and app IDs. Workspace and window inventory updates are withheld during session protection.
100+NeferWL also supports `zwlr_foreign_toplevel_manager_v1`. Taskbars can list, focus, close and fullscreen windows, and notification daemons such as Dunst can detect fullscreen windows. The read-only `ext_foreign_toplevel_list_v1` lists the same windows with a stable `identifier` (`neferwl-<n>`, never reused in a session); screen-sharing portals use it to offer single windows. While unlocked, clients can see window titles and app IDs. Workspace and window inventory updates are withheld during session protection.
101101
102102 ## State for scripts
103103
@@ -97,7 +97,7 @@ exec wlopm --off '*'
97 97
98 NeferWL supports `ext_workspace_manager_v1` for bars such as Waybar 0.13+ (`ext/workspaces`) and ironbar. Bars receive workspace updates and can switch workspaces without polling. Every workspace sends an `id`, the same string as its `workspace_id` in the state file. A configured (named) workspace has `name:<configured name>`, stable across launches; renaming it in the config removes its handle and sends a new one. A numbered or dynamic workspace has `<prefix>-<n>`: the prefix is 8 lowercase hex characters drawn once per compositor launch, so the `id` is unique per launch, stable while the workspace exists (across reordering and moves between outputs), and never repeats in the next launch. Numbered workspaces carry an `id` too, so a bar or script can target them during the session, but it is unique for the current launch only: do not store preferences keyed on it across launches. Only named workspaces have an `id` that is stable across launches.98 NeferWL supports `ext_workspace_manager_v1` for bars such as Waybar 0.13+ (`ext/workspaces`) and ironbar. Bars receive workspace updates and can switch workspaces without polling. Every workspace sends an `id`, the same string as its `workspace_id` in the state file. A configured (named) workspace has `name:<configured name>`, stable across launches; renaming it in the config removes its handle and sends a new one. A numbered or dynamic workspace has `<prefix>-<n>`: the prefix is 8 lowercase hex characters drawn once per compositor launch, so the `id` is unique per launch, stable while the workspace exists (across reordering and moves between outputs), and never repeats in the next launch. Numbered workspaces carry an `id` too, so a bar or script can target them during the session, but it is unique for the current launch only: do not store preferences keyed on it across launches. Only named workspaces have an `id` that is stable across launches.
99 99
100-NeferWL also supports `zwlr_foreign_toplevel_manager_v1`. Taskbars can list, focus, close and fullscreen windows, and notification daemons such as Dunst can detect fullscreen windows. While unlocked, clients can see window titles and app IDs. Workspace and window inventory updates are withheld during session protection.100+NeferWL also supports `zwlr_foreign_toplevel_manager_v1`. Taskbars can list, focus, close and fullscreen windows, and notification daemons such as Dunst can detect fullscreen windows. The read-only `ext_foreign_toplevel_list_v1` lists the same windows with a stable `identifier` (`neferwl-<n>`, never reused in a session); screen-sharing portals use it to offer single windows. While unlocked, clients can see window titles and app IDs. Workspace and window inventory updates are withheld during session protection.
101 101
102 ## State for scripts102 ## State for scripts
103 103
M docs/headless.md +1 -1
@@ -9,7 +9,7 @@ WAYLAND_DISPLAY=<name from the log> foot
99
1010 Screenshots through `grim` use `zwlr_screencopy_v1` or `ext_image_copy_capture_v1`. Both deliver opaque 8-bit sRGB, including GPU tone mapping of HDR content. Protocol capture requires Vulkan sync-file export; unsupported devices fail the request rather than wait for GPU completion on the output goroutine. The cursor is not included in captures: `overlay_cursor` and `paint_cursors` are ignored. Headless screenshot files include the cursor. If readback is temporarily unavailable, that screenshot frame is skipped without stopping the output or writing a black PNG.
1111
12-Recording goes through `ext_image_copy_capture_v1`, with optional additions from `neferwl_image_capture_source_manager_v1` (XML in `internal/adapters/wayland/imagecapture/`): `create_workspace_source` (a workspace handle of `ext_workspace_manager_v1`, whether or not the workspace is on screen) and `create_output_region_source` (a rectangle of an output, in logical pixels, clipped). Both give an ordinary `ext_image_capture_source_v1`. A workspace source is the workspace's real layout without bars, not an overview preview; its buffer size follows the workspace frame (new constraints when it changes) and its sessions stop when the workspace is removed. `get_workspace_frame` gives a `neferwl_workspace_frame_v1` that sends the workspace frame (`frame`: x, y, width, height in output-local logical pixels) when created and whenever it changes, since ext-workspace carries no geometry. A region source stops when the output goes away or nothing of the rectangle is left. A workspace that is not on screen uses a separate, demand-driven child renderer and capture pipeline, with its own worker and two staging slots (up to 128 MiB host-visible memory) plus render targets (each image up to 256 MiB, 16384 pixels per side), for one workspace at a time; frames of other sessions on a different hidden workspace are retried while it lasts, then fail (see below). Hidden capture can leave the displayed workspace in direct scanout. Offscreen capture currently requires whole-frame requests matching its exact physical dimensions.
12+Recording goes through `ext_image_copy_capture_v1`, with optional additions from `neferwl_image_capture_source_manager_v1` (XML in `internal/adapters/wayland/imagecapture/`): `create_workspace_source` (a workspace handle of `ext_workspace_manager_v1`, whether or not the workspace is on screen) and `create_output_region_source` (a rectangle of an output, in logical pixels, clipped). Both give an ordinary `ext_image_capture_source_v1`. A workspace source is the workspace's real layout without bars, not an overview preview; its buffer size follows the workspace frame (new constraints when it changes) and its sessions stop when the workspace is removed. The standard `ext_foreign_toplevel_image_capture_source_manager_v1` turns an `ext_foreign_toplevel_list_v1` handle into a source of that window: its client area and popups, at its own size, rendered off screen wherever the window is (one off-screen target, a window or a hidden workspace, at a time: a session that finds it taken gets no frames until it is free); its buffer size follows the window, and its sessions stop when the window unmaps or closes. A remapped window is a new toplevel with a new identifier; sources made from the old handle are dead. The indicator of a window is the pill on its output, plus its outline while it is on screen. `get_workspace_frame` gives a `neferwl_workspace_frame_v1` that sends the workspace frame (`frame`: x, y, width, height in output-local logical pixels) when created and whenever it changes, since ext-workspace carries no geometry. A region source stops when the output goes away or nothing of the rectangle is left. A workspace that is not on screen uses a separate, demand-driven child renderer and capture pipeline, with its own worker and two staging slots (up to 128 MiB host-visible memory) plus render targets (each image up to 256 MiB, 16384 pixels per side), for one workspace at a time; frames of other sessions on a different hidden workspace are retried while it lasts, then fail (see below). Hidden capture can leave the displayed workspace in direct scanout. Offscreen capture currently requires whole-frame requests matching its exact physical dimensions.
1313
1414 `neferwl_capture_exclusion_manager_v1` leaves a recorder's own HUD out of its frames. The recorder calls `get_exclusion` on its capture session and gets a random token (or `failed`: `busy` when another exclusion is live anywhere in the compositor, `session_stopped`). A HUD, usually on another connection of the same user, attaches up to 4 layer surfaces (top or overlay, not yet mapped, no exclusive keyboard) with the token. They stay visible over fullscreen without keyboard focus; frames of that session omit them and their popups, every other capture (wlr-screencopy included) keeps them. HUD authorization requires the token, a matching socket peer UID, and that the HUD's executable is also allowed to capture (see [Screen capture](capture.md)), never a layer namespace or app-id. The exclusion ends with its session, or when its object is destroyed; its HUD layers that are still listed then stay out of the session's frames (and no longer stay over a fullscreen window) until they are gone, so the HUD never shows in the recorder's frames after the exclusion ended. Output removal and workspace removal stop sessions. An ext-image-copy-capture frame whose target is briefly unavailable (not active yet, its exclusion or indicator not confirmed, the renderer busy) is tried again for up to one second before it fails, so a short hiccup does not end a screencast; a refused client, a stopped session or an error a retry cannot clear (a bad buffer) fails at once. One function of the Wayland adapter (`mayCapture`) decides whether a client may capture, for wlr-screencopy and every ext-image-copy-capture source, and for the attach of a HUD layer to an exclusion; see [Screen capture](capture.md).
1515
@@ -9,7 +9,7 @@ WAYLAND_DISPLAY=<name from the log> foot
9 9
10 Screenshots through `grim` use `zwlr_screencopy_v1` or `ext_image_copy_capture_v1`. Both deliver opaque 8-bit sRGB, including GPU tone mapping of HDR content. Protocol capture requires Vulkan sync-file export; unsupported devices fail the request rather than wait for GPU completion on the output goroutine. The cursor is not included in captures: `overlay_cursor` and `paint_cursors` are ignored. Headless screenshot files include the cursor. If readback is temporarily unavailable, that screenshot frame is skipped without stopping the output or writing a black PNG.10 Screenshots through `grim` use `zwlr_screencopy_v1` or `ext_image_copy_capture_v1`. Both deliver opaque 8-bit sRGB, including GPU tone mapping of HDR content. Protocol capture requires Vulkan sync-file export; unsupported devices fail the request rather than wait for GPU completion on the output goroutine. The cursor is not included in captures: `overlay_cursor` and `paint_cursors` are ignored. Headless screenshot files include the cursor. If readback is temporarily unavailable, that screenshot frame is skipped without stopping the output or writing a black PNG.
11 11
12-Recording goes through `ext_image_copy_capture_v1`, with optional additions from `neferwl_image_capture_source_manager_v1` (XML in `internal/adapters/wayland/imagecapture/`): `create_workspace_source` (a workspace handle of `ext_workspace_manager_v1`, whether or not the workspace is on screen) and `create_output_region_source` (a rectangle of an output, in logical pixels, clipped). Both give an ordinary `ext_image_capture_source_v1`. A workspace source is the workspace's real layout without bars, not an overview preview; its buffer size follows the workspace frame (new constraints when it changes) and its sessions stop when the workspace is removed. `get_workspace_frame` gives a `neferwl_workspace_frame_v1` that sends the workspace frame (`frame`: x, y, width, height in output-local logical pixels) when created and whenever it changes, since ext-workspace carries no geometry. A region source stops when the output goes away or nothing of the rectangle is left. A workspace that is not on screen uses a separate, demand-driven child renderer and capture pipeline, with its own worker and two staging slots (up to 128 MiB host-visible memory) plus render targets (each image up to 256 MiB, 16384 pixels per side), for one workspace at a time; frames of other sessions on a different hidden workspace are retried while it lasts, then fail (see below). Hidden capture can leave the displayed workspace in direct scanout. Offscreen capture currently requires whole-frame requests matching its exact physical dimensions.12+Recording goes through `ext_image_copy_capture_v1`, with optional additions from `neferwl_image_capture_source_manager_v1` (XML in `internal/adapters/wayland/imagecapture/`): `create_workspace_source` (a workspace handle of `ext_workspace_manager_v1`, whether or not the workspace is on screen) and `create_output_region_source` (a rectangle of an output, in logical pixels, clipped). Both give an ordinary `ext_image_capture_source_v1`. A workspace source is the workspace's real layout without bars, not an overview preview; its buffer size follows the workspace frame (new constraints when it changes) and its sessions stop when the workspace is removed. The standard `ext_foreign_toplevel_image_capture_source_manager_v1` turns an `ext_foreign_toplevel_list_v1` handle into a source of that window: its client area and popups, at its own size, rendered off screen wherever the window is (one off-screen target, a window or a hidden workspace, at a time: a session that finds it taken gets no frames until it is free); its buffer size follows the window, and its sessions stop when the window unmaps or closes. A remapped window is a new toplevel with a new identifier; sources made from the old handle are dead. The indicator of a window is the pill on its output, plus its outline while it is on screen. `get_workspace_frame` gives a `neferwl_workspace_frame_v1` that sends the workspace frame (`frame`: x, y, width, height in output-local logical pixels) when created and whenever it changes, since ext-workspace carries no geometry. A region source stops when the output goes away or nothing of the rectangle is left. A workspace that is not on screen uses a separate, demand-driven child renderer and capture pipeline, with its own worker and two staging slots (up to 128 MiB host-visible memory) plus render targets (each image up to 256 MiB, 16384 pixels per side), for one workspace at a time; frames of other sessions on a different hidden workspace are retried while it lasts, then fail (see below). Hidden capture can leave the displayed workspace in direct scanout. Offscreen capture currently requires whole-frame requests matching its exact physical dimensions.
13 13
14 `neferwl_capture_exclusion_manager_v1` leaves a recorder's own HUD out of its frames. The recorder calls `get_exclusion` on its capture session and gets a random token (or `failed`: `busy` when another exclusion is live anywhere in the compositor, `session_stopped`). A HUD, usually on another connection of the same user, attaches up to 4 layer surfaces (top or overlay, not yet mapped, no exclusive keyboard) with the token. They stay visible over fullscreen without keyboard focus; frames of that session omit them and their popups, every other capture (wlr-screencopy included) keeps them. HUD authorization requires the token, a matching socket peer UID, and that the HUD's executable is also allowed to capture (see [Screen capture](capture.md)), never a layer namespace or app-id. The exclusion ends with its session, or when its object is destroyed; its HUD layers that are still listed then stay out of the session's frames (and no longer stay over a fullscreen window) until they are gone, so the HUD never shows in the recorder's frames after the exclusion ended. Output removal and workspace removal stop sessions. An ext-image-copy-capture frame whose target is briefly unavailable (not active yet, its exclusion or indicator not confirmed, the renderer busy) is tried again for up to one second before it fails, so a short hiccup does not end a screencast; a refused client, a stopped session or an error a retry cannot clear (a bad buffer) fails at once. One function of the Wayland adapter (`mayCapture`) decides whether a client may capture, for wlr-screencopy and every ext-image-copy-capture source, and for the attach of a HUD layer to an exclusion; see [Screen capture](capture.md).14 `neferwl_capture_exclusion_manager_v1` leaves a recorder's own HUD out of its frames. The recorder calls `get_exclusion` on its capture session and gets a random token (or `failed`: `busy` when another exclusion is live anywhere in the compositor, `session_stopped`). A HUD, usually on another connection of the same user, attaches up to 4 layer surfaces (top or overlay, not yet mapped, no exclusive keyboard) with the token. They stay visible over fullscreen without keyboard focus; frames of that session omit them and their popups, every other capture (wlr-screencopy included) keeps them. HUD authorization requires the token, a matching socket peer UID, and that the HUD's executable is also allowed to capture (see [Screen capture](capture.md)), never a layer namespace or app-id. The exclusion ends with its session, or when its object is destroyed; its HUD layers that are still listed then stay out of the session's frames (and no longer stay over a fullscreen window) until they are gone, so the HUD never shows in the recorder's frames after the exclusion ended. Output removal and workspace removal stop sessions. An ext-image-copy-capture frame whose target is briefly unavailable (not active yet, its exclusion or indicator not confirmed, the renderer busy) is tried again for up to one second before it fails, so a short hiccup does not end a screencast; a refused client, a stopped session or an error a retry cannot clear (a bad buffer) fails at once. One function of the Wayland adapter (`mayCapture`) decides whether a client may capture, for wlr-screencopy and every ext-image-copy-capture source, and for the attach of a HUD layer to an exclusion; see [Screen capture](capture.md).
15 15
M internal/adapters/capture/excluded.go +5 -2
@@ -24,8 +24,9 @@ var ErrSessionInactive = fmt.Errorf("%w: capture exclusion is not active", ports
2424 var ErrSessionStale = fmt.Errorf("%w: capture exclusion scene is older than the request", ports.ErrCaptureTransient)
2525
2626 // ErrWorkspaceMoved fails a workspace request whose workspace is neither on
27-// screen nor the one rendered off screen in the scene: it moved, went away,
28-// or the scene predates the session.
27+// screen nor the one rendered off screen in the scene, or a window request
28+// whose window is not the one rendered off screen: it moved, went away, or
29+// the scene predates the session.
2930 var ErrWorkspaceMoved = fmt.Errorf("%w: captured workspace is not where the request expected", ports.ErrCaptureTransient)
3031
3132 // excluder derives the scene of Exclude captures: the frame without the
@@ -151,6 +152,8 @@ func (p *Pipeline) Split(s ports.Scene, reqs []ports.CaptureRequest) (normal, ex
151152 err = ErrSessionStale
152153 case q.Workspace != 0 && (s.Capture == nil || q.OffScreen && q.Workspace != s.Capture.Workspace || !q.OffScreen && q.Workspace != s.Capture.Shown):
153154 err = ErrWorkspaceMoved
155+ case q.Window != 0 && (s.Capture == nil || !q.OffScreen || q.Window != s.Capture.Window):
156+ err = ErrWorkspaceMoved
154157 }
155158 if err != nil {
156159 Fail(p.ctx, q, err, p.replies)
@@ -24,8 +24,9 @@ var ErrSessionInactive = fmt.Errorf("%w: capture exclusion is not active", ports
24 var ErrSessionStale = fmt.Errorf("%w: capture exclusion scene is older than the request", ports.ErrCaptureTransient)24 var ErrSessionStale = fmt.Errorf("%w: capture exclusion scene is older than the request", ports.ErrCaptureTransient)
25 25
26 // ErrWorkspaceMoved fails a workspace request whose workspace is neither on26 // ErrWorkspaceMoved fails a workspace request whose workspace is neither on
27-// screen nor the one rendered off screen in the scene: it moved, went away,27+// screen nor the one rendered off screen in the scene, or a window request
28-// or the scene predates the session.28+// whose window is not the one rendered off screen: it moved, went away, or
29+// the scene predates the session.
29 var ErrWorkspaceMoved = fmt.Errorf("%w: captured workspace is not where the request expected", ports.ErrCaptureTransient)30 var ErrWorkspaceMoved = fmt.Errorf("%w: captured workspace is not where the request expected", ports.ErrCaptureTransient)
30 31
31 // excluder derives the scene of Exclude captures: the frame without the32 // excluder derives the scene of Exclude captures: the frame without the
@@ -151,6 +152,8 @@ func (p *Pipeline) Split(s ports.Scene, reqs []ports.CaptureRequest) (normal, ex
151 err = ErrSessionStale152 err = ErrSessionStale
152 case q.Workspace != 0 && (s.Capture == nil || q.OffScreen && q.Workspace != s.Capture.Workspace || !q.OffScreen && q.Workspace != s.Capture.Shown):153 case q.Workspace != 0 && (s.Capture == nil || q.OffScreen && q.Workspace != s.Capture.Workspace || !q.OffScreen && q.Workspace != s.Capture.Shown):
153 err = ErrWorkspaceMoved154 err = ErrWorkspaceMoved
155+ case q.Window != 0 && (s.Capture == nil || !q.OffScreen || q.Window != s.Capture.Window):
156+ err = ErrWorkspaceMoved
154 }157 }
155 if err != nil {158 if err != nil {
156 Fail(p.ctx, q, err, p.replies)159 Fail(p.ctx, q, err, p.replies)
M internal/adapters/capture/excluded_test.go +21 -0
@@ -174,3 +174,24 @@ func TestSplitWorkspaceMovedFailsClosed(t *testing.T) {
174174 require.Empty(t, hidden)
175175 require.ErrorIs(t, awaitCapture(t, replies).Err, ErrWorkspaceMoved)
176176 }
177+
178+// A window request is served off screen only while the scene renders that
179+// window; any other is failed, never served from the displayed frame.
180+func TestSplitWindowFailsClosed(t *testing.T) {
181+ replies := make(chan ports.CaptureDone, 8)
182+ p := NewPipeline(context.Background(), replies)
183+ defer p.Close(nil)
184+ s := ports.Scene{Capture: &ports.SceneCapture{Window: 7}}
185+ reqs := []ports.CaptureRequest{
186+ {ID: 1, Window: 7, OffScreen: true}, // rendered
187+ {ID: 2, Window: 8, OffScreen: true}, // another window
188+ {ID: 3, Window: 7}, // never on screen
189+ }
190+ normal, excluded, hidden := p.Split(s, reqs)
191+ require.Empty(t, normal)
192+ require.Empty(t, excluded)
193+ require.Equal(t, []uint64{1}, ids(hidden))
194+ for range 2 {
195+ require.ErrorIs(t, awaitCapture(t, replies).Err, ErrWorkspaceMoved)
196+ }
197+}
@@ -174,3 +174,24 @@ func TestSplitWorkspaceMovedFailsClosed(t *testing.T) {
174 require.Empty(t, hidden)174 require.Empty(t, hidden)
175 require.ErrorIs(t, awaitCapture(t, replies).Err, ErrWorkspaceMoved)175 require.ErrorIs(t, awaitCapture(t, replies).Err, ErrWorkspaceMoved)
176 }176 }
177+
178+// A window request is served off screen only while the scene renders that
179+// window; any other is failed, never served from the displayed frame.
180+func TestSplitWindowFailsClosed(t *testing.T) {
181+ replies := make(chan ports.CaptureDone, 8)
182+ p := NewPipeline(context.Background(), replies)
183+ defer p.Close(nil)
184+ s := ports.Scene{Capture: &ports.SceneCapture{Window: 7}}
185+ reqs := []ports.CaptureRequest{
186+ {ID: 1, Window: 7, OffScreen: true}, // rendered
187+ {ID: 2, Window: 8, OffScreen: true}, // another window
188+ {ID: 3, Window: 7}, // never on screen
189+ }
190+ normal, excluded, hidden := p.Split(s, reqs)
191+ require.Empty(t, normal)
192+ require.Empty(t, excluded)
193+ require.Equal(t, []uint64{1}, ids(hidden))
194+ for range 2 {
195+ require.ErrorIs(t, awaitCapture(t, replies).Err, ErrWorkspaceMoved)
196+ }
197+}
M internal/adapters/capture/offscreen.go +19 -12
@@ -14,9 +14,9 @@ import (
1414 "golang.org/x/sys/unix"
1515 )
1616
17-// Offscreen capture: a session of a workspace that is not on screen is drawn
18-// by a dedicated child renderer from Scene.CaptureScene, only for requests
19-// of that workspace and never shown. The child is a separate renderer instance: it has
17+// Offscreen capture: a session of a workspace that is not on screen, or of a
18+// window (always), is drawn by a dedicated child renderer from
19+// Scene.CaptureScene, only for requests of that target and never shown. The child is a separate renderer instance: it has
2020 // its own Vulkan device, so its fences are not ordered against the display's.
2121 // It is created on the first request of the workspace and closed when the
2222 // scene stops carrying CaptureScene (once idle), when the workspace or the frame
@@ -63,13 +63,19 @@ func (s *fenceSlot) release() {
6363 clear(s.ceil)
6464 }
6565
66+// offTarget is what the child draws: a hidden workspace or a window.
67+type offTarget struct {
68+ workspace uint64
69+ window ports.WindowID
70+}
71+
6672 // offscreen is owned by the goroutine of its Pipeline.
6773 type offscreen struct {
68- factory func(w, h int) (ports.Renderer, error)
69- r ports.Renderer
70- p *Pipeline // leases of r
71- w, h int
72- workspace uint64
74+ factory func(w, h int) (ports.Renderer, error)
75+ r ports.Renderer
76+ p *Pipeline // leases of r
77+ w, h int
78+ target offTarget
7379 // slots[:nf] are the unfinished child renders, oldest first.
7480 slots [maxChildFences]fenceSlot
7581 nf int
@@ -292,7 +298,7 @@ func (o *offscreen) close() {
292298 for i := range o.slots {
293299 o.slots[i].release()
294300 }
295- o.nf, o.w, o.h, o.workspace = 0, 0, 0, 0
301+ o.nf, o.w, o.h, o.target = 0, 0, 0, offTarget{}
296302 }
297303
298304 // childSize is the physical child image: the workspace frame at the scene
@@ -350,7 +356,7 @@ func (p *Pipeline) SubmitHidden(s ports.Scene, surfaces map[ports.WindowID]ports
350356 case s.Off:
351357 fail(errors.New("output off"), reqs)
352358 return
353- case s.CaptureScene == nil || s.Capture == nil || s.Capture.Workspace == 0:
359+ case s.CaptureScene == nil || s.Capture == nil || s.Capture.Workspace == 0 && s.Capture.Window == 0:
354360 fail(ErrOffscreenUnavailable, reqs)
355361 return
356362 case o == nil || o.factory == nil:
@@ -366,7 +372,8 @@ func (p *Pipeline) SubmitHidden(s ports.Scene, surfaces map[ports.WindowID]ports
366372 fail(err, reqs)
367373 return
368374 }
369- if o.r != nil && (o.workspace != s.Capture.Workspace || o.w != w || o.h != h) {
375+ target := offTarget{workspace: s.Capture.Workspace, window: s.Capture.Window}
376+ if o.r != nil && (o.target != target || o.w != w || o.h != h) {
370377 if o.busy() {
371378 fail(ErrOffscreenBusy, reqs)
372379 return
@@ -385,7 +392,7 @@ func (p *Pipeline) SubmitHidden(s ports.Scene, surfaces map[ports.WindowID]ports
385392 fail(fmt.Errorf("%w: %v", ErrOffscreenUnavailable, err), reqs)
386393 return
387394 }
388- o.r, o.p, o.w, o.h, o.workspace = r, NewPipeline(p.ctx, p.replies), w, h, s.Capture.Workspace
395+ o.r, o.p, o.w, o.h, o.target = r, NewPipeline(p.ctx, p.replies), w, h, target
389396 o.p.Security = p.Security
390397 }
391398 // Request regions are physical pixels of the output, and the scene does
@@ -14,9 +14,9 @@ import (
14 "golang.org/x/sys/unix"14 "golang.org/x/sys/unix"
15 )15 )
16 16
17-// Offscreen capture: a session of a workspace that is not on screen is drawn17+// Offscreen capture: a session of a workspace that is not on screen, or of a
18-// by a dedicated child renderer from Scene.CaptureScene, only for requests18+// window (always), is drawn by a dedicated child renderer from
19-// of that workspace and never shown. The child is a separate renderer instance: it has19+// Scene.CaptureScene, only for requests of that target and never shown. The child is a separate renderer instance: it has
20 // its own Vulkan device, so its fences are not ordered against the display's.20 // its own Vulkan device, so its fences are not ordered against the display's.
21 // It is created on the first request of the workspace and closed when the21 // It is created on the first request of the workspace and closed when the
22 // scene stops carrying CaptureScene (once idle), when the workspace or the frame22 // scene stops carrying CaptureScene (once idle), when the workspace or the frame
@@ -63,13 +63,19 @@ func (s *fenceSlot) release() {
63 clear(s.ceil)63 clear(s.ceil)
64 }64 }
65 65
66+// offTarget is what the child draws: a hidden workspace or a window.
67+type offTarget struct {
68+ workspace uint64
69+ window ports.WindowID
70+}
71+
66 // offscreen is owned by the goroutine of its Pipeline.72 // offscreen is owned by the goroutine of its Pipeline.
67 type offscreen struct {73 type offscreen struct {
68- factory func(w, h int) (ports.Renderer, error)74+ factory func(w, h int) (ports.Renderer, error)
69- r ports.Renderer75+ r ports.Renderer
70- p *Pipeline // leases of r76+ p *Pipeline // leases of r
71- w, h int77+ w, h int
72- workspace uint6478+ target offTarget
73 // slots[:nf] are the unfinished child renders, oldest first.79 // slots[:nf] are the unfinished child renders, oldest first.
74 slots [maxChildFences]fenceSlot80 slots [maxChildFences]fenceSlot
75 nf int81 nf int
@@ -292,7 +298,7 @@ func (o *offscreen) close() {
292 for i := range o.slots {298 for i := range o.slots {
293 o.slots[i].release()299 o.slots[i].release()
294 }300 }
295- o.nf, o.w, o.h, o.workspace = 0, 0, 0, 0301+ o.nf, o.w, o.h, o.target = 0, 0, 0, offTarget{}
296 }302 }
297 303
298 // childSize is the physical child image: the workspace frame at the scene304 // childSize is the physical child image: the workspace frame at the scene
@@ -350,7 +356,7 @@ func (p *Pipeline) SubmitHidden(s ports.Scene, surfaces map[ports.WindowID]ports
350 case s.Off:356 case s.Off:
351 fail(errors.New("output off"), reqs)357 fail(errors.New("output off"), reqs)
352 return358 return
353- case s.CaptureScene == nil || s.Capture == nil || s.Capture.Workspace == 0:359+ case s.CaptureScene == nil || s.Capture == nil || s.Capture.Workspace == 0 && s.Capture.Window == 0:
354 fail(ErrOffscreenUnavailable, reqs)360 fail(ErrOffscreenUnavailable, reqs)
355 return361 return
356 case o == nil || o.factory == nil:362 case o == nil || o.factory == nil:
@@ -366,7 +372,8 @@ func (p *Pipeline) SubmitHidden(s ports.Scene, surfaces map[ports.WindowID]ports
366 fail(err, reqs)372 fail(err, reqs)
367 return373 return
368 }374 }
369- if o.r != nil && (o.workspace != s.Capture.Workspace || o.w != w || o.h != h) {375+ target := offTarget{workspace: s.Capture.Workspace, window: s.Capture.Window}
376+ if o.r != nil && (o.target != target || o.w != w || o.h != h) {
370 if o.busy() {377 if o.busy() {
371 fail(ErrOffscreenBusy, reqs)378 fail(ErrOffscreenBusy, reqs)
372 return379 return
@@ -385,7 +392,7 @@ func (p *Pipeline) SubmitHidden(s ports.Scene, surfaces map[ports.WindowID]ports
385 fail(fmt.Errorf("%w: %v", ErrOffscreenUnavailable, err), reqs)392 fail(fmt.Errorf("%w: %v", ErrOffscreenUnavailable, err), reqs)
386 return393 return
387 }394 }
388- o.r, o.p, o.w, o.h, o.workspace = r, NewPipeline(p.ctx, p.replies), w, h, s.Capture.Workspace395+ o.r, o.p, o.w, o.h, o.target = r, NewPipeline(p.ctx, p.replies), w, h, target
389 o.p.Security = p.Security396 o.p.Security = p.Security
390 }397 }
391 // Request regions are physical pixels of the output, and the scene does398 // Request regions are physical pixels of the output, and the scene does
M internal/adapters/capture/offscreen_test.go +20 -0
@@ -183,3 +183,23 @@ func TestHiddenNilPipelineSafe(t *testing.T) {
183183 require.False(t, p.HiddenReading())
184184 p.Retire(ports.Scene{})
185185 }
186+
187+// A window is drawn by the child like a hidden workspace; another window or
188+// a workspace needs a new child.
189+func TestSubmitHiddenWindow(t *testing.T) {
190+ replies := make(chan ports.CaptureDone, 4)
191+ p := NewPipeline(context.Background(), replies)
192+ child, _ := childMock(t, 200, 100)
193+ child.EXPECT().Render(mock.Anything, mock.Anything).Return(nil, nil).Once()
194+ made := 0
195+ p.EnableOffscreen(func(w, h int) (ports.Renderer, error) { made++; return child, nil })
196+ s := hiddenScene()
197+ s.Capture = &ports.SceneCapture{Window: 5}
198+ q := hiddenRequest(t, 1, image.Rect(0, 0, 200, 100))
199+ q.Workspace, q.Window = 0, 5
200+ p.SubmitHidden(s, nil, []ports.CaptureRequest{q})
201+ require.NoError(t, awaitCapture(t, replies).Err)
202+ require.Equal(t, 1, made)
203+ require.Equal(t, offTarget{window: 5}, p.off.target)
204+ p.Close(nil)
205+}
@@ -183,3 +183,23 @@ func TestHiddenNilPipelineSafe(t *testing.T) {
183 require.False(t, p.HiddenReading())183 require.False(t, p.HiddenReading())
184 p.Retire(ports.Scene{})184 p.Retire(ports.Scene{})
185 }185 }
186+
187+// A window is drawn by the child like a hidden workspace; another window or
188+// a workspace needs a new child.
189+func TestSubmitHiddenWindow(t *testing.T) {
190+ replies := make(chan ports.CaptureDone, 4)
191+ p := NewPipeline(context.Background(), replies)
192+ child, _ := childMock(t, 200, 100)
193+ child.EXPECT().Render(mock.Anything, mock.Anything).Return(nil, nil).Once()
194+ made := 0
195+ p.EnableOffscreen(func(w, h int) (ports.Renderer, error) { made++; return child, nil })
196+ s := hiddenScene()
197+ s.Capture = &ports.SceneCapture{Window: 5}
198+ q := hiddenRequest(t, 1, image.Rect(0, 0, 200, 100))
199+ q.Workspace, q.Window = 0, 5
200+ p.SubmitHidden(s, nil, []ports.CaptureRequest{q})
201+ require.NoError(t, awaitCapture(t, replies).Err)
202+ require.Equal(t, 1, made)
203+ require.Equal(t, offTarget{window: 5}, p.off.target)
204+ p.Close(nil)
205+}
A internal/adapters/wayland/extforeigntoplevel.go +156 -0
@@ -0,0 +1,156 @@
1+package wayland
2+
3+import (
4+ "slices"
5+ "strconv"
6+
7+ ext "github.com/bnema/go-wayland-bindings/server/extforeigntoplevellist"
8+ "github.com/bnema/neferwl/internal/ports"
9+ "github.com/bnema/purego-libwayland/server"
10+)
11+
12+// ext-foreign-toplevel-list lists mapped toplevels, read-only: title, app_id
13+// and a stable identifier. xdg-desktop-portal-wlr lists the windows it can
14+// share with it, and ext_foreign_toplevel_image_capture_source_manager_v1
15+// takes its handles (window capture, imagecopy.go). It follows the same
16+// window state and session-protection rules as wlr-foreign-toplevel.
17+
18+type extToplevelList struct {
19+ s *Server
20+ res *ext.ExtForeignToplevelListV1
21+ stopped bool
22+ handles map[ports.WindowID]*extToplevelHandle
23+}
24+
25+// extToplevelHandle is one window seen by one list, with what it was last
26+// sent so that only changes go out.
27+type extToplevelHandle struct {
28+ res *ext.ExtForeignToplevelHandleV1
29+ window ports.WindowID
30+ gen uint32 // the window's mapping it was made for
31+ closed bool
32+ title, appID string
33+}
34+
35+func (extToplevelHandle) Destroy(*ext.ExtForeignToplevelHandleV1) {}
36+
37+// toplevelIdentifier is the ext-foreign-toplevel identifier of a window's
38+// mapping: window IDs are never reused and a remap counts a new mapping, so
39+// neither is the identifier.
40+func toplevelIdentifier(id ports.WindowID, gen uint32) string {
41+ return "neferwl-" + strconv.FormatUint(uint64(id), 10) + "-" + strconv.FormatUint(uint64(gen), 10)
42+}
43+
44+func registerExtForeignToplevel(d *server.Display, s *Server) error {
45+ return ext.NewExtForeignToplevelListV1Global(d, 1, func(c server.Client, v, id uint32) {
46+ l := &extToplevelList{s: s, handles: map[ports.WindowID]*extToplevelHandle{}}
47+ r, err := ext.NewExtForeignToplevelListV1(c, int32(v), id, l)
48+ if err != nil {
49+ return
50+ }
51+ l.res = r
52+ s.extToplevelLists = append(s.extToplevelLists, l)
53+ r.OnDestroy = func() { s.extToplevelLists = removeItem(s.extToplevelLists, l) }
54+ for _, id := range s.mappedToplevels() {
55+ l.refresh(s.windows[id])
56+ }
57+ })
58+}
59+
60+// Stop ends the list: finished, and no more toplevel events.
61+func (l *extToplevelList) Stop(*ext.ExtForeignToplevelListV1) {
62+ if l.stopped {
63+ return
64+ }
65+ l.stopped = true
66+ l.res.SendFinished()
67+}
68+
69+func (*extToplevelList) Destroy(*ext.ExtForeignToplevelListV1) {}
70+
71+// mappedToplevels lists the mapped toplevels, by ID.
72+func (s *Server) mappedToplevels() []ports.WindowID {
73+ ids := make([]ports.WindowID, 0, len(s.windows))
74+ for id, w := range s.windows {
75+ if w.toplevel != nil && w.mapped {
76+ ids = append(ids, id)
77+ }
78+ }
79+ slices.Sort(ids)
80+ return ids
81+}
82+
83+// refresh announces w or sends what changed since the last done. After
84+// stop no new toplevel is announced; the handles already sent stay live.
85+func (l *extToplevelList) refresh(w *window) {
86+ if l.s.protected() || !l.res.Alive() {
87+ return
88+ }
89+ h := l.handles[w.id]
90+ if h != nil && (h.closed || h.gen != w.gen) {
91+ l.closed(w.id)
92+ h = nil
93+ }
94+ fresh := h == nil
95+ if fresh && l.stopped {
96+ return
97+ }
98+ if fresh {
99+ h = &extToplevelHandle{window: w.id, gen: w.gen}
100+ r, err := ext.NewExtForeignToplevelHandleV1(l.res.Client(), l.res.Version(), 0, h)
101+ if err != nil {
102+ return
103+ }
104+ h.res = r
105+ l.handles[w.id] = h
106+ l.res.SendToplevel(r)
107+ r.SendIdentifier(toplevelIdentifier(w.id, w.gen))
108+ }
109+ if !h.res.Alive() {
110+ return
111+ }
112+ changed := fresh
113+ if fresh || h.title != w.title {
114+ h.title = w.title
115+ h.res.SendTitle(w.title)
116+ changed = true
117+ }
118+ if fresh || h.appID != w.appID {
119+ h.appID = w.appID
120+ h.res.SendAppId(w.appID)
121+ changed = true
122+ }
123+ if changed {
124+ h.res.SendDone()
125+ }
126+}
127+
128+// closed sends closed for a window; like wlr-foreign-toplevel, a close
129+// during session protection is sent at the unlock.
130+func (l *extToplevelList) closed(id ports.WindowID) {
131+ h := l.handles[id]
132+ if h == nil {
133+ return
134+ }
135+ h.closed = true
136+ if l.s.protected() {
137+ return
138+ }
139+ delete(l.handles, id)
140+ if h.res.Alive() {
141+ h.res.SendClosed()
142+ }
143+}
144+
145+// extHandleWindow is the window mapping behind an
146+// ext_foreign_toplevel_handle_v1 of any list, while it is open.
147+func (s *Server) extHandleWindow(res *server.Resource) (ports.WindowID, uint32, bool) {
148+ for _, l := range s.extToplevelLists {
149+ for id, h := range l.handles {
150+ if h.res.Resource == res && !h.closed {
151+ return id, h.gen, true
152+ }
153+ }
154+ }
155+ return 0, 0, false
156+}
@@ -0,0 +1,156 @@
1+package wayland
2+
3+import (
4+ "slices"
5+ "strconv"
6+
7+ ext "github.com/bnema/go-wayland-bindings/server/extforeigntoplevellist"
8+ "github.com/bnema/neferwl/internal/ports"
9+ "github.com/bnema/purego-libwayland/server"
10+)
11+
12+// ext-foreign-toplevel-list lists mapped toplevels, read-only: title, app_id
13+// and a stable identifier. xdg-desktop-portal-wlr lists the windows it can
14+// share with it, and ext_foreign_toplevel_image_capture_source_manager_v1
15+// takes its handles (window capture, imagecopy.go). It follows the same
16+// window state and session-protection rules as wlr-foreign-toplevel.
17+
18+type extToplevelList struct {
19+ s *Server
20+ res *ext.ExtForeignToplevelListV1
21+ stopped bool
22+ handles map[ports.WindowID]*extToplevelHandle
23+}
24+
25+// extToplevelHandle is one window seen by one list, with what it was last
26+// sent so that only changes go out.
27+type extToplevelHandle struct {
28+ res *ext.ExtForeignToplevelHandleV1
29+ window ports.WindowID
30+ gen uint32 // the window's mapping it was made for
31+ closed bool
32+ title, appID string
33+}
34+
35+func (extToplevelHandle) Destroy(*ext.ExtForeignToplevelHandleV1) {}
36+
37+// toplevelIdentifier is the ext-foreign-toplevel identifier of a window's
38+// mapping: window IDs are never reused and a remap counts a new mapping, so
39+// neither is the identifier.
40+func toplevelIdentifier(id ports.WindowID, gen uint32) string {
41+ return "neferwl-" + strconv.FormatUint(uint64(id), 10) + "-" + strconv.FormatUint(uint64(gen), 10)
42+}
43+
44+func registerExtForeignToplevel(d *server.Display, s *Server) error {
45+ return ext.NewExtForeignToplevelListV1Global(d, 1, func(c server.Client, v, id uint32) {
46+ l := &extToplevelList{s: s, handles: map[ports.WindowID]*extToplevelHandle{}}
47+ r, err := ext.NewExtForeignToplevelListV1(c, int32(v), id, l)
48+ if err != nil {
49+ return
50+ }
51+ l.res = r
52+ s.extToplevelLists = append(s.extToplevelLists, l)
53+ r.OnDestroy = func() { s.extToplevelLists = removeItem(s.extToplevelLists, l) }
54+ for _, id := range s.mappedToplevels() {
55+ l.refresh(s.windows[id])
56+ }
57+ })
58+}
59+
60+// Stop ends the list: finished, and no more toplevel events.
61+func (l *extToplevelList) Stop(*ext.ExtForeignToplevelListV1) {
62+ if l.stopped {
63+ return
64+ }
65+ l.stopped = true
66+ l.res.SendFinished()
67+}
68+
69+func (*extToplevelList) Destroy(*ext.ExtForeignToplevelListV1) {}
70+
71+// mappedToplevels lists the mapped toplevels, by ID.
72+func (s *Server) mappedToplevels() []ports.WindowID {
73+ ids := make([]ports.WindowID, 0, len(s.windows))
74+ for id, w := range s.windows {
75+ if w.toplevel != nil && w.mapped {
76+ ids = append(ids, id)
77+ }
78+ }
79+ slices.Sort(ids)
80+ return ids
81+}
82+
83+// refresh announces w or sends what changed since the last done. After
84+// stop no new toplevel is announced; the handles already sent stay live.
85+func (l *extToplevelList) refresh(w *window) {
86+ if l.s.protected() || !l.res.Alive() {
87+ return
88+ }
89+ h := l.handles[w.id]
90+ if h != nil && (h.closed || h.gen != w.gen) {
91+ l.closed(w.id)
92+ h = nil
93+ }
94+ fresh := h == nil
95+ if fresh && l.stopped {
96+ return
97+ }
98+ if fresh {
99+ h = &extToplevelHandle{window: w.id, gen: w.gen}
100+ r, err := ext.NewExtForeignToplevelHandleV1(l.res.Client(), l.res.Version(), 0, h)
101+ if err != nil {
102+ return
103+ }
104+ h.res = r
105+ l.handles[w.id] = h
106+ l.res.SendToplevel(r)
107+ r.SendIdentifier(toplevelIdentifier(w.id, w.gen))
108+ }
109+ if !h.res.Alive() {
110+ return
111+ }
112+ changed := fresh
113+ if fresh || h.title != w.title {
114+ h.title = w.title
115+ h.res.SendTitle(w.title)
116+ changed = true
117+ }
118+ if fresh || h.appID != w.appID {
119+ h.appID = w.appID
120+ h.res.SendAppId(w.appID)
121+ changed = true
122+ }
123+ if changed {
124+ h.res.SendDone()
125+ }
126+}
127+
128+// closed sends closed for a window; like wlr-foreign-toplevel, a close
129+// during session protection is sent at the unlock.
130+func (l *extToplevelList) closed(id ports.WindowID) {
131+ h := l.handles[id]
132+ if h == nil {
133+ return
134+ }
135+ h.closed = true
136+ if l.s.protected() {
137+ return
138+ }
139+ delete(l.handles, id)
140+ if h.res.Alive() {
141+ h.res.SendClosed()
142+ }
143+}
144+
145+// extHandleWindow is the window mapping behind an
146+// ext_foreign_toplevel_handle_v1 of any list, while it is open.
147+func (s *Server) extHandleWindow(res *server.Resource) (ports.WindowID, uint32, bool) {
148+ for _, l := range s.extToplevelLists {
149+ for id, h := range l.handles {
150+ if h.res.Resource == res && !h.closed {
151+ return id, h.gen, true
152+ }
153+ }
154+ }
155+ return 0, 0, false
156+}
A internal/adapters/wayland/extforeigntoplevel_test.go +170 -0
@@ -0,0 +1,170 @@
1+package wayland
2+
3+import (
4+ "slices"
5+ "testing"
6+
7+ ext "github.com/bnema/go-wayland-bindings/server/extforeigntoplevellist"
8+ source "github.com/bnema/go-wayland-bindings/server/extimagecapturesource"
9+ "github.com/bnema/go-wayland-bindings/server/wayland"
10+ xdgshell "github.com/bnema/go-wayland-bindings/server/xdgshell"
11+ "github.com/bnema/neferwl/internal/ports"
12+ "github.com/bnema/wlturbo"
13+)
14+
15+// extListEvents registers a handle proxy for each announced toplevel.
16+type extListEvents struct {
17+ wlturbo.BaseProxy
18+ client *wlturbo.Display
19+ handles chan uint32
20+ events chan string
21+}
22+
23+func (p *extListEvents) Dispatch(e *wlturbo.Event) {
24+ switch uint32(e.Opcode) {
25+ case ext.ExtForeignToplevelListV1EventToplevel:
26+ id := e.Uint32()
27+ h := &extHandleEvents{events: p.events}
28+ h.SetID(id)
29+ registerWireProxy(p.client, h)
30+ p.handles <- id
31+ case ext.ExtForeignToplevelListV1EventFinished:
32+ p.events <- "finished"
33+ }
34+}
35+
36+// extHandleEvents records handle events as text.
37+type extHandleEvents struct {
38+ wlturbo.BaseProxy
39+ events chan string
40+}
41+
42+func (p *extHandleEvents) Dispatch(e *wlturbo.Event) {
43+ switch uint32(e.Opcode) {
44+ case ext.ExtForeignToplevelHandleV1EventIdentifier:
45+ p.events <- "identifier " + e.String()
46+ case ext.ExtForeignToplevelHandleV1EventTitle:
47+ p.events <- "title " + e.String()
48+ case ext.ExtForeignToplevelHandleV1EventAppId:
49+ p.events <- "app_id " + e.String()
50+ case ext.ExtForeignToplevelHandleV1EventDone:
51+ p.events <- "done"
52+ case ext.ExtForeignToplevelHandleV1EventClosed:
53+ p.events <- "closed"
54+ }
55+}
56+
57+// The ext list announces mapped windows with a stable identifier, sends
58+// changes, and closed when a window unmaps.
59+func TestExtForeignToplevelList(t *testing.T) {
60+ s, events, _, dir := lifecycleServer(t)
61+ c := protocolClient(t, s, dir)
62+ w, _, xdg := surfaceMapper(t, c, events)()
63+ registerProtocol(t, c, xdg)
64+ top := xdg + 1
65+ requestProtocol(t, c, top, xdgshell.ToplevelRequestSetTitle, "editor")
66+ requestProtocol(t, c, top, xdgshell.ToplevelRequestSetAppId, "app.editor")
67+
68+ list := bindProtocol(t, c, "ext_foreign_toplevel_list_v1")
69+ p := &extListEvents{client: c, handles: make(chan uint32, 4), events: make(chan string, 64)}
70+ p.SetID(list)
71+ registerWireProxy(c, p)
72+ want := []string{"identifier " + toplevelIdentifier(w.ID, 1), "title editor", "app_id app.editor", "done"}
73+ if got := takeEvents(t, c, p.events); !slices.Equal(got, want) {
74+ t.Fatalf("announce = %q, want %q", got, want)
75+ }
76+ <-p.handles
77+ requestProtocol(t, c, top, xdgshell.ToplevelRequestSetTitle, "editor - file")
78+ if got, want := takeEvents(t, c, p.events), []string{"title editor - file", "done"}; !slices.Equal(got, want) {
79+ t.Fatalf("title = %q, want %q", got, want)
80+ }
81+
82+ // After stop, a handle already sent still follows its window.
83+ requestProtocol(t, c, list, ext.ExtForeignToplevelListV1RequestStop)
84+ if err := c.Roundtrip(); err != nil {
85+ t.Fatal(err)
86+ }
87+ if ev := <-p.events; ev != "finished" {
88+ t.Fatalf("stop = %q", ev)
89+ }
90+ requestProtocol(t, c, top, xdgshell.ToplevelRequestSetTitle, "editor - other")
91+ if got, want := takeEvents(t, c, p.events), []string{"title editor - other", "done"}; !slices.Equal(got, want) {
92+ t.Fatalf("title after stop = %q, want %q", got, want)
93+ }
94+ requestProtocol(t, c, top, xdgshell.ToplevelRequestDestroy)
95+ if got := takeEvents(t, c, p.events); !slices.Equal(got, []string{"closed"}) {
96+ t.Fatalf("close = %q", got)
97+ }
98+}
99+
100+// A handle of the list makes a window capture source: its session opens on
101+// the window in core, its frames ask for that window off screen, and the
102+// session stops when the window goes.
103+func TestWindowCaptureSource(t *testing.T) {
104+ h := newCaptureHarness(t)
105+ cc := h.client(t)
106+ win, surf, xdg := surfaceMapper(t, cc.c, h.events)()
107+ registerProtocol(t, cc.c, xdg)
108+ h.send(ports.ConfigureWindow{ID: win.ID, Width: 3, Height: 2, Output: "HEADLESS-1", Visible: true})
109+ h.settle(t, cc)
110+
111+ list := bindProtocol(t, cc.c, "ext_foreign_toplevel_list_v1")
112+ p := &extListEvents{client: cc.c, handles: make(chan uint32, 4), events: make(chan string, 64)}
113+ p.SetID(list)
114+ registerWireProxy(cc.c, p)
115+ takeEvents(t, cc.c, p.events)
116+ handle := <-p.handles
117+
118+ mgr := bindProtocol(t, cc.c, "ext_foreign_toplevel_image_capture_source_manager_v1")
119+ src := cc.c.AllocateID()
120+ registerProtocol(t, cc.c, src)
121+ requestProtocol(t, cc.c, mgr, source.ExtForeignToplevelImageCaptureSourceManagerV1RequestCreateSource, src, handle)
122+ session, ev := cc.session(t, src)
123+ if w, hh := constraints(t, cc, ev); w != 3 || hh != 2 {
124+ t.Fatalf("buffer %dx%d, want the window's 3x2", w, hh)
125+ }
126+ open := h.open(t)
127+ if open.Window != win.ID || open.Workspace != 0 || open.Output != "" {
128+ t.Fatalf("open %+v", open)
129+ }
130+ h.send(ports.CaptureSessionState{ID: open.ID, Output: "HEADLESS-1", Rect: ports.Rect{W: 3, H: 2}, Hidden: true, Active: true})
131+ h.settle(t, cc)
132+ cc.extFrame(t, session, 3, 2)
133+ r := receiveCapture(t, h.captures)
134+ if r.Window != win.ID || !r.OffScreen || r.Region.Dx() != 3 || r.Region.Dy() != 2 {
135+ t.Fatalf("request %+v", r)
136+ }
137+
138+ // The window unmaps: the session stops at once.
139+ requestProtocol(t, cc.c, surf, wayland.SurfaceRequestAttach, uint32(0), int32(0), int32(0))
140+ requestProtocol(t, cc.c, surf, wayland.SurfaceRequestCommit)
141+ for {
142+ if got := nextSession(t, cc, ev); got[0] == evStopped {
143+ break
144+ }
145+ }
146+ if ev := clientEvent[ports.WindowUnmapped](t, h.events); ev.ID != win.ID {
147+ t.Fatalf("unmapped %d, want %d", ev.ID, win.ID)
148+ }
149+
150+ // The remap is a new toplevel: a new identifier, and the source of the
151+ // closed handle is dead.
152+ serials := make(chan uint32, 8)
153+ xp := &configureProxy{serial: serials}
154+ xp.SetID(xdg)
155+ registerWireProxy(cc.c, xp)
156+ requestProtocol(t, cc.c, surf, wayland.SurfaceRequestCommit)
157+ ackAndAttach(t, cc.c, surf, xdg, shmBuffer(t, cc.c), serials)
158+ clientEvent[ports.WindowMapped](t, h.events)
159+ if got := takeEvents(t, cc.c, p.events); !slices.Equal(got, []string{"closed"}) {
160+ t.Fatalf("unmap = %q, want closed", got)
161+ }
162+ got := takeEvents(t, cc.c, p.events)
163+ if !slices.Contains(got, "identifier "+toplevelIdentifier(win.ID, 2)) {
164+ t.Fatalf("remap = %q", got)
165+ }
166+ _, ev = cc.session(t, src)
167+ if got := nextSession(t, cc, ev); got[0] != evStopped {
168+ t.Fatalf("session on a stale source: %v", got)
169+ }
170+}
@@ -0,0 +1,170 @@
1+package wayland
2+
3+import (
4+ "slices"
5+ "testing"
6+
7+ ext "github.com/bnema/go-wayland-bindings/server/extforeigntoplevellist"
8+ source "github.com/bnema/go-wayland-bindings/server/extimagecapturesource"
9+ "github.com/bnema/go-wayland-bindings/server/wayland"
10+ xdgshell "github.com/bnema/go-wayland-bindings/server/xdgshell"
11+ "github.com/bnema/neferwl/internal/ports"
12+ "github.com/bnema/wlturbo"
13+)
14+
15+// extListEvents registers a handle proxy for each announced toplevel.
16+type extListEvents struct {
17+ wlturbo.BaseProxy
18+ client *wlturbo.Display
19+ handles chan uint32
20+ events chan string
21+}
22+
23+func (p *extListEvents) Dispatch(e *wlturbo.Event) {
24+ switch uint32(e.Opcode) {
25+ case ext.ExtForeignToplevelListV1EventToplevel:
26+ id := e.Uint32()
27+ h := &extHandleEvents{events: p.events}
28+ h.SetID(id)
29+ registerWireProxy(p.client, h)
30+ p.handles <- id
31+ case ext.ExtForeignToplevelListV1EventFinished:
32+ p.events <- "finished"
33+ }
34+}
35+
36+// extHandleEvents records handle events as text.
37+type extHandleEvents struct {
38+ wlturbo.BaseProxy
39+ events chan string
40+}
41+
42+func (p *extHandleEvents) Dispatch(e *wlturbo.Event) {
43+ switch uint32(e.Opcode) {
44+ case ext.ExtForeignToplevelHandleV1EventIdentifier:
45+ p.events <- "identifier " + e.String()
46+ case ext.ExtForeignToplevelHandleV1EventTitle:
47+ p.events <- "title " + e.String()
48+ case ext.ExtForeignToplevelHandleV1EventAppId:
49+ p.events <- "app_id " + e.String()
50+ case ext.ExtForeignToplevelHandleV1EventDone:
51+ p.events <- "done"
52+ case ext.ExtForeignToplevelHandleV1EventClosed:
53+ p.events <- "closed"
54+ }
55+}
56+
57+// The ext list announces mapped windows with a stable identifier, sends
58+// changes, and closed when a window unmaps.
59+func TestExtForeignToplevelList(t *testing.T) {
60+ s, events, _, dir := lifecycleServer(t)
61+ c := protocolClient(t, s, dir)
62+ w, _, xdg := surfaceMapper(t, c, events)()
63+ registerProtocol(t, c, xdg)
64+ top := xdg + 1
65+ requestProtocol(t, c, top, xdgshell.ToplevelRequestSetTitle, "editor")
66+ requestProtocol(t, c, top, xdgshell.ToplevelRequestSetAppId, "app.editor")
67+
68+ list := bindProtocol(t, c, "ext_foreign_toplevel_list_v1")
69+ p := &extListEvents{client: c, handles: make(chan uint32, 4), events: make(chan string, 64)}
70+ p.SetID(list)
71+ registerWireProxy(c, p)
72+ want := []string{"identifier " + toplevelIdentifier(w.ID, 1), "title editor", "app_id app.editor", "done"}
73+ if got := takeEvents(t, c, p.events); !slices.Equal(got, want) {
74+ t.Fatalf("announce = %q, want %q", got, want)
75+ }
76+ <-p.handles
77+ requestProtocol(t, c, top, xdgshell.ToplevelRequestSetTitle, "editor - file")
78+ if got, want := takeEvents(t, c, p.events), []string{"title editor - file", "done"}; !slices.Equal(got, want) {
79+ t.Fatalf("title = %q, want %q", got, want)
80+ }
81+
82+ // After stop, a handle already sent still follows its window.
83+ requestProtocol(t, c, list, ext.ExtForeignToplevelListV1RequestStop)
84+ if err := c.Roundtrip(); err != nil {
85+ t.Fatal(err)
86+ }
87+ if ev := <-p.events; ev != "finished" {
88+ t.Fatalf("stop = %q", ev)
89+ }
90+ requestProtocol(t, c, top, xdgshell.ToplevelRequestSetTitle, "editor - other")
91+ if got, want := takeEvents(t, c, p.events), []string{"title editor - other", "done"}; !slices.Equal(got, want) {
92+ t.Fatalf("title after stop = %q, want %q", got, want)
93+ }
94+ requestProtocol(t, c, top, xdgshell.ToplevelRequestDestroy)
95+ if got := takeEvents(t, c, p.events); !slices.Equal(got, []string{"closed"}) {
96+ t.Fatalf("close = %q", got)
97+ }
98+}
99+
100+// A handle of the list makes a window capture source: its session opens on
101+// the window in core, its frames ask for that window off screen, and the
102+// session stops when the window goes.
103+func TestWindowCaptureSource(t *testing.T) {
104+ h := newCaptureHarness(t)
105+ cc := h.client(t)
106+ win, surf, xdg := surfaceMapper(t, cc.c, h.events)()
107+ registerProtocol(t, cc.c, xdg)
108+ h.send(ports.ConfigureWindow{ID: win.ID, Width: 3, Height: 2, Output: "HEADLESS-1", Visible: true})
109+ h.settle(t, cc)
110+
111+ list := bindProtocol(t, cc.c, "ext_foreign_toplevel_list_v1")
112+ p := &extListEvents{client: cc.c, handles: make(chan uint32, 4), events: make(chan string, 64)}
113+ p.SetID(list)
114+ registerWireProxy(cc.c, p)
115+ takeEvents(t, cc.c, p.events)
116+ handle := <-p.handles
117+
118+ mgr := bindProtocol(t, cc.c, "ext_foreign_toplevel_image_capture_source_manager_v1")
119+ src := cc.c.AllocateID()
120+ registerProtocol(t, cc.c, src)
121+ requestProtocol(t, cc.c, mgr, source.ExtForeignToplevelImageCaptureSourceManagerV1RequestCreateSource, src, handle)
122+ session, ev := cc.session(t, src)
123+ if w, hh := constraints(t, cc, ev); w != 3 || hh != 2 {
124+ t.Fatalf("buffer %dx%d, want the window's 3x2", w, hh)
125+ }
126+ open := h.open(t)
127+ if open.Window != win.ID || open.Workspace != 0 || open.Output != "" {
128+ t.Fatalf("open %+v", open)
129+ }
130+ h.send(ports.CaptureSessionState{ID: open.ID, Output: "HEADLESS-1", Rect: ports.Rect{W: 3, H: 2}, Hidden: true, Active: true})
131+ h.settle(t, cc)
132+ cc.extFrame(t, session, 3, 2)
133+ r := receiveCapture(t, h.captures)
134+ if r.Window != win.ID || !r.OffScreen || r.Region.Dx() != 3 || r.Region.Dy() != 2 {
135+ t.Fatalf("request %+v", r)
136+ }
137+
138+ // The window unmaps: the session stops at once.
139+ requestProtocol(t, cc.c, surf, wayland.SurfaceRequestAttach, uint32(0), int32(0), int32(0))
140+ requestProtocol(t, cc.c, surf, wayland.SurfaceRequestCommit)
141+ for {
142+ if got := nextSession(t, cc, ev); got[0] == evStopped {
143+ break
144+ }
145+ }
146+ if ev := clientEvent[ports.WindowUnmapped](t, h.events); ev.ID != win.ID {
147+ t.Fatalf("unmapped %d, want %d", ev.ID, win.ID)
148+ }
149+
150+ // The remap is a new toplevel: a new identifier, and the source of the
151+ // closed handle is dead.
152+ serials := make(chan uint32, 8)
153+ xp := &configureProxy{serial: serials}
154+ xp.SetID(xdg)
155+ registerWireProxy(cc.c, xp)
156+ requestProtocol(t, cc.c, surf, wayland.SurfaceRequestCommit)
157+ ackAndAttach(t, cc.c, surf, xdg, shmBuffer(t, cc.c), serials)
158+ clientEvent[ports.WindowMapped](t, h.events)
159+ if got := takeEvents(t, cc.c, p.events); !slices.Equal(got, []string{"closed"}) {
160+ t.Fatalf("unmap = %q, want closed", got)
161+ }
162+ got := takeEvents(t, cc.c, p.events)
163+ if !slices.Contains(got, "identifier "+toplevelIdentifier(win.ID, 2)) {
164+ t.Fatalf("remap = %q", got)
165+ }
166+ _, ev = cc.session(t, src)
167+ if got := nextSession(t, cc, ev); got[0] != evStopped {
168+ t.Fatalf("session on a stale source: %v", got)
169+ }
170+}
M internal/adapters/wayland/foreigntoplevel.go +17 -5
@@ -192,6 +192,9 @@ func (s *Server) toplevelChanged(w *window) {
192192 for _, m := range s.toplevelManagers {
193193 m.refresh(w)
194194 }
195+ for _, l := range s.extToplevelLists {
196+ l.refresh(w)
197+ }
195198 }
196199
197200 // toplevelClosed tells every manager that w unmapped.
@@ -199,6 +202,10 @@ func (s *Server) toplevelClosed(id ports.WindowID) {
199202 for _, m := range s.toplevelManagers {
200203 m.closed(id)
201204 }
205+ for _, l := range s.extToplevelLists {
206+ l.closed(id)
207+ }
208+ s.windowCaptureGone(id)
202209 }
203210
204211 // refreshToplevels reconciles inventory after output changes or unlock:
@@ -208,13 +215,18 @@ func (s *Server) refreshToplevels() {
208215 if s.protected() {
209216 return
210217 }
211- ids := make([]ports.WindowID, 0, len(s.windows))
212- for id, w := range s.windows {
213- if w.toplevel != nil && w.mapped {
214- ids = append(ids, id)
218+ ids := s.mappedToplevels()
219+ for _, l := range s.extToplevelLists {
220+ for id, h := range l.handles {
221+ w := s.windows[id]
222+ if h.closed || w == nil || w.toplevel == nil || !w.mapped {
223+ l.closed(id)
224+ }
225+ }
226+ for _, id := range ids {
227+ l.refresh(s.windows[id])
215228 }
216229 }
217- slices.Sort(ids)
218230 for _, m := range s.toplevelManagers {
219231 for id, h := range m.handles {
220232 w := s.windows[id]
@@ -192,6 +192,9 @@ func (s *Server) toplevelChanged(w *window) {
192 for _, m := range s.toplevelManagers {192 for _, m := range s.toplevelManagers {
193 m.refresh(w)193 m.refresh(w)
194 }194 }
195+ for _, l := range s.extToplevelLists {
196+ l.refresh(w)
197+ }
195 }198 }
196 199
197 // toplevelClosed tells every manager that w unmapped.200 // toplevelClosed tells every manager that w unmapped.
@@ -199,6 +202,10 @@ func (s *Server) toplevelClosed(id ports.WindowID) {
199 for _, m := range s.toplevelManagers {202 for _, m := range s.toplevelManagers {
200 m.closed(id)203 m.closed(id)
201 }204 }
205+ for _, l := range s.extToplevelLists {
206+ l.closed(id)
207+ }
208+ s.windowCaptureGone(id)
202 }209 }
203 210
204 // refreshToplevels reconciles inventory after output changes or unlock:211 // refreshToplevels reconciles inventory after output changes or unlock:
@@ -208,13 +215,18 @@ func (s *Server) refreshToplevels() {
208 if s.protected() {215 if s.protected() {
209 return216 return
210 }217 }
211- ids := make([]ports.WindowID, 0, len(s.windows))218+ ids := s.mappedToplevels()
212- for id, w := range s.windows {219+ for _, l := range s.extToplevelLists {
213- if w.toplevel != nil && w.mapped {220+ for id, h := range l.handles {
214- ids = append(ids, id)221+ w := s.windows[id]
222+ if h.closed || w == nil || w.toplevel == nil || !w.mapped {
223+ l.closed(id)
224+ }
225+ }
226+ for _, id := range ids {
227+ l.refresh(s.windows[id])
215 }228 }
216 }229 }
217- slices.Sort(ids)
218 for _, m := range s.toplevelManagers {230 for _, m := range s.toplevelManagers {
219 for id, h := range m.handles {231 for id, h := range m.handles {
220 w := s.windows[id]232 w := s.windows[id]
M internal/adapters/wayland/globals.go +1 -0
@@ -25,6 +25,7 @@ func registerGlobals(d *server.Display, o Options, s *Server) error {
2525 func() error { return registerOutputManagement(d, s) },
2626 func() error { return registerWorkspaces(d, s) },
2727 func() error { return registerForeignToplevel(d, s) },
28+ func() error { return registerExtForeignToplevel(d, s) },
2829 func() error { return registerDecoration(d, s) },
2930 func() error { return registerScale(d, s) },
3031 func() error { return registerCursorShape(d, s) },
@@ -25,6 +25,7 @@ func registerGlobals(d *server.Display, o Options, s *Server) error {
25 func() error { return registerOutputManagement(d, s) },25 func() error { return registerOutputManagement(d, s) },
26 func() error { return registerWorkspaces(d, s) },26 func() error { return registerWorkspaces(d, s) },
27 func() error { return registerForeignToplevel(d, s) },27 func() error { return registerForeignToplevel(d, s) },
28+ func() error { return registerExtForeignToplevel(d, s) },
28 func() error { return registerDecoration(d, s) },29 func() error { return registerDecoration(d, s) },
29 func() error { return registerScale(d, s) },30 func() error { return registerScale(d, s) },
30 func() error { return registerCursorShape(d, s) },31 func() error { return registerCursorShape(d, s) },
M internal/adapters/wayland/imagecopy.go +70 -2
@@ -6,6 +6,7 @@ import (
66 "math"
77 "time"
88
9+ "github.com/bnema/go-wayland-bindings/server/extforeigntoplevellist"
910 source "github.com/bnema/go-wayland-bindings/server/extimagecapturesource"
1011 ext "github.com/bnema/go-wayland-bindings/server/extimagecopycapture"
1112 extworkspace "github.com/bnema/go-wayland-bindings/server/extworkspace"
@@ -28,6 +29,7 @@ const (
2829 srcOutput // a whole output
2930 srcRegion // a logical rectangle of an output
3031 srcWorkspace // a workspace frame
32+ srcWindow // a toplevel, at its own size
3133 )
3234
3335 // captureSource is the state behind one ext_image_capture_source_v1.
@@ -36,6 +38,8 @@ type captureSource struct {
3638 output *output
3739 region image.Rectangle // output-local logical, srcRegion
3840 workspace uint64
41+ window ports.WindowID // srcWindow
42+ gen uint32 // srcWindow: its mapping; a remap kills the source
3943 }
4044
4145 func registerImageCopy(d *server.Display, s *Server) error {
@@ -49,6 +53,11 @@ func registerImageCopy(d *server.Display, s *Server) error {
4953 }); err != nil {
5054 return err
5155 }
56+ if err := source.NewExtForeignToplevelImageCaptureSourceManagerV1Global(d, 1, func(c server.Client, v, id uint32) {
57+ _, _ = source.NewExtForeignToplevelImageCaptureSourceManagerV1(c, int32(v), id, toplevelCaptureSource{s})
58+ }); err != nil {
59+ return err
60+ }
5261 return ext.NewExtImageCopyCaptureManagerV1Global(d, 1, func(c server.Client, v, id uint32) {
5362 _, _ = ext.NewExtImageCopyCaptureManagerV1(c, int32(v), id, copyCaptureManager{s})
5463 })
@@ -79,6 +88,33 @@ func (m outputCaptureSource) CreateSource(r *source.ExtOutputImageCaptureSourceM
7988 m.s.newSource(r.Client(), id, src)
8089 }
8190
91+// toplevelCaptureSource makes the source of an ext-foreign-toplevel-list
92+// handle: that window. A closed or unknown handle gives a dead source.
93+type toplevelCaptureSource struct{ s *Server }
94+
95+func (toplevelCaptureSource) Destroy(*source.ExtForeignToplevelImageCaptureSourceManagerV1) {}
96+func (m toplevelCaptureSource) CreateSource(r *source.ExtForeignToplevelImageCaptureSourceManagerV1, id uint32, h *extforeigntoplevellist.ExtForeignToplevelHandleV1) {
97+ src := &captureSource{kind: srcDead}
98+ if h != nil {
99+ if wid, gen, ok := m.s.extHandleWindow(h.Resource); ok {
100+ src = &captureSource{kind: srcWindow, window: wid, gen: gen}
101+ }
102+ }
103+ m.s.newSource(r.Client(), id, src)
104+}
105+
106+// windowCaptureGone stops the sessions of a window that unmapped or closed.
107+func (s *Server) windowCaptureGone(id ports.WindowID) {
108+ for c := range s.captureSessions {
109+ if c.src != nil && c.src.kind == srcWindow && c.src.window == id {
110+ if c.id != 0 {
111+ s.log.Info().Uint64("session", c.id).Str("reason", string(ports.CaptureReasonWindowGone)).Msg("capture session end")
112+ }
113+ c.end(true, true)
114+ }
115+ }
116+}
117+
82118 type extraSources struct{ s *Server }
83119
84120 func (extraSources) Destroy(*imagecapture.NeferwlImageCaptureSourceManagerV1) {}
@@ -172,6 +208,8 @@ func (m copyCaptureManager) CreateSession(r *ext.ExtImageCopyCaptureManagerV1, i
172208 open.Output, open.Region = cs.output.name(), ports.Rect{X: cs.region.Min.X, Y: cs.region.Min.Y, W: cs.region.Dx(), H: cs.region.Dy()}
173209 case srcWorkspace:
174210 open.Workspace = cs.workspace
211+ case srcWindow:
212+ open.Window = cs.window
175213 }
176214 s.emit(open)
177215 state.sendConstraints()
@@ -297,6 +335,31 @@ func (c *captureSession) geometry() (o *output, rect image.Rectangle, hidden, ok
297335 }
298336 rect = captureRegion(o, image.Rect(frame.X, frame.Y, frame.X+frame.W, frame.Y+frame.H))
299337 return o, rect, false, !rect.Empty()
338+ case srcWindow:
339+ // Core's state gives the size; before it, the last configure.
340+ w := c.s.windows[src.window]
341+ if w == nil || !w.mapped || w.toplevel == nil || w.gen != src.gen {
342+ return nil, image.Rectangle{}, false, false
343+ }
344+ name, size := w.last.Output, ports.Rect{W: w.last.Width, H: w.last.Height}
345+ if c.haveState && c.st.Reason == ports.CaptureReasonNone {
346+ name, size = c.st.Output, c.st.Rect
347+ }
348+ if size.W <= 0 || size.H <= 0 {
349+ if gw, gh := w.size(); gw > 0 && gh > 0 {
350+ size = ports.Rect{W: gw, H: gh}
351+ }
352+ }
353+ o = c.s.outputByNameExact(name)
354+ if o == nil || size.W <= 0 || size.H <= 0 {
355+ return nil, image.Rectangle{}, false, false
356+ }
357+ scale := o.place.Scale
358+ if scale <= 0 {
359+ scale = 1
360+ }
361+ // The size of the off-screen child image (capture.childSize).
362+ return o, image.Rect(0, 0, int(math.Ceil(float64(size.W)*scale)), int(math.Ceil(float64(size.H)*scale))), true, true
300363 }
301364 return nil, image.Rectangle{}, false, false
302365 }
@@ -331,7 +394,7 @@ func (c *captureSession) refresh() {
331394 }
332395 _, rect, _, ok := c.geometry()
333396 switch {
334- case !ok && c.src.kind != srcWorkspace:
397+ case !ok && c.src.kind != srcWorkspace && c.src.kind != srcWindow:
335398 c.s.log.Info().Uint64("session", c.id).Msg("capture session end: target empty")
336399 c.end(true, true)
337400 case ok && image.Pt(rect.Dx(), rect.Dy()) != c.sent:
@@ -395,8 +458,11 @@ func (s *Server) captureState(st ports.CaptureSessionState) {
395458 // HUD in it (ok false).
396459 func (c *captureSession) tag(hidden bool) (t captureTag, ok bool) {
397460 t = captureTag{session: c.id}
398- if c.src.kind == srcWorkspace {
461+ switch c.src.kind {
462+ case srcWorkspace:
399463 t.workspace, t.offscreen = c.src.workspace, hidden
464+ case srcWindow:
465+ t.window, t.offscreen = c.src.window, true
400466 }
401467 switch c.src.kind {
402468 case srcOutput:
@@ -406,6 +472,8 @@ func (c *captureSession) tag(hidden bool) (t captureTag, ok bool) {
406472 t.taken = ports.CaptureFrameTaken{Output: c.src.output.name(), Region: ports.Rect{X: r.Min.X, Y: r.Min.Y, W: r.Dx(), H: r.Dy()}}
407473 case srcWorkspace:
408474 t.taken = ports.CaptureFrameTaken{Workspace: c.src.workspace}
475+ case srcWindow:
476+ t.taken = ports.CaptureFrameTaken{Window: c.src.window}
409477 }
410478 if c.excl != nil && !c.exclSeen {
411479 return t, false
@@ -6,6 +6,7 @@ import (
6 "math"6 "math"
7 "time"7 "time"
8 8
9+ "github.com/bnema/go-wayland-bindings/server/extforeigntoplevellist"
9 source "github.com/bnema/go-wayland-bindings/server/extimagecapturesource"10 source "github.com/bnema/go-wayland-bindings/server/extimagecapturesource"
10 ext "github.com/bnema/go-wayland-bindings/server/extimagecopycapture"11 ext "github.com/bnema/go-wayland-bindings/server/extimagecopycapture"
11 extworkspace "github.com/bnema/go-wayland-bindings/server/extworkspace"12 extworkspace "github.com/bnema/go-wayland-bindings/server/extworkspace"
@@ -28,6 +29,7 @@ const (
28 srcOutput // a whole output29 srcOutput // a whole output
29 srcRegion // a logical rectangle of an output30 srcRegion // a logical rectangle of an output
30 srcWorkspace // a workspace frame31 srcWorkspace // a workspace frame
32+ srcWindow // a toplevel, at its own size
31 )33 )
32 34
33 // captureSource is the state behind one ext_image_capture_source_v1.35 // captureSource is the state behind one ext_image_capture_source_v1.
@@ -36,6 +38,8 @@ type captureSource struct {
36 output *output38 output *output
37 region image.Rectangle // output-local logical, srcRegion39 region image.Rectangle // output-local logical, srcRegion
38 workspace uint6440 workspace uint64
41+ window ports.WindowID // srcWindow
42+ gen uint32 // srcWindow: its mapping; a remap kills the source
39 }43 }
40 44
41 func registerImageCopy(d *server.Display, s *Server) error {45 func registerImageCopy(d *server.Display, s *Server) error {
@@ -49,6 +53,11 @@ func registerImageCopy(d *server.Display, s *Server) error {
49 }); err != nil {53 }); err != nil {
50 return err54 return err
51 }55 }
56+ if err := source.NewExtForeignToplevelImageCaptureSourceManagerV1Global(d, 1, func(c server.Client, v, id uint32) {
57+ _, _ = source.NewExtForeignToplevelImageCaptureSourceManagerV1(c, int32(v), id, toplevelCaptureSource{s})
58+ }); err != nil {
59+ return err
60+ }
52 return ext.NewExtImageCopyCaptureManagerV1Global(d, 1, func(c server.Client, v, id uint32) {61 return ext.NewExtImageCopyCaptureManagerV1Global(d, 1, func(c server.Client, v, id uint32) {
53 _, _ = ext.NewExtImageCopyCaptureManagerV1(c, int32(v), id, copyCaptureManager{s})62 _, _ = ext.NewExtImageCopyCaptureManagerV1(c, int32(v), id, copyCaptureManager{s})
54 })63 })
@@ -79,6 +88,33 @@ func (m outputCaptureSource) CreateSource(r *source.ExtOutputImageCaptureSourceM
79 m.s.newSource(r.Client(), id, src)88 m.s.newSource(r.Client(), id, src)
80 }89 }
81 90
91+// toplevelCaptureSource makes the source of an ext-foreign-toplevel-list
92+// handle: that window. A closed or unknown handle gives a dead source.
93+type toplevelCaptureSource struct{ s *Server }
94+
95+func (toplevelCaptureSource) Destroy(*source.ExtForeignToplevelImageCaptureSourceManagerV1) {}
96+func (m toplevelCaptureSource) CreateSource(r *source.ExtForeignToplevelImageCaptureSourceManagerV1, id uint32, h *extforeigntoplevellist.ExtForeignToplevelHandleV1) {
97+ src := &captureSource{kind: srcDead}
98+ if h != nil {
99+ if wid, gen, ok := m.s.extHandleWindow(h.Resource); ok {
100+ src = &captureSource{kind: srcWindow, window: wid, gen: gen}
101+ }
102+ }
103+ m.s.newSource(r.Client(), id, src)
104+}
105+
106+// windowCaptureGone stops the sessions of a window that unmapped or closed.
107+func (s *Server) windowCaptureGone(id ports.WindowID) {
108+ for c := range s.captureSessions {
109+ if c.src != nil && c.src.kind == srcWindow && c.src.window == id {
110+ if c.id != 0 {
111+ s.log.Info().Uint64("session", c.id).Str("reason", string(ports.CaptureReasonWindowGone)).Msg("capture session end")
112+ }
113+ c.end(true, true)
114+ }
115+ }
116+}
117+
82 type extraSources struct{ s *Server }118 type extraSources struct{ s *Server }
83 119
84 func (extraSources) Destroy(*imagecapture.NeferwlImageCaptureSourceManagerV1) {}120 func (extraSources) Destroy(*imagecapture.NeferwlImageCaptureSourceManagerV1) {}
@@ -172,6 +208,8 @@ func (m copyCaptureManager) CreateSession(r *ext.ExtImageCopyCaptureManagerV1, i
172 open.Output, open.Region = cs.output.name(), ports.Rect{X: cs.region.Min.X, Y: cs.region.Min.Y, W: cs.region.Dx(), H: cs.region.Dy()}208 open.Output, open.Region = cs.output.name(), ports.Rect{X: cs.region.Min.X, Y: cs.region.Min.Y, W: cs.region.Dx(), H: cs.region.Dy()}
173 case srcWorkspace:209 case srcWorkspace:
174 open.Workspace = cs.workspace210 open.Workspace = cs.workspace
211+ case srcWindow:
212+ open.Window = cs.window
175 }213 }
176 s.emit(open)214 s.emit(open)
177 state.sendConstraints()215 state.sendConstraints()
@@ -297,6 +335,31 @@ func (c *captureSession) geometry() (o *output, rect image.Rectangle, hidden, ok
297 }335 }
298 rect = captureRegion(o, image.Rect(frame.X, frame.Y, frame.X+frame.W, frame.Y+frame.H))336 rect = captureRegion(o, image.Rect(frame.X, frame.Y, frame.X+frame.W, frame.Y+frame.H))
299 return o, rect, false, !rect.Empty()337 return o, rect, false, !rect.Empty()
338+ case srcWindow:
339+ // Core's state gives the size; before it, the last configure.
340+ w := c.s.windows[src.window]
341+ if w == nil || !w.mapped || w.toplevel == nil || w.gen != src.gen {
342+ return nil, image.Rectangle{}, false, false
343+ }
344+ name, size := w.last.Output, ports.Rect{W: w.last.Width, H: w.last.Height}
345+ if c.haveState && c.st.Reason == ports.CaptureReasonNone {
346+ name, size = c.st.Output, c.st.Rect
347+ }
348+ if size.W <= 0 || size.H <= 0 {
349+ if gw, gh := w.size(); gw > 0 && gh > 0 {
350+ size = ports.Rect{W: gw, H: gh}
351+ }
352+ }
353+ o = c.s.outputByNameExact(name)
354+ if o == nil || size.W <= 0 || size.H <= 0 {
355+ return nil, image.Rectangle{}, false, false
356+ }
357+ scale := o.place.Scale
358+ if scale <= 0 {
359+ scale = 1
360+ }
361+ // The size of the off-screen child image (capture.childSize).
362+ return o, image.Rect(0, 0, int(math.Ceil(float64(size.W)*scale)), int(math.Ceil(float64(size.H)*scale))), true, true
300 }363 }
301 return nil, image.Rectangle{}, false, false364 return nil, image.Rectangle{}, false, false
302 }365 }
@@ -331,7 +394,7 @@ func (c *captureSession) refresh() {
331 }394 }
332 _, rect, _, ok := c.geometry()395 _, rect, _, ok := c.geometry()
333 switch {396 switch {
334- case !ok && c.src.kind != srcWorkspace:397+ case !ok && c.src.kind != srcWorkspace && c.src.kind != srcWindow:
335 c.s.log.Info().Uint64("session", c.id).Msg("capture session end: target empty")398 c.s.log.Info().Uint64("session", c.id).Msg("capture session end: target empty")
336 c.end(true, true)399 c.end(true, true)
337 case ok && image.Pt(rect.Dx(), rect.Dy()) != c.sent:400 case ok && image.Pt(rect.Dx(), rect.Dy()) != c.sent:
@@ -395,8 +458,11 @@ func (s *Server) captureState(st ports.CaptureSessionState) {
395 // HUD in it (ok false).458 // HUD in it (ok false).
396 func (c *captureSession) tag(hidden bool) (t captureTag, ok bool) {459 func (c *captureSession) tag(hidden bool) (t captureTag, ok bool) {
397 t = captureTag{session: c.id}460 t = captureTag{session: c.id}
398- if c.src.kind == srcWorkspace {461+ switch c.src.kind {
462+ case srcWorkspace:
399 t.workspace, t.offscreen = c.src.workspace, hidden463 t.workspace, t.offscreen = c.src.workspace, hidden
464+ case srcWindow:
465+ t.window, t.offscreen = c.src.window, true
400 }466 }
401 switch c.src.kind {467 switch c.src.kind {
402 case srcOutput:468 case srcOutput:
@@ -406,6 +472,8 @@ func (c *captureSession) tag(hidden bool) (t captureTag, ok bool) {
406 t.taken = ports.CaptureFrameTaken{Output: c.src.output.name(), Region: ports.Rect{X: r.Min.X, Y: r.Min.Y, W: r.Dx(), H: r.Dy()}}472 t.taken = ports.CaptureFrameTaken{Output: c.src.output.name(), Region: ports.Rect{X: r.Min.X, Y: r.Min.Y, W: r.Dx(), H: r.Dy()}}
407 case srcWorkspace:473 case srcWorkspace:
408 t.taken = ports.CaptureFrameTaken{Workspace: c.src.workspace}474 t.taken = ports.CaptureFrameTaken{Workspace: c.src.workspace}
475+ case srcWindow:
476+ t.taken = ports.CaptureFrameTaken{Window: c.src.window}
409 }477 }
410 if c.excl != nil && !c.exclSeen {478 if c.excl != nil && !c.exclSeen {
411 return t, false479 return t, false
M internal/adapters/wayland/screencopy.go +2 -1
@@ -168,6 +168,7 @@ type captureTag struct {
168168 exclude bool
169169 revision uint64
170170 workspace uint64
171+ window ports.WindowID
171172 offscreen bool
172173 taken ports.CaptureFrameTaken
173174 }
@@ -219,7 +220,7 @@ func (s *Server) requestCapture(o *output, rect image.Rectangle, cursor bool, b
219220 reply(done)
220221 }
221222 }
222- req := ports.CaptureRequest{ID: id, Output: o.name(), Region: rect, Cursor: cursor, Dst: ports.SHMBuffer{File: os.NewFile(uintptr(fd), "capture"), Offset: buf.offset}, Width: buf.width, Height: buf.height, Stride: buf.stride, Format: buf.format, Session: tag.session, Exclude: tag.exclude, CaptureRevision: tag.revision, Workspace: tag.workspace, OffScreen: tag.offscreen, Indicate: true}
223+ req := ports.CaptureRequest{ID: id, Output: o.name(), Region: rect, Cursor: cursor, Dst: ports.SHMBuffer{File: os.NewFile(uintptr(fd), "capture"), Offset: buf.offset}, Width: buf.width, Height: buf.height, Stride: buf.stride, Format: buf.format, Session: tag.session, Exclude: tag.exclude, CaptureRevision: tag.revision, Workspace: tag.workspace, Window: tag.window, OffScreen: tag.offscreen, Indicate: true}
223224 select {
224225 case s.channels.Captures <- req:
225226 s.log.Debug().Uint64("id", id).Str("output", o.name()).Stringer("region", rect).Uint32("format", format).Bool("cursor", cursor).Msg("capture requested")
@@ -168,6 +168,7 @@ type captureTag struct {
168 exclude bool168 exclude bool
169 revision uint64169 revision uint64
170 workspace uint64170 workspace uint64
171+ window ports.WindowID
171 offscreen bool172 offscreen bool
172 taken ports.CaptureFrameTaken173 taken ports.CaptureFrameTaken
173 }174 }
@@ -219,7 +220,7 @@ func (s *Server) requestCapture(o *output, rect image.Rectangle, cursor bool, b
219 reply(done)220 reply(done)
220 }221 }
221 }222 }
222- req := ports.CaptureRequest{ID: id, Output: o.name(), Region: rect, Cursor: cursor, Dst: ports.SHMBuffer{File: os.NewFile(uintptr(fd), "capture"), Offset: buf.offset}, Width: buf.width, Height: buf.height, Stride: buf.stride, Format: buf.format, Session: tag.session, Exclude: tag.exclude, CaptureRevision: tag.revision, Workspace: tag.workspace, OffScreen: tag.offscreen, Indicate: true}223+ req := ports.CaptureRequest{ID: id, Output: o.name(), Region: rect, Cursor: cursor, Dst: ports.SHMBuffer{File: os.NewFile(uintptr(fd), "capture"), Offset: buf.offset}, Width: buf.width, Height: buf.height, Stride: buf.stride, Format: buf.format, Session: tag.session, Exclude: tag.exclude, CaptureRevision: tag.revision, Workspace: tag.workspace, Window: tag.window, OffScreen: tag.offscreen, Indicate: true}
223 select {224 select {
224 case s.channels.Captures <- req:225 case s.channels.Captures <- req:
225 s.log.Debug().Uint64("id", id).Str("output", o.name()).Stringer("region", rect).Uint32("format", format).Bool("cursor", cursor).Msg("capture requested")226 s.log.Debug().Uint64("id", id).Str("output", o.name()).Stringer("region", rect).Uint32("format", format).Bool("cursor", cursor).Msg("capture requested")
M internal/adapters/wayland/server.go +1 -0
@@ -232,6 +232,7 @@ type Server struct {
232232 outputManagers []*outputManager
233233 workspaceManagers []*workspaceManager
234234 toplevelManagers []*toplevelManager
235+ extToplevelLists []*extToplevelList
235236 workspaceSnapshot ports.Workspaces
236237 workspaceFrames []*workspaceFrame
237238 workspaceIDs *workspaceid.IDs
@@ -232,6 +232,7 @@ type Server struct {
232 outputManagers []*outputManager232 outputManagers []*outputManager
233 workspaceManagers []*workspaceManager233 workspaceManagers []*workspaceManager
234 toplevelManagers []*toplevelManager234 toplevelManagers []*toplevelManager
235+ extToplevelLists []*extToplevelList
235 workspaceSnapshot ports.Workspaces236 workspaceSnapshot ports.Workspaces
236 workspaceFrames []*workspaceFrame237 workspaceFrames []*workspaceFrame
237 workspaceIDs *workspaceid.IDs238 workspaceIDs *workspaceid.IDs
M internal/adapters/wayland/wire_client_test.go +4 -0
@@ -17,6 +17,7 @@ import (
1717 wire_cursorshape "github.com/bnema/go-wayland-bindings/server/cursorshape"
1818 wire_drmlease "github.com/bnema/go-wayland-bindings/server/drmlease"
1919 wire_extdatacontrol "github.com/bnema/go-wayland-bindings/server/extdatacontrol"
20+ wire_extforeigntoplevellist "github.com/bnema/go-wayland-bindings/server/extforeigntoplevellist"
2021 wire_extidlenotify "github.com/bnema/go-wayland-bindings/server/extidlenotify"
2122 wire_extimagecapturesource "github.com/bnema/go-wayland-bindings/server/extimagecapturesource"
2223 wire_extimagecopycapture "github.com/bnema/go-wayland-bindings/server/extimagecopycapture"
@@ -62,6 +63,7 @@ import (
6263 client_datacontrol "github.com/bnema/wlturbo/protocol/datacontrol"
6364 client_drmlease "github.com/bnema/wlturbo/protocol/drmlease"
6465 client_drmsyncobj "github.com/bnema/wlturbo/protocol/drmsyncobj"
66+ client_extforeigntoplevel "github.com/bnema/wlturbo/protocol/extforeigntoplevel"
6567 client_extsessionlock "github.com/bnema/wlturbo/protocol/extsessionlock"
6668 client_fifo "github.com/bnema/wlturbo/protocol/fifo"
6769 client_foreigntoplevel "github.com/bnema/wlturbo/protocol/foreigntoplevel"
@@ -106,6 +108,8 @@ var wireSchemas = map[string]wireSchema{
106108 "ext_data_control_manager_v1": {wire_extdatacontrol.ExtDataControlManagerV1Interface, &client_datacontrol.ExtDataControlManager{}, map[uint16]bool{2: true}, nil},
107109 "ext_data_control_offer_v1": {wire_extdatacontrol.ExtDataControlOfferV1Interface, &client_datacontrol.ExtDataControlOffer{}, map[uint16]bool{1: true}, nil},
108110 "ext_data_control_source_v1": {wire_extdatacontrol.ExtDataControlSourceV1Interface, &client_datacontrol.ExtDataControlSource{}, map[uint16]bool{1: true}, nil},
111+ "ext_foreign_toplevel_list_v1": {wire_extforeigntoplevellist.ExtForeignToplevelListV1Interface, &client_extforeigntoplevel.ExtForeignToplevelList{}, map[uint16]bool{1: true}, map[uint16]bool{1: true}},
112+ "ext_foreign_toplevel_handle_v1": {wire_extforeigntoplevellist.ExtForeignToplevelHandleV1Interface, &client_extforeigntoplevel.ExtForeignToplevelHandle{}, map[uint16]bool{0: true}, nil},
109113 "ext_foreign_toplevel_image_capture_source_manager_v1": {wire_extimagecapturesource.ExtForeignToplevelImageCaptureSourceManagerV1Interface, &client_imagecapturesource.ExtForeignToplevelImageCaptureSourceManager{}, map[uint16]bool{1: true}, nil},
110114 "ext_idle_notification_v1": {wire_extidlenotify.ExtIdleNotificationV1Interface, &client_idlenotify.ExtIdleNotification{}, map[uint16]bool{0: true}, nil},
111115 "ext_idle_notifier_v1": {wire_extidlenotify.ExtIdleNotifierV1Interface, &client_idlenotify.ExtIdleNotifier{}, map[uint16]bool{0: true}, nil},
@@ -17,6 +17,7 @@ import (
17 wire_cursorshape "github.com/bnema/go-wayland-bindings/server/cursorshape"17 wire_cursorshape "github.com/bnema/go-wayland-bindings/server/cursorshape"
18 wire_drmlease "github.com/bnema/go-wayland-bindings/server/drmlease"18 wire_drmlease "github.com/bnema/go-wayland-bindings/server/drmlease"
19 wire_extdatacontrol "github.com/bnema/go-wayland-bindings/server/extdatacontrol"19 wire_extdatacontrol "github.com/bnema/go-wayland-bindings/server/extdatacontrol"
20+ wire_extforeigntoplevellist "github.com/bnema/go-wayland-bindings/server/extforeigntoplevellist"
20 wire_extidlenotify "github.com/bnema/go-wayland-bindings/server/extidlenotify"21 wire_extidlenotify "github.com/bnema/go-wayland-bindings/server/extidlenotify"
21 wire_extimagecapturesource "github.com/bnema/go-wayland-bindings/server/extimagecapturesource"22 wire_extimagecapturesource "github.com/bnema/go-wayland-bindings/server/extimagecapturesource"
22 wire_extimagecopycapture "github.com/bnema/go-wayland-bindings/server/extimagecopycapture"23 wire_extimagecopycapture "github.com/bnema/go-wayland-bindings/server/extimagecopycapture"
@@ -62,6 +63,7 @@ import (
62 client_datacontrol "github.com/bnema/wlturbo/protocol/datacontrol"63 client_datacontrol "github.com/bnema/wlturbo/protocol/datacontrol"
63 client_drmlease "github.com/bnema/wlturbo/protocol/drmlease"64 client_drmlease "github.com/bnema/wlturbo/protocol/drmlease"
64 client_drmsyncobj "github.com/bnema/wlturbo/protocol/drmsyncobj"65 client_drmsyncobj "github.com/bnema/wlturbo/protocol/drmsyncobj"
66+ client_extforeigntoplevel "github.com/bnema/wlturbo/protocol/extforeigntoplevel"
65 client_extsessionlock "github.com/bnema/wlturbo/protocol/extsessionlock"67 client_extsessionlock "github.com/bnema/wlturbo/protocol/extsessionlock"
66 client_fifo "github.com/bnema/wlturbo/protocol/fifo"68 client_fifo "github.com/bnema/wlturbo/protocol/fifo"
67 client_foreigntoplevel "github.com/bnema/wlturbo/protocol/foreigntoplevel"69 client_foreigntoplevel "github.com/bnema/wlturbo/protocol/foreigntoplevel"
@@ -106,6 +108,8 @@ var wireSchemas = map[string]wireSchema{
106 "ext_data_control_manager_v1": {wire_extdatacontrol.ExtDataControlManagerV1Interface, &client_datacontrol.ExtDataControlManager{}, map[uint16]bool{2: true}, nil},108 "ext_data_control_manager_v1": {wire_extdatacontrol.ExtDataControlManagerV1Interface, &client_datacontrol.ExtDataControlManager{}, map[uint16]bool{2: true}, nil},
107 "ext_data_control_offer_v1": {wire_extdatacontrol.ExtDataControlOfferV1Interface, &client_datacontrol.ExtDataControlOffer{}, map[uint16]bool{1: true}, nil},109 "ext_data_control_offer_v1": {wire_extdatacontrol.ExtDataControlOfferV1Interface, &client_datacontrol.ExtDataControlOffer{}, map[uint16]bool{1: true}, nil},
108 "ext_data_control_source_v1": {wire_extdatacontrol.ExtDataControlSourceV1Interface, &client_datacontrol.ExtDataControlSource{}, map[uint16]bool{1: true}, nil},110 "ext_data_control_source_v1": {wire_extdatacontrol.ExtDataControlSourceV1Interface, &client_datacontrol.ExtDataControlSource{}, map[uint16]bool{1: true}, nil},
111+ "ext_foreign_toplevel_list_v1": {wire_extforeigntoplevellist.ExtForeignToplevelListV1Interface, &client_extforeigntoplevel.ExtForeignToplevelList{}, map[uint16]bool{1: true}, map[uint16]bool{1: true}},
112+ "ext_foreign_toplevel_handle_v1": {wire_extforeigntoplevellist.ExtForeignToplevelHandleV1Interface, &client_extforeigntoplevel.ExtForeignToplevelHandle{}, map[uint16]bool{0: true}, nil},
109 "ext_foreign_toplevel_image_capture_source_manager_v1": {wire_extimagecapturesource.ExtForeignToplevelImageCaptureSourceManagerV1Interface, &client_imagecapturesource.ExtForeignToplevelImageCaptureSourceManager{}, map[uint16]bool{1: true}, nil},113 "ext_foreign_toplevel_image_capture_source_manager_v1": {wire_extimagecapturesource.ExtForeignToplevelImageCaptureSourceManagerV1Interface, &client_imagecapturesource.ExtForeignToplevelImageCaptureSourceManager{}, map[uint16]bool{1: true}, nil},
110 "ext_idle_notification_v1": {wire_extidlenotify.ExtIdleNotificationV1Interface, &client_idlenotify.ExtIdleNotification{}, map[uint16]bool{0: true}, nil},114 "ext_idle_notification_v1": {wire_extidlenotify.ExtIdleNotificationV1Interface, &client_idlenotify.ExtIdleNotification{}, map[uint16]bool{0: true}, nil},
111 "ext_idle_notifier_v1": {wire_extidlenotify.ExtIdleNotifierV1Interface, &client_idlenotify.ExtIdleNotifier{}, map[uint16]bool{0: true}, nil},115 "ext_idle_notifier_v1": {wire_extidlenotify.ExtIdleNotifierV1Interface, &client_idlenotify.ExtIdleNotifier{}, map[uint16]bool{0: true}, nil},
M internal/adapters/wayland/xdg.go +4 -0
@@ -134,6 +134,9 @@ type window struct {
134134 xdg *xdgSurface
135135 appID, title string
136136 mapped bool
137+ // gen counts the mappings: an unmap and a remap is a new toplevel to
138+ // ext-foreign-toplevel-list (identifier) and to its capture sources.
139+ gen uint32
137140 // parent is set_parent (dialogs); min and max are the size hints.
138141 parent *window
139142 minW, minH int32
@@ -232,6 +235,7 @@ func (x *xdgSurface) GetToplevel(r *xdgshell.Surface, id uint32) {
232235 x.serials = append(x.serials, x.server.serial)
233236 } else if buffer && !w.mapped && x.acked {
234237 w.mapped = true
238+ w.gen++
235239 slot := x.server.slotToken(r.Client())
236240 // Its size comes with the commit, in WindowResized (afterCommit).
237241 w.floating = w.floats()
@@ -134,6 +134,9 @@ type window struct {
134 xdg *xdgSurface134 xdg *xdgSurface
135 appID, title string135 appID, title string
136 mapped bool136 mapped bool
137+ // gen counts the mappings: an unmap and a remap is a new toplevel to
138+ // ext-foreign-toplevel-list (identifier) and to its capture sources.
139+ gen uint32
137 // parent is set_parent (dialogs); min and max are the size hints.140 // parent is set_parent (dialogs); min and max are the size hints.
138 parent *window141 parent *window
139 minW, minH int32142 minW, minH int32
@@ -232,6 +235,7 @@ func (x *xdgSurface) GetToplevel(r *xdgshell.Surface, id uint32) {
232 x.serials = append(x.serials, x.server.serial)235 x.serials = append(x.serials, x.server.serial)
233 } else if buffer && !w.mapped && x.acked {236 } else if buffer && !w.mapped && x.acked {
234 w.mapped = true237 w.mapped = true
238+ w.gen++
235 slot := x.server.slotToken(r.Client())239 slot := x.server.slotToken(r.Client())
236 // Its size comes with the commit, in WindowResized (afterCommit).240 // Its size comes with the commit, in WindowResized (afterCommit).
237 w.floating = w.floats()241 w.floating = w.floats()
M internal/core/capindicator.go +17 -8
@@ -46,7 +46,7 @@ func (c *Core) captureFrame(v ports.CaptureFrameTaken) bool {
4646 if s := c.capt.session(v.Session); v.Session != 0 && s != nil && !s.recording {
4747 s.recording, changed = true, true
4848 }
49- t := capTarget{output: v.Output, workspace: v.Workspace, rect: v.Region}
49+ t := capTarget{output: v.Output, workspace: v.Workspace, window: v.Window, rect: v.Region}
5050 if _, reason := c.capResolve(t); reason != ports.CaptureReasonNone {
5151 // Nothing of the target is left on screen to mark.
5252 return changed
@@ -185,14 +185,23 @@ func (c *Core) captureIndicators(sc *screen) []ports.CaptureIndicator {
185185 return
186186 }
187187 o := sc.mon.Output()
188- var m ports.CaptureIndicator
189- if r.hidden {
190- m = ports.CaptureIndicator{Pill: true, Rect: pillRect(o)}
191- } else {
192- m = ports.CaptureIndicator{Rect: thickenMark(r.rect, o)}
188+ mark := func(m ports.CaptureIndicator) {
189+ if !slices.Contains(out, m) {
190+ out = append(out, m)
191+ }
193192 }
194- if !slices.Contains(out, m) {
195- out = append(out, m)
193+ switch {
194+ case t.window != 0:
195+ // A window is rendered off screen, so its frames need the pill;
196+ // while it is on screen it is outlined too.
197+ mark(ports.CaptureIndicator{Pill: true, Rect: pillRect(o)})
198+ if r.visible {
199+ mark(ports.CaptureIndicator{Rect: thickenMark(r.rect, o)})
200+ }
201+ case r.hidden:
202+ mark(ports.CaptureIndicator{Pill: true, Rect: pillRect(o)})
203+ default:
204+ mark(ports.CaptureIndicator{Rect: thickenMark(r.rect, o)})
196205 }
197206 }
198207 for _, s := range c.capt.sessions {
@@ -46,7 +46,7 @@ func (c *Core) captureFrame(v ports.CaptureFrameTaken) bool {
46 if s := c.capt.session(v.Session); v.Session != 0 && s != nil && !s.recording {46 if s := c.capt.session(v.Session); v.Session != 0 && s != nil && !s.recording {
47 s.recording, changed = true, true47 s.recording, changed = true, true
48 }48 }
49- t := capTarget{output: v.Output, workspace: v.Workspace, rect: v.Region}49+ t := capTarget{output: v.Output, workspace: v.Workspace, window: v.Window, rect: v.Region}
50 if _, reason := c.capResolve(t); reason != ports.CaptureReasonNone {50 if _, reason := c.capResolve(t); reason != ports.CaptureReasonNone {
51 // Nothing of the target is left on screen to mark.51 // Nothing of the target is left on screen to mark.
52 return changed52 return changed
@@ -185,14 +185,23 @@ func (c *Core) captureIndicators(sc *screen) []ports.CaptureIndicator {
185 return185 return
186 }186 }
187 o := sc.mon.Output()187 o := sc.mon.Output()
188- var m ports.CaptureIndicator188+ mark := func(m ports.CaptureIndicator) {
189- if r.hidden {189+ if !slices.Contains(out, m) {
190- m = ports.CaptureIndicator{Pill: true, Rect: pillRect(o)}190+ out = append(out, m)
191- } else {191+ }
192- m = ports.CaptureIndicator{Rect: thickenMark(r.rect, o)}
193 }192 }
194- if !slices.Contains(out, m) {193+ switch {
195- out = append(out, m)194+ case t.window != 0:
195+ // A window is rendered off screen, so its frames need the pill;
196+ // while it is on screen it is outlined too.
197+ mark(ports.CaptureIndicator{Pill: true, Rect: pillRect(o)})
198+ if r.visible {
199+ mark(ports.CaptureIndicator{Rect: thickenMark(r.rect, o)})
200+ }
201+ case r.hidden:
202+ mark(ports.CaptureIndicator{Pill: true, Rect: pillRect(o)})
203+ default:
204+ mark(ports.CaptureIndicator{Rect: thickenMark(r.rect, o)})
196 }205 }
197 }206 }
198 for _, s := range c.capt.sessions {207 for _, s := range c.capt.sessions {
M internal/core/capture.go +41 -5
@@ -57,6 +57,7 @@ func (s *captureState) idle() bool {
5757 type capTarget struct {
5858 output string
5959 workspace uint64
60+ window WindowID
6061 rect Rect // output-local logical; zero is the whole output or frame
6162 }
6263
@@ -70,7 +71,7 @@ type capSession struct {
7071 }
7172
7273 func (s *capSession) target() capTarget {
73- return capTarget{output: s.open.Output, workspace: s.open.Workspace, rect: s.open.Region}
74+ return capTarget{output: s.open.Output, workspace: s.open.Workspace, window: s.open.Window, rect: s.open.Region}
7475 }
7576
7677 // capExclusion is the one live exclusion.
@@ -108,21 +109,44 @@ type capView struct {
108109 hidden *Workspace // captured off screen, nil when none
109110 hiddenScr *screen
110111 hiddenID uint64
112+ // window is the window rendered off screen for a session, 0 when none.
113+ // One target at most, a workspace or a window, is rendered off screen.
114+ window WindowID
115+ windowScr *screen
116+ windowSz Rect // its client size, logical, at the origin
111117 excluded []WindowID
112118 exclusion *capExclusion
113119 }
114120
121+// offscreen reports whether the view renders a target off screen.
122+func (v *capView) offscreen() bool { return v.hiddenID != 0 || v.window != 0 }
123+
115124 // capResolved is a target located on the current screens.
116125 type capResolved struct {
117126 sc *screen
118127 ws *Workspace
119128 rect Rect // clipped, output-local logical
120129 hidden bool
130+ // size is a window's client size at the origin; visible tells whether
131+ // the window is on screen, at rect.
132+ size Rect
133+ visible bool
121134 }
122135
123136 // capResolve locates a target. A zero reason is success.
124137 func (c *Core) capResolve(t capTarget) (capResolved, ports.CaptureReason) {
125138 var r capResolved
139+ if t.window != 0 {
140+ sc, w := c.screenOf(t.window)
141+ if w == nil || sc.name() == "" {
142+ return r, ports.CaptureReasonWindowGone
143+ }
144+ r.sc, r.ws, r.hidden, r.size = sc, w, true, c.windowSize(w, t.window)
145+ if vs, vr, ok := c.windowRect(t.window); ok && vs == sc {
146+ r.rect, r.visible = vr, true
147+ }
148+ return r, ports.CaptureReasonNone
149+ }
126150 if t.workspace != 0 {
127151 sc, w := c.workspaceByID(t.workspace)
128152 if w == nil || sc.name() == "" {
@@ -335,12 +359,21 @@ func (c *Core) captureEvaluate(ctx context.Context) (*capView, error) {
335359 for i, s := range c.capt.sessions {
336360 st := ports.CaptureSessionState{ID: s.open.ID}
337361 r, reason := c.capResolve(s.target())
338- if reason == ports.CaptureReasonNone {
362+ switch {
363+ case reason != ports.CaptureReasonNone:
364+ case s.open.Window != 0:
365+ // A window is always rendered off screen, alone at its size.
366+ st.Output, st.Rect, st.Hidden = r.sc.name(), r.size, true
367+ if !v.offscreen() && r.size.W > 0 && r.size.H > 0 {
368+ v.window, v.windowScr, v.windowSz = s.open.Window, r.sc, r.size
369+ }
370+ st.Active = v.window == s.open.Window
371+ default:
339372 st.Output, st.Rect, st.Workspace, st.Hidden = r.sc.name(), r.rect, s.open.Workspace, r.hidden
340- if r.hidden && v.hiddenID == 0 {
373+ if r.hidden && !v.offscreen() {
341374 v.hidden, v.hiddenScr, v.hiddenID = r.ws, r.sc, s.open.Workspace
342375 }
343- // One hidden workspace is rendered at a time.
376+ // One target is rendered off screen at a time.
344377 st.Active = !r.hidden || v.hiddenID == s.open.Workspace
345378 }
346379 st.Reason = reason
@@ -443,10 +476,13 @@ func (c *Core) captureSceneFor(sc *screen, v *capView) *ports.SceneCapture {
443476 if v.hiddenScr == sc {
444477 want.Workspace = v.hiddenID
445478 }
479+ if v.windowScr == sc {
480+ want.Window = v.window
481+ }
446482 // The scene is read by the output owner and never changed: the last one
447483 // is shared while it says the same.
448484 if p := sc.capScene; p != nil && p.Shown == want.Shown && p.Session == want.Session && p.Revision == want.Revision &&
449- p.Workspace == want.Workspace && slices.Equal(p.Excluded, want.Excluded) {
485+ p.Workspace == want.Workspace && p.Window == want.Window && slices.Equal(p.Excluded, want.Excluded) {
450486 return p
451487 }
452488 shared := want
@@ -57,6 +57,7 @@ func (s *captureState) idle() bool {
57 type capTarget struct {57 type capTarget struct {
58 output string58 output string
59 workspace uint6459 workspace uint64
60+ window WindowID
60 rect Rect // output-local logical; zero is the whole output or frame61 rect Rect // output-local logical; zero is the whole output or frame
61 }62 }
62 63
@@ -70,7 +71,7 @@ type capSession struct {
70 }71 }
71 72
72 func (s *capSession) target() capTarget {73 func (s *capSession) target() capTarget {
73- return capTarget{output: s.open.Output, workspace: s.open.Workspace, rect: s.open.Region}74+ return capTarget{output: s.open.Output, workspace: s.open.Workspace, window: s.open.Window, rect: s.open.Region}
74 }75 }
75 76
76 // capExclusion is the one live exclusion.77 // capExclusion is the one live exclusion.
@@ -108,21 +109,44 @@ type capView struct {
108 hidden *Workspace // captured off screen, nil when none109 hidden *Workspace // captured off screen, nil when none
109 hiddenScr *screen110 hiddenScr *screen
110 hiddenID uint64111 hiddenID uint64
112+ // window is the window rendered off screen for a session, 0 when none.
113+ // One target at most, a workspace or a window, is rendered off screen.
114+ window WindowID
115+ windowScr *screen
116+ windowSz Rect // its client size, logical, at the origin
111 excluded []WindowID117 excluded []WindowID
112 exclusion *capExclusion118 exclusion *capExclusion
113 }119 }
114 120
121+// offscreen reports whether the view renders a target off screen.
122+func (v *capView) offscreen() bool { return v.hiddenID != 0 || v.window != 0 }
123+
115 // capResolved is a target located on the current screens.124 // capResolved is a target located on the current screens.
116 type capResolved struct {125 type capResolved struct {
117 sc *screen126 sc *screen
118 ws *Workspace127 ws *Workspace
119 rect Rect // clipped, output-local logical128 rect Rect // clipped, output-local logical
120 hidden bool129 hidden bool
130+ // size is a window's client size at the origin; visible tells whether
131+ // the window is on screen, at rect.
132+ size Rect
133+ visible bool
121 }134 }
122 135
123 // capResolve locates a target. A zero reason is success.136 // capResolve locates a target. A zero reason is success.
124 func (c *Core) capResolve(t capTarget) (capResolved, ports.CaptureReason) {137 func (c *Core) capResolve(t capTarget) (capResolved, ports.CaptureReason) {
125 var r capResolved138 var r capResolved
139+ if t.window != 0 {
140+ sc, w := c.screenOf(t.window)
141+ if w == nil || sc.name() == "" {
142+ return r, ports.CaptureReasonWindowGone
143+ }
144+ r.sc, r.ws, r.hidden, r.size = sc, w, true, c.windowSize(w, t.window)
145+ if vs, vr, ok := c.windowRect(t.window); ok && vs == sc {
146+ r.rect, r.visible = vr, true
147+ }
148+ return r, ports.CaptureReasonNone
149+ }
126 if t.workspace != 0 {150 if t.workspace != 0 {
127 sc, w := c.workspaceByID(t.workspace)151 sc, w := c.workspaceByID(t.workspace)
128 if w == nil || sc.name() == "" {152 if w == nil || sc.name() == "" {
@@ -335,12 +359,21 @@ func (c *Core) captureEvaluate(ctx context.Context) (*capView, error) {
335 for i, s := range c.capt.sessions {359 for i, s := range c.capt.sessions {
336 st := ports.CaptureSessionState{ID: s.open.ID}360 st := ports.CaptureSessionState{ID: s.open.ID}
337 r, reason := c.capResolve(s.target())361 r, reason := c.capResolve(s.target())
338- if reason == ports.CaptureReasonNone {362+ switch {
363+ case reason != ports.CaptureReasonNone:
364+ case s.open.Window != 0:
365+ // A window is always rendered off screen, alone at its size.
366+ st.Output, st.Rect, st.Hidden = r.sc.name(), r.size, true
367+ if !v.offscreen() && r.size.W > 0 && r.size.H > 0 {
368+ v.window, v.windowScr, v.windowSz = s.open.Window, r.sc, r.size
369+ }
370+ st.Active = v.window == s.open.Window
371+ default:
339 st.Output, st.Rect, st.Workspace, st.Hidden = r.sc.name(), r.rect, s.open.Workspace, r.hidden372 st.Output, st.Rect, st.Workspace, st.Hidden = r.sc.name(), r.rect, s.open.Workspace, r.hidden
340- if r.hidden && v.hiddenID == 0 {373+ if r.hidden && !v.offscreen() {
341 v.hidden, v.hiddenScr, v.hiddenID = r.ws, r.sc, s.open.Workspace374 v.hidden, v.hiddenScr, v.hiddenID = r.ws, r.sc, s.open.Workspace
342 }375 }
343- // One hidden workspace is rendered at a time.376+ // One target is rendered off screen at a time.
344 st.Active = !r.hidden || v.hiddenID == s.open.Workspace377 st.Active = !r.hidden || v.hiddenID == s.open.Workspace
345 }378 }
346 st.Reason = reason379 st.Reason = reason
@@ -443,10 +476,13 @@ func (c *Core) captureSceneFor(sc *screen, v *capView) *ports.SceneCapture {
443 if v.hiddenScr == sc {476 if v.hiddenScr == sc {
444 want.Workspace = v.hiddenID477 want.Workspace = v.hiddenID
445 }478 }
479+ if v.windowScr == sc {
480+ want.Window = v.window
481+ }
446 // The scene is read by the output owner and never changed: the last one482 // The scene is read by the output owner and never changed: the last one
447 // is shared while it says the same.483 // is shared while it says the same.
448 if p := sc.capScene; p != nil && p.Shown == want.Shown && p.Session == want.Session && p.Revision == want.Revision &&484 if p := sc.capScene; p != nil && p.Shown == want.Shown && p.Session == want.Session && p.Revision == want.Revision &&
449- p.Workspace == want.Workspace && slices.Equal(p.Excluded, want.Excluded) {485+ p.Workspace == want.Workspace && p.Window == want.Window && slices.Equal(p.Excluded, want.Excluded) {
450 return p486 return p
451 }487 }
452 shared := want488 shared := want
A internal/core/capture_window.go +65 -0
@@ -0,0 +1,65 @@
1+package core
2+
3+import "github.com/bnema/neferwl/internal/ports"
4+
5+// Window capture: a session of one window (CaptureSessionOpen.Window) renders
6+// it off screen, alone, at its client size, wherever it is: on screen, on
7+// another workspace, under a fullscreen window or in a hidden stash. While it
8+// is captured and not on screen its configure says Captured, so the client
9+// keeps drawing (not suspended). The scene it is drawn from is
10+// Scene.CaptureScene of the output that owns it, like a hidden workspace; one
11+// target, a workspace or a window, is rendered off screen at a time.
12+
13+// windowSize is a window's client size, at the origin: its placement on its
14+// own workspace or, when that hides it (a covered float), the float's own
15+// size, else the size it was last configured to (a stashed window keeps its
16+// imposed stash size).
17+func (c *Core) windowSize(w *Workspace, id WindowID) Rect {
18+ for _, p := range w.Layout() {
19+ if p.ID == id && !p.Hidden && p.Rect.W > 0 && p.Rect.H > 0 {
20+ r := c.clientRect(p)
21+ return Rect{W: r.W, H: r.H}
22+ }
23+ }
24+ if i := w.floatIndex(id); i >= 0 && w.Floats[i].W > 0 && w.Floats[i].H > 0 {
25+ return Rect{W: w.Floats[i].W, H: w.Floats[i].H}
26+ }
27+ if v, ok := c.configures.sent[id]; ok && v.Width > 0 && v.Height > 0 {
28+ return Rect{W: v.Width, H: v.Height}
29+ }
30+ return Rect{}
31+}
32+
33+// popupOffset is where a popup lies relative to the client area of its root
34+// window.
35+func (c *Core) popupOffset(id WindowID) Rect {
36+ p := c.popups[id]
37+ if p == nil {
38+ return Rect{}
39+ }
40+ r := p.rect
41+ if c.popups[p.parent] != nil {
42+ o := c.popupOffset(p.parent)
43+ r.X, r.Y = r.X+o.X, r.Y+o.Y
44+ }
45+ return r
46+}
47+
48+// captureWindowScene is the window of v drawn for capture only: a scene of its
49+// client size with the window at the origin and its mapped popups over it.
50+func (c *Core) captureWindowScene(v *capView, seq uint64) *ports.Scene {
51+ sz := v.windowSz
52+ s := &ports.Scene{
53+ Security: c.security,
54+ Output: v.windowScr.name(), Seq: seq, OutputWidth: sz.W, OutputHeight: sz.H, WorkspaceClip: Rect{W: sz.W, H: sz.H},
55+ Scale: v.windowScr.scale, Background: c.cfg.Background.Color,
56+ Border: ports.Border{Width: c.cfg.Border.Width, Active: c.cfg.Border.Active, Inactive: c.cfg.Border.Inactive},
57+ Windows: []ports.SceneWindow{{ID: v.window, Rect: Rect{W: sz.W, H: sz.H}, Fullscreen: true}},
58+ }
59+ for _, id := range c.popupOrder {
60+ if p := c.popups[id]; p != nil && p.mapped && c.popupRoot(id) == v.window {
61+ s.Windows = append(s.Windows, ports.SceneWindow{ID: id, Rect: c.popupOffset(id), Popup: true})
62+ }
63+ }
64+ return s
65+}
@@ -0,0 +1,65 @@
1+package core
2+
3+import "github.com/bnema/neferwl/internal/ports"
4+
5+// Window capture: a session of one window (CaptureSessionOpen.Window) renders
6+// it off screen, alone, at its client size, wherever it is: on screen, on
7+// another workspace, under a fullscreen window or in a hidden stash. While it
8+// is captured and not on screen its configure says Captured, so the client
9+// keeps drawing (not suspended). The scene it is drawn from is
10+// Scene.CaptureScene of the output that owns it, like a hidden workspace; one
11+// target, a workspace or a window, is rendered off screen at a time.
12+
13+// windowSize is a window's client size, at the origin: its placement on its
14+// own workspace or, when that hides it (a covered float), the float's own
15+// size, else the size it was last configured to (a stashed window keeps its
16+// imposed stash size).
17+func (c *Core) windowSize(w *Workspace, id WindowID) Rect {
18+ for _, p := range w.Layout() {
19+ if p.ID == id && !p.Hidden && p.Rect.W > 0 && p.Rect.H > 0 {
20+ r := c.clientRect(p)
21+ return Rect{W: r.W, H: r.H}
22+ }
23+ }
24+ if i := w.floatIndex(id); i >= 0 && w.Floats[i].W > 0 && w.Floats[i].H > 0 {
25+ return Rect{W: w.Floats[i].W, H: w.Floats[i].H}
26+ }
27+ if v, ok := c.configures.sent[id]; ok && v.Width > 0 && v.Height > 0 {
28+ return Rect{W: v.Width, H: v.Height}
29+ }
30+ return Rect{}
31+}
32+
33+// popupOffset is where a popup lies relative to the client area of its root
34+// window.
35+func (c *Core) popupOffset(id WindowID) Rect {
36+ p := c.popups[id]
37+ if p == nil {
38+ return Rect{}
39+ }
40+ r := p.rect
41+ if c.popups[p.parent] != nil {
42+ o := c.popupOffset(p.parent)
43+ r.X, r.Y = r.X+o.X, r.Y+o.Y
44+ }
45+ return r
46+}
47+
48+// captureWindowScene is the window of v drawn for capture only: a scene of its
49+// client size with the window at the origin and its mapped popups over it.
50+func (c *Core) captureWindowScene(v *capView, seq uint64) *ports.Scene {
51+ sz := v.windowSz
52+ s := &ports.Scene{
53+ Security: c.security,
54+ Output: v.windowScr.name(), Seq: seq, OutputWidth: sz.W, OutputHeight: sz.H, WorkspaceClip: Rect{W: sz.W, H: sz.H},
55+ Scale: v.windowScr.scale, Background: c.cfg.Background.Color,
56+ Border: ports.Border{Width: c.cfg.Border.Width, Active: c.cfg.Border.Active, Inactive: c.cfg.Border.Inactive},
57+ Windows: []ports.SceneWindow{{ID: v.window, Rect: Rect{W: sz.W, H: sz.H}, Fullscreen: true}},
58+ }
59+ for _, id := range c.popupOrder {
60+ if p := c.popups[id]; p != nil && p.mapped && c.popupRoot(id) == v.window {
61+ s.Windows = append(s.Windows, ports.SceneWindow{ID: id, Rect: c.popupOffset(id), Popup: true})
62+ }
63+ }
64+ return s
65+}
A internal/core/capture_window_test.go +123 -0
@@ -0,0 +1,123 @@
1+package core_test
2+
3+import (
4+ "testing"
5+
6+ "github.com/bnema/neferwl/internal/ports"
7+)
8+
9+// A window session renders the window alone, at its client size, from the
10+// first frame and whatever workspace is shown; once the window leaves the
11+// screen it is configured Captured, and the session ends when it goes.
12+func TestCaptureWindowSession(t *testing.T) {
13+ r := captureCore(t)
14+ r.client <- ports.WindowMapped{ID: 1}
15+ scene(t, r.scenes)
16+ r.client <- ports.WindowMapped{ID: 2}
17+ scene(t, r.scenes)
18+ var ws []ports.WorkspaceInfo
19+ for len(ws) < 2 {
20+ ws = receive(t, r.workspaces).Outputs[0].Workspaces
21+ }
22+ r.client <- ports.CaptureSessionOpen{ID: 1, Window: 1}
23+ st := r.state(t)
24+ if !st.Active || !st.Hidden || st.Output != "OUT-1" || st.Rect.W <= 0 || st.Rect.H <= 0 || st.Rect.X != 0 || st.Rect.Y != 0 {
25+ t.Fatalf("state %+v", st)
26+ }
27+ s := r.sceneWith(t, true)
28+ if s.Capture.Window != 1 || s.Capture.Workspace != 0 || s.CaptureScene == nil {
29+ t.Fatalf("capture %+v scene %v", s.Capture, s.CaptureScene)
30+ }
31+ c := s.CaptureScene
32+ if c.OutputWidth != st.Rect.W || c.OutputHeight != st.Rect.H || len(c.Windows) != 1 || c.Windows[0].ID != 1 || c.Windows[0].Rect != (ports.Rect{W: st.Rect.W, H: st.Rect.H}) {
33+ t.Fatalf("window scene %+v", c)
34+ }
35+
36+ // Another workspace on screen: window 1 is hidden yet kept drawing.
37+ r.client <- ports.WorkspaceActivate{IDs: []uint64{ws[1].ID}}
38+ for {
39+ v := r.configureOf(t, 1)
40+ if !v.Visible {
41+ if !v.Captured || v.Width != st.Rect.W {
42+ t.Fatalf("hidden captured window %+v", v)
43+ }
44+ break
45+ }
46+ }
47+ for {
48+ if s = scene(t, r.scenes); s.Capture != nil && s.Capture.Window == 1 && s.CaptureScene != nil {
49+ break
50+ }
51+ }
52+
53+ // The window goes: the session ends with window-gone.
54+ r.client <- ports.WindowUnmapped{ID: 1}
55+ for {
56+ if st = r.state(t); st.Reason != ports.CaptureReasonNone {
57+ break
58+ }
59+ }
60+ if st.Reason != ports.CaptureReasonWindowGone {
61+ t.Fatalf("end %+v", st)
62+ }
63+}
64+
65+// The indicator of a window session is a pill on its output, and the
66+// window's outline while it is on screen.
67+func TestCaptureWindowIndicator(t *testing.T) {
68+ r := captureCore(t)
69+ r.client <- ports.WindowMapped{ID: 1}
70+ scene(t, r.scenes)
71+ r.client <- ports.CaptureSessionOpen{ID: 1, Window: 1}
72+ r.state(t)
73+ r.client <- ports.CaptureFrameTaken{Session: 1, Window: 1}
74+ for {
75+ s := scene(t, r.scenes)
76+ pill, border := false, false
77+ for _, m := range s.CaptureIndicators {
78+ pill = pill || m.Pill
79+ border = border || !m.Pill
80+ }
81+ if pill && border {
82+ return
83+ }
84+ }
85+}
86+
87+// A captured window on a hidden workspace follows that workspace's layout:
88+// when a sibling closes, the capture and its configure grow together.
89+func TestCaptureWindowHiddenReflow(t *testing.T) {
90+ r := captureCore(t)
91+ r.client <- ports.WindowMapped{ID: 1}
92+ scene(t, r.scenes)
93+ r.client <- ports.WindowMapped{ID: 2}
94+ scene(t, r.scenes)
95+ var ws []ports.WorkspaceInfo
96+ for len(ws) < 2 {
97+ ws = receive(t, r.workspaces).Outputs[0].Workspaces
98+ }
99+ r.client <- ports.CaptureSessionOpen{ID: 1, Window: 1}
100+ before := r.state(t)
101+ r.client <- ports.WorkspaceActivate{IDs: []uint64{ws[1].ID}}
102+ for {
103+ if v := r.configureOf(t, 1); !v.Visible {
104+ break
105+ }
106+ }
107+ r.client <- ports.WindowUnmapped{ID: 2}
108+ var after ports.CaptureSessionState
109+ for {
110+ if after = r.state(t); after.Rect != before.Rect {
111+ break
112+ }
113+ }
114+ for {
115+ v := r.configureOf(t, 1)
116+ if v.Width == after.Rect.W && v.Height == after.Rect.H {
117+ if v.Visible || !v.Captured {
118+ t.Fatalf("reflowed hidden window %+v", v)
119+ }
120+ return
121+ }
122+ }
123+}
@@ -0,0 +1,123 @@
1+package core_test
2+
3+import (
4+ "testing"
5+
6+ "github.com/bnema/neferwl/internal/ports"
7+)
8+
9+// A window session renders the window alone, at its client size, from the
10+// first frame and whatever workspace is shown; once the window leaves the
11+// screen it is configured Captured, and the session ends when it goes.
12+func TestCaptureWindowSession(t *testing.T) {
13+ r := captureCore(t)
14+ r.client <- ports.WindowMapped{ID: 1}
15+ scene(t, r.scenes)
16+ r.client <- ports.WindowMapped{ID: 2}
17+ scene(t, r.scenes)
18+ var ws []ports.WorkspaceInfo
19+ for len(ws) < 2 {
20+ ws = receive(t, r.workspaces).Outputs[0].Workspaces
21+ }
22+ r.client <- ports.CaptureSessionOpen{ID: 1, Window: 1}
23+ st := r.state(t)
24+ if !st.Active || !st.Hidden || st.Output != "OUT-1" || st.Rect.W <= 0 || st.Rect.H <= 0 || st.Rect.X != 0 || st.Rect.Y != 0 {
25+ t.Fatalf("state %+v", st)
26+ }
27+ s := r.sceneWith(t, true)
28+ if s.Capture.Window != 1 || s.Capture.Workspace != 0 || s.CaptureScene == nil {
29+ t.Fatalf("capture %+v scene %v", s.Capture, s.CaptureScene)
30+ }
31+ c := s.CaptureScene
32+ if c.OutputWidth != st.Rect.W || c.OutputHeight != st.Rect.H || len(c.Windows) != 1 || c.Windows[0].ID != 1 || c.Windows[0].Rect != (ports.Rect{W: st.Rect.W, H: st.Rect.H}) {
33+ t.Fatalf("window scene %+v", c)
34+ }
35+
36+ // Another workspace on screen: window 1 is hidden yet kept drawing.
37+ r.client <- ports.WorkspaceActivate{IDs: []uint64{ws[1].ID}}
38+ for {
39+ v := r.configureOf(t, 1)
40+ if !v.Visible {
41+ if !v.Captured || v.Width != st.Rect.W {
42+ t.Fatalf("hidden captured window %+v", v)
43+ }
44+ break
45+ }
46+ }
47+ for {
48+ if s = scene(t, r.scenes); s.Capture != nil && s.Capture.Window == 1 && s.CaptureScene != nil {
49+ break
50+ }
51+ }
52+
53+ // The window goes: the session ends with window-gone.
54+ r.client <- ports.WindowUnmapped{ID: 1}
55+ for {
56+ if st = r.state(t); st.Reason != ports.CaptureReasonNone {
57+ break
58+ }
59+ }
60+ if st.Reason != ports.CaptureReasonWindowGone {
61+ t.Fatalf("end %+v", st)
62+ }
63+}
64+
65+// The indicator of a window session is a pill on its output, and the
66+// window's outline while it is on screen.
67+func TestCaptureWindowIndicator(t *testing.T) {
68+ r := captureCore(t)
69+ r.client <- ports.WindowMapped{ID: 1}
70+ scene(t, r.scenes)
71+ r.client <- ports.CaptureSessionOpen{ID: 1, Window: 1}
72+ r.state(t)
73+ r.client <- ports.CaptureFrameTaken{Session: 1, Window: 1}
74+ for {
75+ s := scene(t, r.scenes)
76+ pill, border := false, false
77+ for _, m := range s.CaptureIndicators {
78+ pill = pill || m.Pill
79+ border = border || !m.Pill
80+ }
81+ if pill && border {
82+ return
83+ }
84+ }
85+}
86+
87+// A captured window on a hidden workspace follows that workspace's layout:
88+// when a sibling closes, the capture and its configure grow together.
89+func TestCaptureWindowHiddenReflow(t *testing.T) {
90+ r := captureCore(t)
91+ r.client <- ports.WindowMapped{ID: 1}
92+ scene(t, r.scenes)
93+ r.client <- ports.WindowMapped{ID: 2}
94+ scene(t, r.scenes)
95+ var ws []ports.WorkspaceInfo
96+ for len(ws) < 2 {
97+ ws = receive(t, r.workspaces).Outputs[0].Workspaces
98+ }
99+ r.client <- ports.CaptureSessionOpen{ID: 1, Window: 1}
100+ before := r.state(t)
101+ r.client <- ports.WorkspaceActivate{IDs: []uint64{ws[1].ID}}
102+ for {
103+ if v := r.configureOf(t, 1); !v.Visible {
104+ break
105+ }
106+ }
107+ r.client <- ports.WindowUnmapped{ID: 2}
108+ var after ports.CaptureSessionState
109+ for {
110+ if after = r.state(t); after.Rect != before.Rect {
111+ break
112+ }
113+ }
114+ for {
115+ v := r.configureOf(t, 1)
116+ if v.Width == after.Rect.W && v.Height == after.Rect.H {
117+ if v.Visible || !v.Captured {
118+ t.Fatalf("reflowed hidden window %+v", v)
119+ }
120+ return
121+ }
122+ }
123+}
M internal/core/configures.go +10 -1
@@ -16,6 +16,9 @@ type configureTarget struct {
1616 captureArea Rect
1717 // realTiled sizes a visible tiled preview from its real client rect.
1818 realTiled bool
19+ // captured: a capture session renders this window off screen (window
20+ // capture), so off screen it is Captured, not suspended.
21+ captured bool
1922 }
2023
2124 // configures owns the last configure sent to each window and decides the
@@ -40,6 +43,12 @@ func (s *configures) next(p Placement, t configureTarget) (ports.ConfigureWindow
4043 s.seen[p.ID] = true
4144 old, ok := s.sent[p.ID]
4245 v := build(p, t, old, ok)
46+ v.Captured = t.captured && !v.Visible
47+ if t.captured && p.Hidden && t.client.W > 0 && t.client.H > 0 {
48+ // Hidden but captured: sized for the capture, which follows its real
49+ // layout.
50+ v.Width, v.Height = t.client.W, t.client.H
51+ }
4352 return v, !ok || v != old || s.answer[p.ID]
4453 }
4554
@@ -68,7 +77,7 @@ func (s *configures) nextWithCapture(p Placement, t configureTarget, cp *Placeme
6877 } else {
6978 v = build(p, t, old, ok)
7079 }
71- v.Captured = !v.Visible && !cp.Hidden && cp.Rect.Overlaps(t.captureArea)
80+ v.Captured = !v.Visible && (t.captured || !cp.Hidden && cp.Rect.Overlaps(t.captureArea))
7281 return v, !ok || v != old || s.answer[p.ID]
7382 }
7483
@@ -16,6 +16,9 @@ type configureTarget struct {
16 captureArea Rect16 captureArea Rect
17 // realTiled sizes a visible tiled preview from its real client rect.17 // realTiled sizes a visible tiled preview from its real client rect.
18 realTiled bool18 realTiled bool
19+ // captured: a capture session renders this window off screen (window
20+ // capture), so off screen it is Captured, not suspended.
21+ captured bool
19 }22 }
20 23
21 // configures owns the last configure sent to each window and decides the24 // configures owns the last configure sent to each window and decides the
@@ -40,6 +43,12 @@ func (s *configures) next(p Placement, t configureTarget) (ports.ConfigureWindow
40 s.seen[p.ID] = true43 s.seen[p.ID] = true
41 old, ok := s.sent[p.ID]44 old, ok := s.sent[p.ID]
42 v := build(p, t, old, ok)45 v := build(p, t, old, ok)
46+ v.Captured = t.captured && !v.Visible
47+ if t.captured && p.Hidden && t.client.W > 0 && t.client.H > 0 {
48+ // Hidden but captured: sized for the capture, which follows its real
49+ // layout.
50+ v.Width, v.Height = t.client.W, t.client.H
51+ }
43 return v, !ok || v != old || s.answer[p.ID]52 return v, !ok || v != old || s.answer[p.ID]
44 }53 }
45 54
@@ -68,7 +77,7 @@ func (s *configures) nextWithCapture(p Placement, t configureTarget, cp *Placeme
68 } else {77 } else {
69 v = build(p, t, old, ok)78 v = build(p, t, old, ok)
70 }79 }
71- v.Captured = !v.Visible && !cp.Hidden && cp.Rect.Overlaps(t.captureArea)80+ v.Captured = !v.Visible && (t.captured || !cp.Hidden && cp.Rect.Overlaps(t.captureArea))
72 return v, !ok || v != old || s.answer[p.ID]81 return v, !ok || v != old || s.answer[p.ID]
73 }82 }
74 83
M internal/core/core.go +11 -3
@@ -582,10 +582,13 @@ func (c *Core) publish(ctx context.Context) error {
582582 }
583583 }
584584 scene.Windows = append(scene.Windows, sw)
585- t := configureTarget{output: sc.name(), area: frame, focused: focused}
585+ t := configureTarget{output: sc.name(), area: frame, focused: focused, captured: capture != nil && capture.window == p.ID}
586586 if !p.Hidden && p.Preview == 0 {
587587 // Only a sized configure needs the client size.
588588 t.client, t.imposed = c.clientRect(p), sc.mon.Current().imposedFloat(p.ID)
589+ } else if t.captured && p.Hidden {
590+ // A captured hidden window is sized like its capture.
591+ t.client = capture.windowSz
589592 } else if p.Preview > 0 && !p.Hidden {
590593 if rp, ok := real[p.ID]; ok && !rp.Hidden {
591594 t.realTiled = !rp.Floating
@@ -602,8 +605,13 @@ func (c *Core) publish(ctx context.Context) error {
602605 }
603606 scene.Windows = append(scene.Windows, c.scenePopups(sc)...)
604607 scene.CaptureIndicators = c.captureIndicators(sc)
605- if scene.Capture = c.captureSceneFor(sc, capture); scene.Capture != nil && capture.hiddenScr == sc {
606- scene.CaptureScene = c.captureScene(scene.Seq)
608+ if scene.Capture = c.captureSceneFor(sc, capture); scene.Capture != nil {
609+ switch sc {
610+ case capture.hiddenScr:
611+ scene.CaptureScene = c.captureScene(scene.Seq)
612+ case capture.windowScr:
613+ scene.CaptureScene = c.captureWindowScene(capture, scene.Seq)
614+ }
607615 }
608616 scenes = append(scenes, scene)
609617 }
@@ -582,10 +582,13 @@ func (c *Core) publish(ctx context.Context) error {
582 }582 }
583 }583 }
584 scene.Windows = append(scene.Windows, sw)584 scene.Windows = append(scene.Windows, sw)
585- t := configureTarget{output: sc.name(), area: frame, focused: focused}585+ t := configureTarget{output: sc.name(), area: frame, focused: focused, captured: capture != nil && capture.window == p.ID}
586 if !p.Hidden && p.Preview == 0 {586 if !p.Hidden && p.Preview == 0 {
587 // Only a sized configure needs the client size.587 // Only a sized configure needs the client size.
588 t.client, t.imposed = c.clientRect(p), sc.mon.Current().imposedFloat(p.ID)588 t.client, t.imposed = c.clientRect(p), sc.mon.Current().imposedFloat(p.ID)
589+ } else if t.captured && p.Hidden {
590+ // A captured hidden window is sized like its capture.
591+ t.client = capture.windowSz
589 } else if p.Preview > 0 && !p.Hidden {592 } else if p.Preview > 0 && !p.Hidden {
590 if rp, ok := real[p.ID]; ok && !rp.Hidden {593 if rp, ok := real[p.ID]; ok && !rp.Hidden {
591 t.realTiled = !rp.Floating594 t.realTiled = !rp.Floating
@@ -602,8 +605,13 @@ func (c *Core) publish(ctx context.Context) error {
602 }605 }
603 scene.Windows = append(scene.Windows, c.scenePopups(sc)...)606 scene.Windows = append(scene.Windows, c.scenePopups(sc)...)
604 scene.CaptureIndicators = c.captureIndicators(sc)607 scene.CaptureIndicators = c.captureIndicators(sc)
605- if scene.Capture = c.captureSceneFor(sc, capture); scene.Capture != nil && capture.hiddenScr == sc {608+ if scene.Capture = c.captureSceneFor(sc, capture); scene.Capture != nil {
606- scene.CaptureScene = c.captureScene(scene.Seq)609+ switch sc {
610+ case capture.hiddenScr:
611+ scene.CaptureScene = c.captureScene(scene.Seq)
612+ case capture.windowScr:
613+ scene.CaptureScene = c.captureWindowScene(capture, scene.Seq)
614+ }
607 }615 }
608 scenes = append(scenes, scene)616 scenes = append(scenes, scene)
609 }617 }
M internal/ports/capture.go +10 -2
@@ -45,6 +45,7 @@ const (
4545 CaptureReasonOutputGone CaptureReason = "output-gone"
4646 CaptureReasonWorkspaceGone CaptureReason = "workspace-gone"
4747 CaptureReasonInvalidRegion CaptureReason = "invalid-region" // nothing of the region is left on the output
48+ CaptureReasonWindowGone CaptureReason = "window-gone"
4849 // TooManyExcluded: the attached layers and their popups exceed
4950 // MaxCaptureExcluded; a capture could not leave them all out (fail closed).
5051 CaptureReasonTooManyExcluded CaptureReason = "too-many-excluded"
@@ -57,11 +58,14 @@ func (r CaptureReason) Terminal() bool { return r != CaptureReasonNone }
5758 // session. Its target is an output (Workspace 0), whole when Region is zero,
5859 // else the part of it in Region (output-local logical pixels, clipped), or a
5960 // workspace (ID of WorkspaceInfo.ID): its frame, on whichever output owns it
60-// now. ID is chosen by wayland and unique for the process lifetime.
61+// now, or a window (Window not 0): its client area at its own size, wherever
62+// it is and whether or not it is on screen, always rendered off screen. ID is
63+// chosen by wayland and unique for the process lifetime.
6164 type CaptureSessionOpen struct {
6265 ID uint64
6366 Output string
6467 Workspace uint64
68+ Window WindowID
6569 Region Rect
6670 }
6771
@@ -85,6 +89,7 @@ type CaptureFrameTaken struct {
8589 Session uint64
8690 Output string
8791 Workspace uint64
92+ Window WindowID
8893 Region Rect
8994 }
9095
@@ -167,13 +172,16 @@ func (CaptureSessionState) clientCommand() {}
167172 // workspace CaptureScene draws, 0 when there is none on this output. Shown is
168173 // the ID of the workspace this scene draws as itself (0 in the overview): a
169174 // request for a workspace is served only when it is Shown here, or is the
170-// hidden Workspace; else it fails, whatever wayland last believed.
175+// hidden Workspace; else it fails, whatever wayland last believed. Window is
176+// the window CaptureScene draws instead, alone at its client size, 0 when
177+// none: one of Workspace and Window at most is set.
171178 type SceneCapture struct {
172179 Shown uint64
173180 Session uint64
174181 Excluded []WindowID
175182 Revision uint64
176183 Workspace uint64
184+ Window WindowID
177185 }
178186
179187 // CaptureIndicator carries core → renderer one mark of a capture on an
@@ -45,6 +45,7 @@ const (
45 CaptureReasonOutputGone CaptureReason = "output-gone"45 CaptureReasonOutputGone CaptureReason = "output-gone"
46 CaptureReasonWorkspaceGone CaptureReason = "workspace-gone"46 CaptureReasonWorkspaceGone CaptureReason = "workspace-gone"
47 CaptureReasonInvalidRegion CaptureReason = "invalid-region" // nothing of the region is left on the output47 CaptureReasonInvalidRegion CaptureReason = "invalid-region" // nothing of the region is left on the output
48+ CaptureReasonWindowGone CaptureReason = "window-gone"
48 // TooManyExcluded: the attached layers and their popups exceed49 // TooManyExcluded: the attached layers and their popups exceed
49 // MaxCaptureExcluded; a capture could not leave them all out (fail closed).50 // MaxCaptureExcluded; a capture could not leave them all out (fail closed).
50 CaptureReasonTooManyExcluded CaptureReason = "too-many-excluded"51 CaptureReasonTooManyExcluded CaptureReason = "too-many-excluded"
@@ -57,11 +58,14 @@ func (r CaptureReason) Terminal() bool { return r != CaptureReasonNone }
57 // session. Its target is an output (Workspace 0), whole when Region is zero,58 // session. Its target is an output (Workspace 0), whole when Region is zero,
58 // else the part of it in Region (output-local logical pixels, clipped), or a59 // else the part of it in Region (output-local logical pixels, clipped), or a
59 // workspace (ID of WorkspaceInfo.ID): its frame, on whichever output owns it60 // workspace (ID of WorkspaceInfo.ID): its frame, on whichever output owns it
60-// now. ID is chosen by wayland and unique for the process lifetime.61+// now, or a window (Window not 0): its client area at its own size, wherever
62+// it is and whether or not it is on screen, always rendered off screen. ID is
63+// chosen by wayland and unique for the process lifetime.
61 type CaptureSessionOpen struct {64 type CaptureSessionOpen struct {
62 ID uint6465 ID uint64
63 Output string66 Output string
64 Workspace uint6467 Workspace uint64
68+ Window WindowID
65 Region Rect69 Region Rect
66 }70 }
67 71
@@ -85,6 +89,7 @@ type CaptureFrameTaken struct {
85 Session uint6489 Session uint64
86 Output string90 Output string
87 Workspace uint6491 Workspace uint64
92+ Window WindowID
88 Region Rect93 Region Rect
89 }94 }
90 95
@@ -167,13 +172,16 @@ func (CaptureSessionState) clientCommand() {}
167 // workspace CaptureScene draws, 0 when there is none on this output. Shown is172 // workspace CaptureScene draws, 0 when there is none on this output. Shown is
168 // the ID of the workspace this scene draws as itself (0 in the overview): a173 // the ID of the workspace this scene draws as itself (0 in the overview): a
169 // request for a workspace is served only when it is Shown here, or is the174 // request for a workspace is served only when it is Shown here, or is the
170-// hidden Workspace; else it fails, whatever wayland last believed.175+// hidden Workspace; else it fails, whatever wayland last believed. Window is
176+// the window CaptureScene draws instead, alone at its client size, 0 when
177+// none: one of Workspace and Window at most is set.
171 type SceneCapture struct {178 type SceneCapture struct {
172 Shown uint64179 Shown uint64
173 Session uint64180 Session uint64
174 Excluded []WindowID181 Excluded []WindowID
175 Revision uint64182 Revision uint64
176 Workspace uint64183 Workspace uint64
184+ Window WindowID
177 }185 }
178 186
179 // CaptureIndicator carries core → renderer one mark of a capture on an187 // CaptureIndicator carries core → renderer one mark of a capture on an
M internal/ports/capture_messages.go +4 -0
@@ -36,6 +36,10 @@ type CaptureRequest struct {
3636 // another crop.
3737 Workspace uint64
3838 OffScreen bool
39+ // Window is not 0 for the capture of a window: always OffScreen, served
40+ // whole from Scene.CaptureScene, and failed unless the scene's
41+ // Capture.Window is Window.
42+ Window WindowID
3943 // Indicate is set by wayland on every capture it requests: core shows the
4044 // capture indicator for it (CaptureFrameTaken), and the output owner
4145 // serves the request only from a scene that shows the mark it needs (a
@@ -36,6 +36,10 @@ type CaptureRequest struct {
36 // another crop.36 // another crop.
37 Workspace uint6437 Workspace uint64
38 OffScreen bool38 OffScreen bool
39+ // Window is not 0 for the capture of a window: always OffScreen, served
40+ // whole from Scene.CaptureScene, and failed unless the scene's
41+ // Capture.Window is Window.
42+ Window WindowID
39 // Indicate is set by wayland on every capture it requests: core shows the43 // Indicate is set by wayland on every capture it requests: core shows the
40 // capture indicator for it (CaptureFrameTaken), and the output owner44 // capture indicator for it (CaptureFrameTaken), and the output owner
41 // serves the request only from a scene that shows the mark it needs (a45 // serves the request only from a scene that shows the mark it needs (a

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 25

Tags 10

↑↓ moveenter openesc close