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 Unverified
B5690EEEBB952194The 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 | |||
| 82 | 82 | ||
| 83 | 83 | ## 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 | 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. | |
| @@ -82,6 +82,6 @@ A refused client is logged once per distinct executable, at info level: `capture | |||||
| 82 | 82 | ||||
| 83 | ## Portals | 83 | ## 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 '*' | |||
| 97 | 97 | ||
| 98 | 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 | 102 | ## State for scripts | |
| 103 | 103 | ||
| @@ -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 scripts | 102 | ## State for scripts | ||
| 103 | 103 | ||||
M docs/headless.md +1 -1
| @@ -9,7 +9,7 @@ WAYLAND_DISPLAY=<name from the log> foot | |||
| 9 | 9 | ||
| 10 | 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 | 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 | ||
| @@ -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 | |||
| 24 | 24 | var ErrSessionStale = fmt.Errorf("%w: capture exclusion scene is older than the request", ports.ErrCaptureTransient) | |
| 25 | 25 | ||
| 26 | 26 | // 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. | |
| 29 | 30 | var ErrWorkspaceMoved = fmt.Errorf("%w: captured workspace is not where the request expected", ports.ErrCaptureTransient) | |
| 30 | 31 | ||
| 31 | 32 | // 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 | 152 | err = ErrSessionStale | |
| 152 | 153 | case q.Workspace != 0 && (s.Capture == nil || q.OffScreen && q.Workspace != s.Capture.Workspace || !q.OffScreen && q.Workspace != s.Capture.Shown): | |
| 153 | 154 | err = ErrWorkspaceMoved | |
| 155 | + | case q.Window != 0 && (s.Capture == nil || !q.OffScreen || q.Window != s.Capture.Window): | |
| 156 | + | err = ErrWorkspaceMoved | |
| 154 | 157 | } | |
| 155 | 158 | if err != nil { | |
| 156 | 159 | 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 on | 26 | // 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 the | 32 | // 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 = ErrSessionStale | 152 | 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 = ErrWorkspaceMoved | 154 | 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) { | |||
| 174 | 174 | require.Empty(t, hidden) | |
| 175 | 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 | + | } | |
| @@ -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 ( | |||
| 14 | 14 | "golang.org/x/sys/unix" | |
| 15 | 15 | ) | |
| 16 | 16 | ||
| 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 | |
| 20 | 20 | // its own Vulkan device, so its fences are not ordered against the display's. | |
| 21 | 21 | // It is created on the first request of the workspace and closed when the | |
| 22 | 22 | // scene stops carrying CaptureScene (once idle), when the workspace or the frame | |
| @@ -63,13 +63,19 @@ func (s *fenceSlot) release() { | |||
| 63 | 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 | 72 | // offscreen is owned by the goroutine of its Pipeline. | |
| 67 | 73 | 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 | |
| 73 | 79 | // slots[:nf] are the unfinished child renders, oldest first. | |
| 74 | 80 | slots [maxChildFences]fenceSlot | |
| 75 | 81 | nf int | |
| @@ -292,7 +298,7 @@ func (o *offscreen) close() { | |||
| 292 | 298 | for i := range o.slots { | |
| 293 | 299 | o.slots[i].release() | |
| 294 | 300 | } | |
| 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{} | |
| 296 | 302 | } | |
| 297 | 303 | ||
| 298 | 304 | // 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 | 356 | case s.Off: | |
| 351 | 357 | fail(errors.New("output off"), reqs) | |
| 352 | 358 | 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 | 360 | fail(ErrOffscreenUnavailable, reqs) | |
| 355 | 361 | return | |
| 356 | 362 | case o == nil || o.factory == nil: | |
| @@ -366,7 +372,8 @@ func (p *Pipeline) SubmitHidden(s ports.Scene, surfaces map[ports.WindowID]ports | |||
| 366 | 372 | fail(err, reqs) | |
| 367 | 373 | 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 | 377 | if o.busy() { | |
| 371 | 378 | fail(ErrOffscreenBusy, reqs) | |
| 372 | 379 | return | |
| @@ -385,7 +392,7 @@ func (p *Pipeline) SubmitHidden(s ports.Scene, surfaces map[ports.WindowID]ports | |||
| 385 | 392 | fail(fmt.Errorf("%w: %v", ErrOffscreenUnavailable, err), reqs) | |
| 386 | 393 | return | |
| 387 | 394 | } | |
| 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 | |
| 389 | 396 | o.p.Security = p.Security | |
| 390 | 397 | } | |
| 391 | 398 | // 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 drawn | 17 | + | // 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 requests | 18 | + | // 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 has | 19 | + | // 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 the | 21 | // 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 frame | 22 | // 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.Renderer | 75 | + | r ports.Renderer |
| 70 | - | p *Pipeline // leases of r | 76 | + | p *Pipeline // leases of r |
| 71 | - | w, h int | 77 | + | w, h int |
| 72 | - | workspace uint64 | 78 | + | 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]fenceSlot | 80 | slots [maxChildFences]fenceSlot | ||
| 75 | nf int | 81 | 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, 0 | 301 | + | 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 scene | 304 | // 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 | return | 358 | 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 | return | 361 | 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 | return | 373 | 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 | return | 379 | 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 | return | 393 | return | ||
| 387 | } | 394 | } | ||
| 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 |
| 389 | o.p.Security = p.Security | 396 | o.p.Security = p.Security | ||
| 390 | } | 397 | } | ||
| 391 | // Request regions are physical pixels of the output, and the scene does | 398 | // 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) { | |||
| 183 | 183 | require.False(t, p.HiddenReading()) | |
| 184 | 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 | + | } | |
| @@ -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) { | |||
| 192 | 192 | for _, m := range s.toplevelManagers { | |
| 193 | 193 | m.refresh(w) | |
| 194 | 194 | } | |
| 195 | + | for _, l := range s.extToplevelLists { | |
| 196 | + | l.refresh(w) | |
| 197 | + | } | |
| 195 | 198 | } | |
| 196 | 199 | ||
| 197 | 200 | // toplevelClosed tells every manager that w unmapped. | |
| @@ -199,6 +202,10 @@ func (s *Server) toplevelClosed(id ports.WindowID) { | |||
| 199 | 202 | for _, m := range s.toplevelManagers { | |
| 200 | 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 | 211 | // refreshToplevels reconciles inventory after output changes or unlock: | |
| @@ -208,13 +215,18 @@ func (s *Server) refreshToplevels() { | |||
| 208 | 215 | if s.protected() { | |
| 209 | 216 | return | |
| 210 | 217 | } | |
| 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]) | |
| 215 | 228 | } | |
| 216 | 229 | } | |
| 217 | - | slices.Sort(ids) | |
| 218 | 230 | for _, m := range s.toplevelManagers { | |
| 219 | 231 | for id, h := range m.handles { | |
| 220 | 232 | 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 | return | 216 | 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 { | |||
| 25 | 25 | func() error { return registerOutputManagement(d, s) }, | |
| 26 | 26 | func() error { return registerWorkspaces(d, s) }, | |
| 27 | 27 | func() error { return registerForeignToplevel(d, s) }, | |
| 28 | + | func() error { return registerExtForeignToplevel(d, s) }, | |
| 28 | 29 | func() error { return registerDecoration(d, s) }, | |
| 29 | 30 | func() error { return registerScale(d, s) }, | |
| 30 | 31 | 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 ( | |||
| 6 | 6 | "math" | |
| 7 | 7 | "time" | |
| 8 | 8 | ||
| 9 | + | "github.com/bnema/go-wayland-bindings/server/extforeigntoplevellist" | |
| 9 | 10 | source "github.com/bnema/go-wayland-bindings/server/extimagecapturesource" | |
| 10 | 11 | ext "github.com/bnema/go-wayland-bindings/server/extimagecopycapture" | |
| 11 | 12 | extworkspace "github.com/bnema/go-wayland-bindings/server/extworkspace" | |
| @@ -28,6 +29,7 @@ const ( | |||
| 28 | 29 | srcOutput // a whole output | |
| 29 | 30 | srcRegion // a logical rectangle of an output | |
| 30 | 31 | srcWorkspace // a workspace frame | |
| 32 | + | srcWindow // a toplevel, at its own size | |
| 31 | 33 | ) | |
| 32 | 34 | ||
| 33 | 35 | // captureSource is the state behind one ext_image_capture_source_v1. | |
| @@ -36,6 +38,8 @@ type captureSource struct { | |||
| 36 | 38 | output *output | |
| 37 | 39 | region image.Rectangle // output-local logical, srcRegion | |
| 38 | 40 | workspace uint64 | |
| 41 | + | window ports.WindowID // srcWindow | |
| 42 | + | gen uint32 // srcWindow: its mapping; a remap kills the source | |
| 39 | 43 | } | |
| 40 | 44 | ||
| 41 | 45 | func registerImageCopy(d *server.Display, s *Server) error { | |
| @@ -49,6 +53,11 @@ func registerImageCopy(d *server.Display, s *Server) error { | |||
| 49 | 53 | }); err != nil { | |
| 50 | 54 | 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 | 61 | return ext.NewExtImageCopyCaptureManagerV1Global(d, 1, func(c server.Client, v, id uint32) { | |
| 53 | 62 | _, _ = ext.NewExtImageCopyCaptureManagerV1(c, int32(v), id, copyCaptureManager{s}) | |
| 54 | 63 | }) | |
| @@ -79,6 +88,33 @@ func (m outputCaptureSource) CreateSource(r *source.ExtOutputImageCaptureSourceM | |||
| 79 | 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 | 118 | type extraSources struct{ s *Server } | |
| 83 | 119 | ||
| 84 | 120 | func (extraSources) Destroy(*imagecapture.NeferwlImageCaptureSourceManagerV1) {} | |
| @@ -172,6 +208,8 @@ func (m copyCaptureManager) CreateSession(r *ext.ExtImageCopyCaptureManagerV1, i | |||
| 172 | 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 | 209 | case srcWorkspace: | |
| 174 | 210 | open.Workspace = cs.workspace | |
| 211 | + | case srcWindow: | |
| 212 | + | open.Window = cs.window | |
| 175 | 213 | } | |
| 176 | 214 | s.emit(open) | |
| 177 | 215 | state.sendConstraints() | |
| @@ -297,6 +335,31 @@ func (c *captureSession) geometry() (o *output, rect image.Rectangle, hidden, ok | |||
| 297 | 335 | } | |
| 298 | 336 | rect = captureRegion(o, image.Rect(frame.X, frame.Y, frame.X+frame.W, frame.Y+frame.H)) | |
| 299 | 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 | 364 | return nil, image.Rectangle{}, false, false | |
| 302 | 365 | } | |
| @@ -331,7 +394,7 @@ func (c *captureSession) refresh() { | |||
| 331 | 394 | } | |
| 332 | 395 | _, rect, _, ok := c.geometry() | |
| 333 | 396 | switch { | |
| 334 | - | case !ok && c.src.kind != srcWorkspace: | |
| 397 | + | case !ok && c.src.kind != srcWorkspace && c.src.kind != srcWindow: | |
| 335 | 398 | c.s.log.Info().Uint64("session", c.id).Msg("capture session end: target empty") | |
| 336 | 399 | c.end(true, true) | |
| 337 | 400 | case ok && image.Pt(rect.Dx(), rect.Dy()) != c.sent: | |
| @@ -395,8 +458,11 @@ func (s *Server) captureState(st ports.CaptureSessionState) { | |||
| 395 | 458 | // HUD in it (ok false). | |
| 396 | 459 | func (c *captureSession) tag(hidden bool) (t captureTag, ok bool) { | |
| 397 | 460 | t = captureTag{session: c.id} | |
| 398 | - | if c.src.kind == srcWorkspace { | |
| 461 | + | switch c.src.kind { | |
| 462 | + | case srcWorkspace: | |
| 399 | 463 | t.workspace, t.offscreen = c.src.workspace, hidden | |
| 464 | + | case srcWindow: | |
| 465 | + | t.window, t.offscreen = c.src.window, true | |
| 400 | 466 | } | |
| 401 | 467 | switch c.src.kind { | |
| 402 | 468 | case srcOutput: | |
| @@ -406,6 +472,8 @@ func (c *captureSession) tag(hidden bool) (t captureTag, ok bool) { | |||
| 406 | 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 | 473 | case srcWorkspace: | |
| 408 | 474 | t.taken = ports.CaptureFrameTaken{Workspace: c.src.workspace} | |
| 475 | + | case srcWindow: | |
| 476 | + | t.taken = ports.CaptureFrameTaken{Window: c.src.window} | |
| 409 | 477 | } | |
| 410 | 478 | if c.excl != nil && !c.exclSeen { | |
| 411 | 479 | 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 output | 29 | srcOutput // a whole output | ||
| 29 | srcRegion // a logical rectangle of an output | 30 | srcRegion // a logical rectangle of an output | ||
| 30 | srcWorkspace // a workspace frame | 31 | 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 *output | 38 | output *output | ||
| 37 | region image.Rectangle // output-local logical, srcRegion | 39 | region image.Rectangle // output-local logical, srcRegion | ||
| 38 | workspace uint64 | 40 | 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 err | 54 | 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.workspace | 210 | 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, false | 364 | 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, hidden | 463 | 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, false | 479 | return t, false | ||
M internal/adapters/wayland/screencopy.go +2 -1
| @@ -168,6 +168,7 @@ type captureTag struct { | |||
| 168 | 168 | exclude bool | |
| 169 | 169 | revision uint64 | |
| 170 | 170 | workspace uint64 | |
| 171 | + | window ports.WindowID | |
| 171 | 172 | offscreen bool | |
| 172 | 173 | taken ports.CaptureFrameTaken | |
| 173 | 174 | } | |
| @@ -219,7 +220,7 @@ func (s *Server) requestCapture(o *output, rect image.Rectangle, cursor bool, b | |||
| 219 | 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 | 224 | select { | |
| 224 | 225 | case s.channels.Captures <- req: | |
| 225 | 226 | 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 bool | 168 | exclude bool | ||
| 169 | revision uint64 | 169 | revision uint64 | ||
| 170 | workspace uint64 | 170 | workspace uint64 | ||
| 171 | + | window ports.WindowID | |||
| 171 | offscreen bool | 172 | offscreen bool | ||
| 172 | taken ports.CaptureFrameTaken | 173 | 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 { | |||
| 232 | 232 | outputManagers []*outputManager | |
| 233 | 233 | workspaceManagers []*workspaceManager | |
| 234 | 234 | toplevelManagers []*toplevelManager | |
| 235 | + | extToplevelLists []*extToplevelList | |
| 235 | 236 | workspaceSnapshot ports.Workspaces | |
| 236 | 237 | workspaceFrames []*workspaceFrame | |
| 237 | 238 | workspaceIDs *workspaceid.IDs | |
| @@ -232,6 +232,7 @@ type Server struct { | |||||
| 232 | outputManagers []*outputManager | 232 | outputManagers []*outputManager | ||
| 233 | workspaceManagers []*workspaceManager | 233 | workspaceManagers []*workspaceManager | ||
| 234 | toplevelManagers []*toplevelManager | 234 | toplevelManagers []*toplevelManager | ||
| 235 | + | extToplevelLists []*extToplevelList | |||
| 235 | workspaceSnapshot ports.Workspaces | 236 | workspaceSnapshot ports.Workspaces | ||
| 236 | workspaceFrames []*workspaceFrame | 237 | workspaceFrames []*workspaceFrame | ||
| 237 | workspaceIDs *workspaceid.IDs | 238 | workspaceIDs *workspaceid.IDs | ||
M internal/adapters/wayland/wire_client_test.go +4 -0
| @@ -17,6 +17,7 @@ import ( | |||
| 17 | 17 | wire_cursorshape "github.com/bnema/go-wayland-bindings/server/cursorshape" | |
| 18 | 18 | wire_drmlease "github.com/bnema/go-wayland-bindings/server/drmlease" | |
| 19 | 19 | wire_extdatacontrol "github.com/bnema/go-wayland-bindings/server/extdatacontrol" | |
| 20 | + | wire_extforeigntoplevellist "github.com/bnema/go-wayland-bindings/server/extforeigntoplevellist" | |
| 20 | 21 | wire_extidlenotify "github.com/bnema/go-wayland-bindings/server/extidlenotify" | |
| 21 | 22 | wire_extimagecapturesource "github.com/bnema/go-wayland-bindings/server/extimagecapturesource" | |
| 22 | 23 | wire_extimagecopycapture "github.com/bnema/go-wayland-bindings/server/extimagecopycapture" | |
| @@ -62,6 +63,7 @@ import ( | |||
| 62 | 63 | client_datacontrol "github.com/bnema/wlturbo/protocol/datacontrol" | |
| 63 | 64 | client_drmlease "github.com/bnema/wlturbo/protocol/drmlease" | |
| 64 | 65 | client_drmsyncobj "github.com/bnema/wlturbo/protocol/drmsyncobj" | |
| 66 | + | client_extforeigntoplevel "github.com/bnema/wlturbo/protocol/extforeigntoplevel" | |
| 65 | 67 | client_extsessionlock "github.com/bnema/wlturbo/protocol/extsessionlock" | |
| 66 | 68 | client_fifo "github.com/bnema/wlturbo/protocol/fifo" | |
| 67 | 69 | client_foreigntoplevel "github.com/bnema/wlturbo/protocol/foreigntoplevel" | |
| @@ -106,6 +108,8 @@ var wireSchemas = map[string]wireSchema{ | |||
| 106 | 108 | "ext_data_control_manager_v1": {wire_extdatacontrol.ExtDataControlManagerV1Interface, &client_datacontrol.ExtDataControlManager{}, map[uint16]bool{2: true}, nil}, | |
| 107 | 109 | "ext_data_control_offer_v1": {wire_extdatacontrol.ExtDataControlOfferV1Interface, &client_datacontrol.ExtDataControlOffer{}, map[uint16]bool{1: true}, nil}, | |
| 108 | 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 | 113 | "ext_foreign_toplevel_image_capture_source_manager_v1": {wire_extimagecapturesource.ExtForeignToplevelImageCaptureSourceManagerV1Interface, &client_imagecapturesource.ExtForeignToplevelImageCaptureSourceManager{}, map[uint16]bool{1: true}, nil}, | |
| 110 | 114 | "ext_idle_notification_v1": {wire_extidlenotify.ExtIdleNotificationV1Interface, &client_idlenotify.ExtIdleNotification{}, map[uint16]bool{0: true}, nil}, | |
| 111 | 115 | "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 { | |||
| 134 | 134 | xdg *xdgSurface | |
| 135 | 135 | appID, title string | |
| 136 | 136 | 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 | 140 | // parent is set_parent (dialogs); min and max are the size hints. | |
| 138 | 141 | parent *window | |
| 139 | 142 | minW, minH int32 | |
| @@ -232,6 +235,7 @@ func (x *xdgSurface) GetToplevel(r *xdgshell.Surface, id uint32) { | |||
| 232 | 235 | x.serials = append(x.serials, x.server.serial) | |
| 233 | 236 | } else if buffer && !w.mapped && x.acked { | |
| 234 | 237 | w.mapped = true | |
| 238 | + | w.gen++ | |
| 235 | 239 | slot := x.server.slotToken(r.Client()) | |
| 236 | 240 | // Its size comes with the commit, in WindowResized (afterCommit). | |
| 237 | 241 | w.floating = w.floats() | |
| @@ -134,6 +134,9 @@ type window struct { | |||||
| 134 | xdg *xdgSurface | 134 | xdg *xdgSurface | ||
| 135 | appID, title string | 135 | appID, title string | ||
| 136 | mapped bool | 136 | 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 *window | 141 | parent *window | ||
| 139 | minW, minH int32 | 142 | 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 = true | 237 | 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 { | |||
| 46 | 46 | if s := c.capt.session(v.Session); v.Session != 0 && s != nil && !s.recording { | |
| 47 | 47 | 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 | 50 | if _, reason := c.capResolve(t); reason != ports.CaptureReasonNone { | |
| 51 | 51 | // Nothing of the target is left on screen to mark. | |
| 52 | 52 | return changed | |
| @@ -185,14 +185,23 @@ func (c *Core) captureIndicators(sc *screen) []ports.CaptureIndicator { | |||
| 185 | 185 | return | |
| 186 | 186 | } | |
| 187 | 187 | 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 | + | } | |
| 193 | 192 | } | |
| 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)}) | |
| 196 | 205 | } | |
| 197 | 206 | } | |
| 198 | 207 | 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, true | 47 | 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 changed | 52 | return changed | ||
| @@ -185,14 +185,23 @@ func (c *Core) captureIndicators(sc *screen) []ports.CaptureIndicator { | |||||
| 185 | return | 185 | return | ||
| 186 | } | 186 | } | ||
| 187 | o := sc.mon.Output() | 187 | o := sc.mon.Output() | ||
| 188 | - | var m ports.CaptureIndicator | 188 | + | 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 { | |||
| 57 | 57 | type capTarget struct { | |
| 58 | 58 | output string | |
| 59 | 59 | workspace uint64 | |
| 60 | + | window WindowID | |
| 60 | 61 | 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 | 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 | 77 | // capExclusion is the one live exclusion. | |
| @@ -108,21 +109,44 @@ type capView struct { | |||
| 108 | 109 | hidden *Workspace // captured off screen, nil when none | |
| 109 | 110 | hiddenScr *screen | |
| 110 | 111 | 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 | 117 | excluded []WindowID | |
| 112 | 118 | 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 | 124 | // capResolved is a target located on the current screens. | |
| 116 | 125 | type capResolved struct { | |
| 117 | 126 | sc *screen | |
| 118 | 127 | ws *Workspace | |
| 119 | 128 | rect Rect // clipped, output-local logical | |
| 120 | 129 | 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 | 136 | // capResolve locates a target. A zero reason is success. | |
| 124 | 137 | func (c *Core) capResolve(t capTarget) (capResolved, ports.CaptureReason) { | |
| 125 | 138 | 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 | 150 | if t.workspace != 0 { | |
| 127 | 151 | sc, w := c.workspaceByID(t.workspace) | |
| 128 | 152 | if w == nil || sc.name() == "" { | |
| @@ -335,12 +359,21 @@ func (c *Core) captureEvaluate(ctx context.Context) (*capView, error) { | |||
| 335 | 359 | for i, s := range c.capt.sessions { | |
| 336 | 360 | st := ports.CaptureSessionState{ID: s.open.ID} | |
| 337 | 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 | 372 | 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 | 374 | 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 | 377 | st.Active = !r.hidden || v.hiddenID == s.open.Workspace | |
| 345 | 378 | } | |
| 346 | 379 | st.Reason = reason | |
| @@ -443,10 +476,13 @@ func (c *Core) captureSceneFor(sc *screen, v *capView) *ports.SceneCapture { | |||
| 443 | 476 | if v.hiddenScr == sc { | |
| 444 | 477 | want.Workspace = v.hiddenID | |
| 445 | 478 | } | |
| 479 | + | if v.windowScr == sc { | |
| 480 | + | want.Window = v.window | |
| 481 | + | } | |
| 446 | 482 | // The scene is read by the output owner and never changed: the last one | |
| 447 | 483 | // is shared while it says the same. | |
| 448 | 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 | 486 | return p | |
| 451 | 487 | } | |
| 452 | 488 | shared := want | |
| @@ -57,6 +57,7 @@ func (s *captureState) idle() bool { | |||||
| 57 | type capTarget struct { | 57 | type capTarget struct { | ||
| 58 | output string | 58 | output string | ||
| 59 | workspace uint64 | 59 | workspace uint64 | ||
| 60 | + | window WindowID | |||
| 60 | rect Rect // output-local logical; zero is the whole output or frame | 61 | 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 none | 109 | hidden *Workspace // captured off screen, nil when none | ||
| 109 | hiddenScr *screen | 110 | hiddenScr *screen | ||
| 110 | hiddenID uint64 | 111 | 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 []WindowID | 117 | excluded []WindowID | ||
| 112 | exclusion *capExclusion | 118 | 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 *screen | 126 | sc *screen | ||
| 118 | ws *Workspace | 127 | ws *Workspace | ||
| 119 | rect Rect // clipped, output-local logical | 128 | rect Rect // clipped, output-local logical | ||
| 120 | hidden bool | 129 | 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 capResolved | 138 | 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.hidden | 372 | 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.Workspace | 374 | 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.Workspace | 377 | st.Active = !r.hidden || v.hiddenID == s.open.Workspace | ||
| 345 | } | 378 | } | ||
| 346 | st.Reason = reason | 379 | 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.hiddenID | 477 | 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 one | 482 | // 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 p | 486 | return p | ||
| 451 | } | 487 | } | ||
| 452 | shared := want | 488 | 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 { | |||
| 16 | 16 | captureArea Rect | |
| 17 | 17 | // realTiled sizes a visible tiled preview from its real client rect. | |
| 18 | 18 | 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 | 24 | // 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 | 43 | s.seen[p.ID] = true | |
| 41 | 44 | old, ok := s.sent[p.ID] | |
| 42 | 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 | 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 | 77 | } else { | |
| 69 | 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 | 81 | return v, !ok || v != old || s.answer[p.ID] | |
| 73 | 82 | } | |
| 74 | 83 | ||
| @@ -16,6 +16,9 @@ type configureTarget struct { | |||||
| 16 | captureArea Rect | 16 | 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 bool | 18 | 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 the | 24 | // 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] = true | 43 | 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 { | |||
| 582 | 582 | } | |
| 583 | 583 | } | |
| 584 | 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 | 586 | if !p.Hidden && p.Preview == 0 { | |
| 587 | 587 | // Only a sized configure needs the client size. | |
| 588 | 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 | 592 | } else if p.Preview > 0 && !p.Hidden { | |
| 590 | 593 | if rp, ok := real[p.ID]; ok && !rp.Hidden { | |
| 591 | 594 | t.realTiled = !rp.Floating | |
| @@ -602,8 +605,13 @@ func (c *Core) publish(ctx context.Context) error { | |||
| 602 | 605 | } | |
| 603 | 606 | scene.Windows = append(scene.Windows, c.scenePopups(sc)...) | |
| 604 | 607 | 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 | + | } | |
| 607 | 615 | } | |
| 608 | 616 | scenes = append(scenes, scene) | |
| 609 | 617 | } | |
| @@ -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.Floating | 594 | 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 ( | |||
| 45 | 45 | CaptureReasonOutputGone CaptureReason = "output-gone" | |
| 46 | 46 | CaptureReasonWorkspaceGone CaptureReason = "workspace-gone" | |
| 47 | 47 | CaptureReasonInvalidRegion CaptureReason = "invalid-region" // nothing of the region is left on the output | |
| 48 | + | CaptureReasonWindowGone CaptureReason = "window-gone" | |
| 48 | 49 | // TooManyExcluded: the attached layers and their popups exceed | |
| 49 | 50 | // MaxCaptureExcluded; a capture could not leave them all out (fail closed). | |
| 50 | 51 | CaptureReasonTooManyExcluded CaptureReason = "too-many-excluded" | |
| @@ -57,11 +58,14 @@ func (r CaptureReason) Terminal() bool { return r != CaptureReasonNone } | |||
| 57 | 58 | // session. Its target is an output (Workspace 0), whole when Region is zero, | |
| 58 | 59 | // else the part of it in Region (output-local logical pixels, clipped), or a | |
| 59 | 60 | // 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 | 64 | type CaptureSessionOpen struct { | |
| 62 | 65 | ID uint64 | |
| 63 | 66 | Output string | |
| 64 | 67 | Workspace uint64 | |
| 68 | + | Window WindowID | |
| 65 | 69 | Region Rect | |
| 66 | 70 | } | |
| 67 | 71 | ||
| @@ -85,6 +89,7 @@ type CaptureFrameTaken struct { | |||
| 85 | 89 | Session uint64 | |
| 86 | 90 | Output string | |
| 87 | 91 | Workspace uint64 | |
| 92 | + | Window WindowID | |
| 88 | 93 | Region Rect | |
| 89 | 94 | } | |
| 90 | 95 | ||
| @@ -167,13 +172,16 @@ func (CaptureSessionState) clientCommand() {} | |||
| 167 | 172 | // workspace CaptureScene draws, 0 when there is none on this output. Shown is | |
| 168 | 173 | // the ID of the workspace this scene draws as itself (0 in the overview): a | |
| 169 | 174 | // 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 | 178 | type SceneCapture struct { | |
| 172 | 179 | Shown uint64 | |
| 173 | 180 | Session uint64 | |
| 174 | 181 | Excluded []WindowID | |
| 175 | 182 | Revision uint64 | |
| 176 | 183 | Workspace uint64 | |
| 184 | + | Window WindowID | |
| 177 | 185 | } | |
| 178 | 186 | ||
| 179 | 187 | // 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 output | 47 | 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 exceed | 49 | // 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 a | 59 | // 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 it | 60 | // 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 uint64 | 65 | ID uint64 | ||
| 63 | Output string | 66 | Output string | ||
| 64 | Workspace uint64 | 67 | Workspace uint64 | ||
| 68 | + | Window WindowID | |||
| 65 | Region Rect | 69 | Region Rect | ||
| 66 | } | 70 | } | ||
| 67 | 71 | ||||
| @@ -85,6 +89,7 @@ type CaptureFrameTaken struct { | |||||
| 85 | Session uint64 | 89 | Session uint64 | ||
| 86 | Output string | 90 | Output string | ||
| 87 | Workspace uint64 | 91 | Workspace uint64 | ||
| 92 | + | Window WindowID | |||
| 88 | Region Rect | 93 | 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 is | 172 | // 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): a | 173 | // 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 the | 174 | // 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 uint64 | 179 | Shown uint64 | ||
| 173 | Session uint64 | 180 | Session uint64 | ||
| 174 | Excluded []WindowID | 181 | Excluded []WindowID | ||
| 175 | Revision uint64 | 182 | Revision uint64 | ||
| 176 | Workspace uint64 | 183 | Workspace uint64 | ||
| 184 | + | Window WindowID | |||
| 177 | } | 185 | } | ||
| 178 | 186 | ||||
| 179 | // CaptureIndicator carries core → renderer one mark of a capture on an | 187 | // 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 { | |||
| 36 | 36 | // another crop. | |
| 37 | 37 | Workspace uint64 | |
| 38 | 38 | 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 | 43 | // Indicate is set by wayland on every capture it requests: core shows the | |
| 40 | 44 | // capture indicator for it (CaptureFrameTaken), and the output owner | |
| 41 | 45 | // 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 uint64 | 37 | Workspace uint64 | ||
| 38 | OffScreen bool | 38 | 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 the | 43 | // Indicate is set by wayland on every capture it requests: core shows the | ||
| 40 | // capture indicator for it (CaptureFrameTaken), and the output owner | 44 | // capture indicator for it (CaptureFrameTaken), and the output owner | ||
| 41 | // serves the request only from a scene that shows the mark it needs (a | 45 | // serves the request only from a scene that shows the mark it needs (a | ||