diff --git a/docs/user/actions.md b/docs/user/actions.md index 3d4313bf..e608c425 100644 --- a/docs/user/actions.md +++ b/docs/user/actions.md @@ -337,22 +337,26 @@ layout = "us,de" toggle such as `options = "grp:alt_shift_toggle"` is an alternative that lives in the keymap itself; the two can coexist. -## Scratchpad actions +## Scratchpad and drawer actions -Each output has a holding area for windows that should stay nearby without -remaining on a workspace. +Each output supports multiple dedicated named scratchpad slots (or a default slot). -| Action | What it does | -|--------|--------------| -| `window-move-to-scratchpad` | Move the focused window from its workspace into the scratchpad. | -| `scratchpad-toggle` | Show or hide the output's scratchpad windows. | -| `window-restore-from-scratchpad` | Return the focused scratchpad window to its saved workspace. | -| `window-toggle-scratchpad` | Move the focused window into the scratchpad, or restore it if it's already the scratchpad's focused window. | -| `scratchpad-focus-next` | Focus the next visible scratchpad window. | - -Add `:` to any action to target a specific output, for example -`scratchpad-toggle:DP-1`. Without a suffix, the action targets the output under -the pointer. - -See [Scratchpads](scratchpad.md) for setup examples, the full workflow, -multi-output behavior, restoration rules, and troubleshooting. +| Action | Parameter | What it does | +|--------|-----------|--------------| +| `scratchpad-toggle[:[/]]` | Optional slot name and output | Show or hide the named scratchpad slot. Toggling a new slot auto-closes other active slots on that output. | +| `window-move-to-scratchpad[:[/]]` | Optional slot name and output | Move the focused window from its workspace into the target scratchpad slot. | +| `window-move-to-scratchpad-silent[:[/]]` | Optional slot name and output | Move the focused window into the scratchpad slot without switching focus (silent move). | +| `window-restore-from-scratchpad[:[/]]` | Optional slot name and output | Return the focused scratchpad window to its saved workspace, preserving layout mode. | +| `window-toggle-scratchpad[:[/]]` | Optional slot name and output | Move the focused window into the scratchpad, or restore it if it's already the scratchpad's focused window. | +| `scratchpad-focus-next[:[/]]` | Optional slot name and output | Focus the next visible window in the active scratchpad slot (cycling split-tiled columns). | + +Parameters support ``, ``, or `/`: +- `"scratchpad-toggle:music"` targets the `music` drawer on the output under the pointer. +- `"scratchpad-toggle:DP-1"` targets the default drawer on connector `DP-1`. +- `"scratchpad-toggle:terminal/DP-1"` targets the `terminal` drawer on connector `DP-1`. + +A bare argument that matches a connected output name is read as an output, so +older output-only bindings keep working. + +See [Scratchpads](scratchpad.md) for setup examples, per-slot rules, restoration +rules, multi-output behavior, and troubleshooting. diff --git a/docs/user/animation.md b/docs/user/animation.md index 663e0998..c05f7840 100644 --- a/docs/user/animation.md +++ b/docs/user/animation.md @@ -43,11 +43,15 @@ curve = "easeout" enabled = false duration_ms = 250 curve = "easeout" -dim = 0.5 # 0.0-1.0 -blur = false # requires appearance.blur.enabled -scale = 0.0 # 0 preserves geometry; 0.1-1.0 sizes and centers on entry -maximize = false # maximize to edges on entry -fullscreen = false # fullscreen on entry +direction = "top" # "top", "bottom", "left", "right" +style = "slide" # "slide", "slidefade", "popin", "fade" +dim = 0.5 # 0.0-1.0 +blur = false # requires appearance.blur.enabled +scale = 0.0 # 0 preserves geometry; 0.1-1.0 sizes and centers on entry +maximize = false # maximize to edges on entry +maximize_to_edges = false # fill usable area edge-to-edge +fullscreen = false # fullscreen on entry +suspend_hidden = true # suspend client frame scheduling while closed [animation.border] enabled = false @@ -86,14 +90,14 @@ fields are specific to individual event tables: | `[animation.windows_move]` | None | Window move and resize. | | `[animation.workspaces]` | None | Workspace switch. | | `[animation.overview]` | None | Overview open, close, and row settling. | -| `[animation.scratchpad]` | `dim` (0.0-1.0); `blur`; `scale` (0.0-1.0); `maximize`; `fullscreen` | Scratchpad show, hide, and backdrop. | +| `[animation.scratchpad]` | `direction` (`top`, `bottom`, `left`, `right`); `style` (`slide`, `slidefade`, `popin`, `fade`); `dim` (0.0-1.0); `blur`; `scale` (0.0-1.0); `maximize`; `maximize_to_edges`; `fullscreen`; `suspend_hidden` | Scratchpad show, hide, and backdrop. | | `[animation.border]` | None | Focus-ring color transition in OkLab color space. | | `[animation.dim_unfocused]` | `dim` (0.0-1.0) | Unfocused-window opacity. `dim = 0` disables it. | | `[animation.layers]` | None | Layer-shell surface map and unmap fades. | An event's `enabled = false` makes only that transition instant. Scratchpad `dim` and `blur` remain active, without a fade, when animation is disabled. -Scratchpad `scale`, `maximize`, and `fullscreen` apply when a window enters the +Scratchpad `scale`, `maximize`, `maximize_to_edges`, and `fullscreen` apply when a window enters the scratchpad. ## Curves diff --git a/docs/user/scratchpad.md b/docs/user/scratchpad.md index f6ccb8a6..237956bb 100644 --- a/docs/user/scratchpad.md +++ b/docs/user/scratchpad.md @@ -1,14 +1,15 @@ # Scratchpads A scratchpad is a holding area for windows you want nearby without keeping them -on a workspace. Each output has its own scratchpad, and every window assigned to -that output is shown or hidden together. +on a workspace. Each output supports multiple dedicated named scratchpad slots (or +a default slot), and windows stored in a slot can be shown, hidden, or restored +together. Scratchpad membership and visibility are separate: -- Moving a window to a scratchpad stores it there. -- Toggling a scratchpad shows or hides its stored windows. -- Restoring a window removes it from the scratchpad and returns it to a +- Moving a window to a scratchpad slot stores it there. +- Toggling a scratchpad slot shows or hides its stored windows. +- Restoring a window removes it from the scratchpad slot and returns it to a workspace. ## Basic setup @@ -21,13 +22,18 @@ The packaged config uses these bindings: "Mod+Space" = "scratchpad-toggle" "Mod+Ctrl+Space" = "window-restore-from-scratchpad" "Mod+Tab" = "scratchpad-focus-next" + +# Named drawers +"Mod+M" = "scratchpad-toggle:music" +"Mod+Shift+M" = "window-move-to-scratchpad:music" +"Mod+Ctrl+Shift+M" = "window-move-to-scratchpad-silent:music" ``` A typical workflow is: 1. Focus a workspace window and press `Mod+Shift+Space` to store it. 2. Press `Mod+Space` to show the stored windows. -3. Press `Mod+Tab` to cycle focus when several windows are visible. +3. Press `Mod+Tab` to cycle focus when several windows are visible in the slot. 4. Press `Mod+Ctrl+Space` to return the focused window to its workspace. Press `Mod+Space` instead of restoring when you only want to hide the @@ -37,45 +43,43 @@ scratchpad again. | Action | What it does | |--------|--------------| -| `window-move-to-scratchpad` | Move the focused workspace window into the target scratchpad. | -| `scratchpad-toggle` | Show or hide all scratchpad windows on the target output. | -| `window-restore-from-scratchpad` | Restore the focused scratchpad window to a workspace. | -| `scratchpad-focus-next` | Focus the next visible scratchpad window, wrapping at the end. | +| `window-move-to-scratchpad` | Move the focused workspace window into the target scratchpad slot. | +| `window-move-to-scratchpad-silent` | Move the focused window into the target slot without transferring focus away from the active workspace. | +| `scratchpad-toggle` | Show or hide windows in the target scratchpad slot. | +| `window-restore-from-scratchpad` | Restore the focused scratchpad window to its original workspace. | +| `window-toggle-scratchpad` | Move the focused window into the scratchpad, or restore it if already focused in a slot. | +| `scratchpad-focus-next` | Focus the next visible window in the active scratchpad slot. | The toggle, restore, and focus actions do nothing when their required window is -not available. In particular, restore and focus-next require the scratchpad to -be visible. +not available. In particular, restore and focus-next require the slot to be +visible. Scratchpad visibility and cycling actions never repeat while their key is held, even if the binding does not set `repeat = false`. -## Choosing an output - -Without an output suffix, an action targets the output under the pointer. If the -pointer is outside every output, Umbriel uses the first enabled output. +For compatibility with special workspace workflows, `workspace` and +`window-move-to-workspace` also accept a `special:` selector, which is +equivalent to `scratchpad-toggle:` and `window-move-to-scratchpad:`. -Add `:` to target a specific output from anywhere: +## Choosing a slot and an output -```toml -[keybinds] -"Mod+0" = "scratchpad-toggle:DP-1" -"Mod+Shift+0" = "window-move-to-scratchpad:DP-1" -"Mod+Ctrl+0" = "window-restore-from-scratchpad:DP-1" -"Mod+Alt+0" = "scratchpad-focus-next:DP-1" -``` +The argument is ``, `/`, or just ``: -For `window-move-to-scratchpad`, the suffix selects the destination -scratchpad. The source is still the focused window on the active workspace -under the pointer. +- `scratchpad-toggle` — default slot, output under the pointer. +- `scratchpad-toggle:music` — the `music` slot, output under the pointer. +- `scratchpad-toggle:music/DP-1` — the `music` slot on `DP-1`. +- `scratchpad-toggle:DP-1` — a bare argument that matches a connected output + name is read as an output, so older output-only bindings keep working. A slot + cannot share a name with an output. +If the pointer is outside every output, Umbriel uses the first enabled output. Use `umbriel outputs` inside a session to find output names such as `DP-1` or `HDMI-A-1`. -## Visibility and focus +If a slot is visible on one output and you toggle it on another, Umbriel hides +it on the first output, moves its windows, and shows it on the second. -Moving a window preserves the target scratchpad's current visibility. By -default, a window moved into a hidden scratchpad fades out and is then removed -from the scene. A window moved into a visible scratchpad remains visible. +## Visibility and focus Showing a scratchpad focuses the window that was most recently focused there. If no window has been focused yet, Umbriel focuses the first stored window. @@ -83,8 +87,128 @@ Hiding it returns focus to a regular workspace window. Opening the workspaces overview immediately hides every visible scratchpad while keeping its windows stored. -All windows assigned to one output share its visibility state. There are no -separate named scratchpads within an output. +## Routing windows with window rules + +`default_scratchpad` (or `scratchpad`) stores a window in a slot as it opens, +without showing the slot: + +```toml +[[window_rule]] +match.app_id = "^Spotify$" +default_scratchpad = "music" + +[[window_rule]] +match.title = ".*btop.*" +default_scratchpad = "monitor" +``` + +## Touchpad gestures + +A four-finger vertical swipe toggles at the end of the gesture, based on how far +and how fast you swiped. It does not track the drawer to your finger. + +- **Swipe down**: shows the default scratchpad slot. +- **Swipe up**: opens the workspace overview, or closes the visible scratchpad + slot if one is open. + +## Several windows in one slot + +A slot tiles its windows with the same layout engine a workspace uses, inside +the slot's box. By default that is `layout.mode`; `layout` in a `[[scratchpad]]` +rule overrides it per slot, and `gap` overrides `layout.gap`. Closing or +restoring a window re-tiles the rest. Use `scratchpad-focus-next` to cycle +across them. + +## Empty slots + +Toggling a slot that holds no windows opens it anyway: the backdrop appears and +the slot stays addressable, so the same binding closes it again. This is when an +`on_empty` command runs. A slot is discarded once it is both hidden and empty. + +## Configuration + +Defaults for every slot live under `[animation.scratchpad]`: + +```toml +[animation.scratchpad] +enabled = true +duration_ms = 250 +curve = "easeout" # "easeout", "snappy", cubic-bezier, or spring +dim = 0.5 # backdrop dim over the workspace, 0.0-1.0 +blur = false # backdrop blur (needs appearance.blur.enabled) +scale = 0.8 # fraction of the usable area, 0.1-1.0 +direction = "top" # edge the slot slides in from ("top", "bottom", "left", "right") +style = "slide" # "slide", "fade", "popin", or "slidefade" +suspend_hidden = true # suspend clients while the slot is hidden +maximize = false # fill the usable area instead of scaling +maximize_to_edges = false # fill screen edge-to-edge without gaps or borders +fullscreen = false # fill the whole output instead of scaling +``` + +`scale` sets both the width and height fraction of the output's usable area, +centred. `maximize` fills the usable area; `maximize_to_edges` fills the entire +usable area to the monitor edges; `fullscreen` fills the whole output, +ignoring layer-shell reservations. Neither changes the window's xdg-shell +maximized or fullscreen state — they only size the slot. + +`style` picks the show/hide transition: `"slide"` moves the slot in from +`direction` at full opacity (the default); `"slidefade"` does the same slide +while also fading opacity in/out; `"popin"` zooms the slot in/out from a +centered, scaled-down box instead of sliding, with a fade. `"fade"` and +`style` are ignored when `scale` is `0` — an unscaled slot always fades in +place at its existing geometry. + +### Per-slot rules + +`[[scratchpad]]` overrides the defaults for one slot: + +```toml +[[scratchpad]] +name = "music" +scale = 0.65 +direction = "right" +duration_ms = 350 +curve = "easeout" +dim = 0.50 +blur = true +on_empty = "spotify" + +[[scratchpad]] +name = "terminal" +scale = 0.85 +direction = "top" +duration_ms = 220 +curve = "snappy" +on_empty = "kitty" +layout = "master" +gap = 12 +``` + +- **`name`** (required string): slot identifier. `special:` is stripped, so + `"special:music"` and `"music"` are the same slot. `""` is the default slot. +- **`scale`** (`0.1`–`1.0`), **`direction`** (`"top"`, `"bottom"`, `"left"`, + `"right"`), **`duration_ms`** (`1`–`10000`), **`curve`**, **`dim`** + (`0.0`–`1.0`), **`blur`**, **`maximize`**, **`maximize_to_edges`**, + **`fullscreen`**, **`suspend_hidden`**: as above, for this slot only. +- **`layout`** (string): `"scrolling"`, `"dwindle"`, or `"master"` for the + windows inside this slot. Defaults to `layout.mode`. +- **`gap`** (integer `0`–`200`): gap between windows in this slot. Defaults to + `layout.gap`. +- **`on_empty`** (string, also accepted as `on_created_empty`): command to run + when the slot is toggled while empty. Only a window whose process descends + from that command is placed in the slot, so launching something else + meanwhile is safe. The claim expires after 30 seconds. + +When several slots are visible across outputs, the backdrop uses the largest +`dim` of the visible slots on that output, and blurs if any of them asks for it. + +### Suspending hidden clients + +With `suspend_hidden = true`, Umbriel sends `xdg_toplevel.suspended` to clients +in a hidden slot (xdg-shell version 6 and up). Clients that honour it — browsers, +Electron apps, some media players — throttle or stop rendering while hidden. +Clients that ignore it keep running as before. Hidden windows are also removed +from the scene graph, so they cost nothing to composite either way. ## Restoring windows @@ -97,9 +221,7 @@ output targeted by the action. If the original workspace no longer exists, it uses that output's active workspace. Fullscreen, pinned, and maximize-to-edges state are cleared when a window enters -the scratchpad and are not restored automatically. The optional -`animation.scratchpad.fullscreen` or `animation.scratchpad.maximize` setting can -apply a new state on entry. +the scratchpad and are not restored automatically. ## Moving scratchpad windows @@ -107,14 +229,12 @@ Scratchpad windows always float. Dragging one does not restore it or tile it on the workspace beneath it. Dragging a scratchpad window to another output assigns it to that output's -scratchpad and makes the destination scratchpad visible. The window keeps its -size. Umbriel only repositions it when its center would otherwise be outside the -destination output's usable area. +scratchpad and makes the destination slot visible. When an output disconnects or is disabled, its scratchpad windows move to -another enabled output. A visible scratchpad remains visible after that move. -When the original output returns, its scratchpad windows return with their -output-relative positions intact. +another enabled output. A visible slot remains visible after that move. When the +original output returns, its scratchpad windows return with their output-relative +positions intact. ## Appearance and window actions @@ -126,11 +246,8 @@ scratchpad_border_focused = "#E5C07BFF" scratchpad_border_unfocused = "#5C4A2AFF" ``` -See [Appearance](appearance.md) for the complete appearance -reference. - -Scratchpad show and hide transitions, backdrop dimming and blur, and optional -entry sizing are configured under [`animation.scratchpad`](animation.md#animation). +See [Appearance](appearance.md) for the complete appearance reference, and +[`animation.scratchpad`](animation.md#animation) for the transition settings. While a scratchpad window has focus, `window-toggle-floating`, `window-toggle-pinned`, and `window-center` are inactive. Restore the window @@ -138,8 +255,9 @@ before using those actions. ## Troubleshooting -- If toggle does nothing, the target output has no stored scratchpad windows. -- If restore or focus-next does nothing, show the target scratchpad first. +- If a slot opens empty, its `on_empty` command is missing, failed to start, or + the app it launched has not mapped a window yet. +- If restore or focus-next does nothing, show the target slot first. - If an action affects the wrong monitor, move the pointer to the intended - output or add an explicit output suffix. + output or add an explicit `/` suffix. - If a window will not tile, pin, or center, restore it to a workspace first. diff --git a/docs/user/window-rules.md b/docs/user/window-rules.md index 2b759f7e..47acc12e 100644 --- a/docs/user/window-rules.md +++ b/docs/user/window-rules.md @@ -56,7 +56,7 @@ opening settings do not overwrite user changes made in the meantime. | Key | Type | Description | |-----|------|-------------| | `default_output` | string | Open on a specific output (e.g. `"DP-1"`). | -| `default_workspace` | int | Place on workspace N from 1 to 64. On dynamic outputs, values beyond the current count clamp to the last workspace. | +| `default_scratchpad` | string | Route directly into the named scratchpad slot without showing it (`scratchpad` is also accepted). | | `default_fullscreen` | bool | Open fullscreen across the entire output, ignoring layout struts and layer-shell exclusive zones. | | `default_floating` | bool | Force floating (`true`) or force tiling (`false`). | | `default_maximize` | bool | Open maximized. A tiled column still respects layout struts and gaps. Parented transient dialogs keep their natural size. | diff --git a/examples/config.toml b/examples/config.toml index 844f79ca..48f09a44 100644 --- a/examples/config.toml +++ b/examples/config.toml @@ -87,11 +87,15 @@ curve = "easeout" enabled = false duration_ms = 250 curve = "easeout" +direction = "top" # "top", "bottom", "left", "right" +style = "slide" # "slide", "slidefade", "popin", "fade" dim = 0.5 # 0.0-1.0 blur = false scale = 0.0 # 0 preserves the window geometry maximize = false +maximize_to_edges = false # fill screen edge-to-edge without gaps or borders fullscreen = false +suspend_hidden = true # suspend frame scheduling while closed [animation.border] enabled = false @@ -459,6 +463,11 @@ default_position = { x = 20, y = 20, anchor = "bottom_right" } # default_focused = false # default_pinned = true +# Route specific applications directly into scratchpad slots without showing them +# [[window_rule]] +# match.app_id = "spotify" +# scratchpad = "music" + # Layer rules (see docs/user/layer-rules.md) --------------------------- [[layer_rule]] @@ -466,3 +475,27 @@ match.namespace="^noctalia-(bar-[^\"]+|notification|dock|panel|attached-panel|os blur = true blur_ignore_alpha = 0.5 blur_optimized = false + +# Scratchpad slot rules (see docs/user/scratchpad.md) ------------------------ + +# Dedicated music player drawer sliding in from the right with blur +# [[scratchpad]] +# name = "music" +# scale = 0.65 +# direction = "right" +# duration_ms = 350 +# curve = "easeout" +# dim = 0.50 +# blur = true +# on_empty = "spotify" + +# Dropdown terminal sliding in from top with snappy easing +# [[scratchpad]] +# name = "terminal" +# scale = 0.85 +# direction = "top" +# duration_ms = 220 +# curve = "snappy" +# on_empty = "kitty" +# layout = "master" # per-slot layout; defaults to layout.mode +# gap = 12 # per-slot gap; defaults to layout.gap diff --git a/src/config/config.cpp b/src/config/config.cpp index 94e4e363..e59a29f2 100644 --- a/src/config/config.cpp +++ b/src/config/config.cpp @@ -190,14 +190,13 @@ namespace umbriel { return configPathExists(packaged) ? packaged : userPath; } - std::optional readLayoutMode(Section& section, std::string_view context) { - const toml::node* node = section.take("mode"); + std::optional readLayoutModeAt(const toml::node* node, std::string_view context, std::string_view key) { if (node == nullptr) { return std::nullopt; } const auto* value = node->as_string(); if (value == nullptr) { - warnAt(node->source(), R"({}.mode must be a string ("scrolling", "dwindle", or "master"))", context); + warnAt(node->source(), R"({}.{} must be a string ("scrolling", "dwindle", or "master"))", context, key); return std::nullopt; } const std::string_view mode = value->get(); @@ -210,10 +209,16 @@ namespace umbriel { if (mode == "scrolling") { return LayoutMode::Scrolling; } - warnAt(node->source(), R"(unknown {}.mode "{}" (expected "scrolling", "dwindle", or "master"))", context, mode); + warnAt( + node->source(), R"(unknown {}.{} "{}" (expected "scrolling", "dwindle", or "master"))", context, key, mode + ); return std::nullopt; } + std::optional readLayoutMode(Section& section, std::string_view context) { + return readLayoutModeAt(section.take("mode"), context, "mode"); + } + std::optional readScrollingDirection(Section& section, std::string_view context) { const toml::node* node = section.take("direction"); if (node == nullptr) { @@ -778,7 +783,39 @@ namespace umbriel { .boolean("blur", animation.scratchpad.blur) .real("scale", 0.0, 1.0, animation.scratchpad.scale) .boolean("maximize", animation.scratchpad.maximize) - .boolean("fullscreen", animation.scratchpad.fullscreen); + .boolean("maximize_to_edges", animation.scratchpad.maximizeToEdges) + .boolean("fullscreen", animation.scratchpad.fullscreen) + .boolean("suspend_hidden", animation.scratchpad.suspendHidden); + if (const toml::node* node = section.take("direction")) { + if (const auto direction = node->value()) { + if (*direction == "top" + || *direction == "bottom" + || *direction == "left" + || *direction == "right" + || direction->empty()) { + animation.scratchpad.direction = *direction; + } else { + warnAt( + node->source(), + R"(unknown animation.scratchpad.direction "{}" (expected "top", "bottom", "left", or "right"))", + *direction + ); + } + } + } + if (const toml::node* node = section.take("style")) { + if (const auto style = node->value()) { + if (*style == "slide" || *style == "fade" || *style == "popin" || *style == "slidefade" || style->empty()) { + animation.scratchpad.style = *style; + } else { + warnAt( + node->source(), + R"(unknown animation.scratchpad.style "{}" (expected "slide", "fade", "popin", or "slidefade"))", + *style + ); + } + } + } readCurve(section, "animation.scratchpad", animation.scratchpad.curve); }); s.sub("border", [&](Section& section) { @@ -1733,12 +1770,195 @@ namespace umbriel { rule.defaultScrollingColumnOrder ); + if (const toml::node* n = keys.take("default_scratchpad")) { + if (const auto value = n->value()) { + std::string slot = *value; + if (slot.starts_with("special:")) { + slot.erase(0, 8); + } + rule.defaultScratchpad = std::move(slot); + } else { + warnAt(n->source(), "ignoring window_rule.default_scratchpad (expected string)"); + } + } else if (const toml::node* n2 = keys.take("scratchpad")) { + if (const auto value = n2->value()) { + std::string slot = *value; + if (slot.starts_with("special:")) { + slot.erase(0, 8); + } + rule.defaultScratchpad = std::move(slot); + } else { + warnAt(n2->source(), "ignoring window_rule.scratchpad (expected string)"); + } + } + if (valid) { loaded.windowRules.push_back(std::move(rule)); } } } + void readScratchpads(Section& root, Config& loaded) { + const toml::node* node = root.take("scratchpad"); + if (node == nullptr) { + return; + } + const auto* scratchpads = node->as_array(); + if (scratchpads == nullptr) { + warnAt(node->source(), "ignoring scratchpad (expected [[scratchpad]] array of tables)"); + return; + } + + for (const auto& entry : *scratchpads) { + const auto* section = entry.as_table(); + if (section == nullptr) { + warnAt(entry.source(), "ignoring scratchpad entry (expected table)"); + continue; + } + Section keys(*section, "scratchpad", configStore().mutableDiagnostics()); + + ScratchpadSlotConfig slot; + if (const toml::node* nameNode = keys.take("name")) { + if (const auto val = nameNode->value()) { + slot.name = *val; + if (slot.name.starts_with("special:")) { + slot.name.erase(0, 8); + } + } else { + warnAt(nameNode->source(), "ignoring scratchpad entry (expected 'name' string)"); + continue; + } + } else { + warnAt(entry.source(), "ignoring scratchpad entry (missing 'name' string)"); + continue; + } + + const auto readBool = [](Section& keys, std::string_view key, std::optional& out) { + const toml::node* n = keys.take(key); + if (n == nullptr) { + return; + } + if (const auto val = n->value()) { + out = val; + } else { + warnAt(n->source(), "ignoring scratchpad.{} (expected boolean)", key); + } + }; + const auto readString = [](Section& keys, std::string_view key, std::optional& out) { + const toml::node* n = keys.take(key); + if (n == nullptr) { + return false; + } + if (const auto val = n->value()) { + out = val; + } else { + warnAt(n->source(), "ignoring scratchpad.{} (expected string)", key); + } + return true; + }; + + if (const toml::node* n = keys.take("scale")) { + if (const auto val = n->value()) { + if (*val >= 0.0 && *val <= 1.0) { + slot.scale = val; + } else { + warnAt(n->source(), "scratchpad.scale = {} out of range (expected 0.0-1.0)", *val); + } + } else { + warnAt(n->source(), "ignoring scratchpad.scale (expected number)"); + } + } + + if (const toml::node* n = keys.take("direction")) { + if (const auto val = n->value()) { + if (*val == "top" || *val == "bottom" || *val == "left" || *val == "right" || val->empty()) { + slot.direction = val; + } else { + warnAt( + n->source(), R"(unknown scratchpad.direction "{}" (expected "top", "bottom", "left", or "right"))", + *val + ); + } + } else { + warnAt(n->source(), "ignoring scratchpad.direction (expected string)"); + } + } + + if (const toml::node* n = keys.take("style")) { + if (const auto val = n->value()) { + if (*val == "slide" || *val == "fade" || *val == "popin" || *val == "slidefade") { + slot.style = val; + } else { + warnAt( + n->source(), R"(unknown scratchpad.style "{}" (expected "slide", "fade", "popin", or "slidefade"))", + *val + ); + } + } else { + warnAt(n->source(), "ignoring scratchpad.style (expected string)"); + } + } + + if (const toml::node* n = keys.take("duration_ms")) { + if (const auto val = n->value()) { + if (*val >= 1 && *val <= 10000) { + slot.durationMs = static_cast(*val); + } else { + warnAt(n->source(), "scratchpad.duration_ms = {} out of range (expected 1-10000)", *val); + } + } else { + warnAt(n->source(), "ignoring scratchpad.duration_ms (expected integer)"); + } + } + + if (const toml::node* n = keys.take("dim")) { + if (const auto val = n->value()) { + if (*val >= 0.0 && *val <= 1.0) { + slot.dim = val; + } else { + warnAt(n->source(), "scratchpad.dim = {} out of range (expected 0.0-1.0)", *val); + } + } else { + warnAt(n->source(), "ignoring scratchpad.dim (expected number)"); + } + } + + readBool(keys, "blur", slot.blur); + readBool(keys, "maximize", slot.maximize); + readBool(keys, "maximize_to_edges", slot.maximizeToEdges); + readBool(keys, "fullscreen", slot.fullscreen); + readBool(keys, "suspend_hidden", slot.suspendHidden); + + if (!readString(keys, "on_empty", slot.onEmpty)) { + readString(keys, "on_created_empty", slot.onEmpty); + } + + slot.layout = readLayoutModeAt(keys.take("layout"), "scratchpad", "layout"); + + if (const toml::node* n = keys.take("gap")) { + if (const auto val = n->value()) { + if (*val >= 0 && *val <= 200) { + slot.gap = static_cast(*val); + } else { + warnAt(n->source(), "scratchpad.gap = {} out of range (expected 0-200)", *val); + } + } else { + warnAt(n->source(), "ignoring scratchpad.gap (expected integer)"); + } + } + + if (const toml::node* curveNode = keys.take("curve")) { + if (const auto parsedCurve = readCurveNode( + curveNode, "scratchpad." + slot.name, loaded.animation.beziers, loaded.animation.springs + )) { + slot.curve = *parsedCurve; + } + } + + loaded.scratchpadRules.push_back(std::move(slot)); + } + } + void readLayerRules(Section& root, Config& loaded) { const toml::node* node = root.take("layer_rule"); if (node == nullptr) { @@ -1831,6 +2051,7 @@ namespace umbriel { readWindowRules(root, loaded); readLayerRules(root, loaded); readWorkspaces(root, loaded); + readScratchpads(root, loaded); } // Reject config if any error-level diagnostics were emitted. diff --git a/src/config/config.h b/src/config/config.h index f6837a4c..b51756bd 100644 --- a/src/config/config.h +++ b/src/config/config.h @@ -77,6 +77,28 @@ namespace umbriel { bool operator==(const WorkspaceConfig&) const = default; }; + // Custom configuration override parsed from a [[scratchpad]] entry. + struct ScratchpadSlotConfig { + std::string name; + std::optional scale; + std::optional direction; + // "slide", "fade", "popin", or "slidefade". Ignored when scale <= 0 (that always fades in place). + // Defaults to "slide" otherwise. + std::optional style; + std::optional durationMs; + std::optional curve; + std::optional dim; + std::optional blur; + std::optional maximize; + std::optional maximizeToEdges; + std::optional fullscreen; + std::optional suspendHidden; + std::optional onEmpty; + std::optional layout; + std::optional gap; + bool operator==(const ScratchpadSlotConfig&) const = default; + }; + // Fully resolved layout config. Owned by each Workspace. struct ResolvedLayoutConfig { LayoutMode mode = LayoutMode::Scrolling; @@ -247,6 +269,7 @@ namespace umbriel { std::optional defaultWorkspace; // 1-64 std::optional defaultScrollingColumn; std::optional defaultScrollingColumnOrder; + std::optional defaultScratchpad; std::optional defaultFullscreen; std::optional defaultMaximizeToEdges; std::optional defaultMaximize; @@ -281,6 +304,7 @@ namespace umbriel { && defaultWorkspace == other.defaultWorkspace && defaultScrollingColumn == other.defaultScrollingColumn && defaultScrollingColumnOrder == other.defaultScrollingColumnOrder + && defaultScratchpad == other.defaultScratchpad && defaultFullscreen == other.defaultFullscreen && defaultMaximizeToEdges == other.defaultMaximizeToEdges && defaultMaximize == other.defaultMaximize @@ -309,6 +333,7 @@ namespace umbriel { std::optional defaultWorkspace; std::optional defaultScrollingColumn; std::optional defaultScrollingColumnOrder; + std::optional defaultScratchpad; std::optional defaultFullscreen; std::optional defaultMaximizeToEdges; std::optional defaultMaximize; @@ -454,7 +479,12 @@ namespace umbriel { bool blur = false; double scale = 0.0; bool maximize = false; + bool maximizeToEdges = false; bool fullscreen = false; + std::string direction = "top"; + // Same as ScratchpadSlotConfig::style; empty defaults to "slide". + std::string style = "slide"; + bool suspendHidden = true; bool operator==(const Scratchpad&) const = default; } scratchpad; @@ -676,7 +706,8 @@ namespace umbriel { std::vector outputs; std::vector windowRules; std::vector layerRules; - std::vector workspaceRules; // [[workspace]] layout rules + std::vector workspaceRules; // [[workspace]] layout rules + std::vector scratchpadRules; // [[scratchpad]] custom slot rules bool operator==(const Config&) const = default; }; diff --git a/src/config/keybind_parse.cpp b/src/config/keybind_parse.cpp index c93310cd..5ab6c542 100644 --- a/src/config/keybind_parse.cpp +++ b/src/config/keybind_parse.cpp @@ -184,8 +184,10 @@ namespace umbriel { {"overview-close", "", KeybindAction::OverviewClose}, {"overview-open", "", KeybindAction::OverviewOpen}, {"overview-toggle", "", KeybindAction::OverviewToggle}, - {"scratchpad-focus-next", "[]", KeybindAction::ScratchpadFocusNext, ActionArgKind::OptionalOutput}, - {"scratchpad-toggle", "[]", KeybindAction::ScratchpadToggle, ActionArgKind::OptionalOutput}, + {"scratchpad-focus-next", "[[/]]", KeybindAction::ScratchpadFocusNext, + ActionArgKind::OptionalScratchpad}, + {"scratchpad-toggle", "[[/]]", KeybindAction::ScratchpadToggle, + ActionArgKind::OptionalScratchpad}, {"session-quit", "[skip-confirmation]", KeybindAction::SessionQuit, ActionArgKind::SkipConfirmation}, {"spawn", "", KeybindAction::Spawn, ActionArgKind::Command}, {"submap", "", KeybindAction::Submap, ActionArgKind::Command}, @@ -228,15 +230,17 @@ namespace umbriel { {"window-move-to-output-left", "", KeybindAction::WindowMoveToOutputLeft}, {"window-move-to-output-right", "", KeybindAction::WindowMoveToOutputRight}, {"window-move-to-output-up", "", KeybindAction::WindowMoveToOutputUp}, - {"window-move-to-scratchpad", "[]", KeybindAction::WindowMoveToScratchpad, - ActionArgKind::OptionalOutput}, + {"window-move-to-scratchpad", "[[/]]", KeybindAction::WindowMoveToScratchpad, + ActionArgKind::OptionalScratchpad}, + {"window-move-to-scratchpad-silent", "[[/]]", KeybindAction::WindowMoveToScratchpadSilent, + ActionArgKind::OptionalScratchpad}, {"window-move-to-workspace", "[/]", KeybindAction::WindowMoveToWorkspace, ActionArgKind::Workspace}, {"window-move-to-workspace-next", "", KeybindAction::WindowMoveToWorkspaceNext}, {"window-move-to-workspace-previous", "", KeybindAction::WindowMoveToWorkspacePrevious}, {"window-move-up", "", KeybindAction::WindowMoveUp}, - {"window-restore-from-scratchpad", "[]", KeybindAction::WindowRestoreFromScratchpad, - ActionArgKind::OptionalOutput}, + {"window-restore-from-scratchpad", "[[/]]", KeybindAction::WindowRestoreFromScratchpad, + ActionArgKind::OptionalScratchpad}, {"window-set-height", "", KeybindAction::WindowSetHeight, ActionArgKind::WidthFraction}, {"window-set-width", "", KeybindAction::WindowSetWidth, ActionArgKind::WidthFraction}, {"window-swap-next", "", KeybindAction::WindowSwapNext}, @@ -246,8 +250,8 @@ namespace umbriel { {"window-toggle-maximize", "", KeybindAction::ToggleMaximize}, {"window-toggle-maximize-to-edges", "", KeybindAction::ToggleMaximizeToEdges}, {"window-toggle-pinned", "", KeybindAction::TogglePinned}, - {"window-toggle-scratchpad", "[]", KeybindAction::WindowToggleScratchpad, - ActionArgKind::OptionalOutput}, + {"window-toggle-scratchpad", "[[/]]", KeybindAction::WindowToggleScratchpad, + ActionArgKind::OptionalScratchpad}, {"workspace-focus-last", "", KeybindAction::WorkspaceFocusLast}, {"workspace-move-down", "", KeybindAction::WorkspaceMoveDown}, {"workspace-move-to-output-down", "", KeybindAction::WorkspaceMoveToOutputDown}, @@ -412,6 +416,31 @@ namespace umbriel { return true; } break; + case ActionArgKind::OptionalScratchpad: { + if (value == spec.name) { + output.action = spec.action; + output.payload = ScratchpadArg{}; + return true; + } + if (takeActionArg(value, spec, arg)) { + ScratchpadArg scratchpad; + std::string_view target = arg; + if (target.starts_with("special:")) { + target.remove_prefix(8); + } + const size_t separator = target.find('/'); + if (separator != std::string_view::npos) { + scratchpad.name = std::string(target.substr(0, separator)); + scratchpad.output = std::string(target.substr(separator + 1)); + } else { + scratchpad.name = std::string(target); + } + output.action = spec.action; + output.payload = std::move(scratchpad); + return true; + } + break; + } case ActionArgKind::WindowId: if (takeActionArg(value, spec, arg)) { output.action = spec.action; diff --git a/src/config/keybind_parse.h b/src/config/keybind_parse.h index 4af6a06e..ec6ba289 100644 --- a/src/config/keybind_parse.h +++ b/src/config/keybind_parse.h @@ -83,6 +83,7 @@ namespace umbriel { CheatsheetOpen, CheatsheetClose, WindowMoveToScratchpad, + WindowMoveToScratchpadSilent, ScratchpadToggle, WindowRestoreFromScratchpad, WindowToggleScratchpad, @@ -175,9 +176,15 @@ namespace umbriel { bool skipConfirmation = false; bool operator==(const QuitArg&) const = default; }; + struct ScratchpadArg { + std::string name; + std::string output; // empty = resolve against the focused output + bool operator==(const ScratchpadArg&) const = default; + }; using KeybindPayload = std::variant< - std::monostate, SpawnArg, SubmapArg, WidthArg, WorkspaceArg, OutputArg, WindowIdArg, LayoutModeArg, QuitArg>; + std::monostate, SpawnArg, SubmapArg, WidthArg, WorkspaceArg, OutputArg, WindowIdArg, LayoutModeArg, QuitArg, + ScratchpadArg>; struct Keybind { // What triggers the bind. @@ -228,6 +235,7 @@ namespace umbriel { WidthFraction, Workspace, OptionalOutput, + OptionalScratchpad, WindowId, OptionalWindowId, WidthDelta, diff --git a/src/config/resolve.cpp b/src/config/resolve.cpp index 81ae6488..f75b356f 100644 --- a/src/config/resolve.cpp +++ b/src/config/resolve.cpp @@ -218,6 +218,9 @@ namespace umbriel { if (rule.defaultScrollingColumnOrder) { resolved.defaultScrollingColumnOrder = rule.defaultScrollingColumnOrder; } + if (rule.defaultScratchpad) { + resolved.defaultScratchpad = rule.defaultScratchpad; + } if (rule.defaultFullscreen) { resolved.defaultFullscreen = rule.defaultFullscreen; } diff --git a/src/input/gestures.cpp b/src/input/gestures.cpp index d2101480..95387d8f 100644 --- a/src/input/gestures.cpp +++ b/src/input/gestures.cpp @@ -12,6 +12,7 @@ #include #include "wlr.h" // clang-format on +#include "workspace/scratchpad.h" #include "workspace/workspace.h" namespace umbriel { @@ -124,6 +125,8 @@ namespace umbriel { && (m_state == State::Pending || m_state == State::Scroll || m_state == State::Switch + || m_state == State::Overview + || m_state == State::Scratchpad || m_state == State::OverviewSelect)) { // Hard reset: do NOT call into workspace/group objects (they may be mid-destruction). m_output = nullptr; @@ -145,6 +148,9 @@ namespace umbriel { case State::Overview: finishOverview(true); break; + case State::Scratchpad: + finishScratchpad(true); + break; case State::Forward: // Forward a cancel end so clients see the end. wlr_pointer_gestures_v1_send_swipe_end(m_server->pointerGestures(), m_server->seat()->wlr(), 0, true); @@ -241,6 +247,9 @@ namespace umbriel { case State::Overview: finishOverview(true); break; + case State::Scratchpad: + finishScratchpad(true); + break; case State::Forward: case State::OverviewSelect: case State::Pending: @@ -267,14 +276,37 @@ namespace umbriel { cancelActive(); } Overview* overview = m_server->overview(); - if (event->fingers == 4 && overview != nullptr) { - m_state = State::Overview; + ScratchpadManager* scratchpad = m_server->scratchpadManager(); + wlr_output* wlrOut = m_server->preferredOutput(); + Output* out = m_server->outputFromWlr(wlrOut); + if (out == nullptr && !m_server->outputs().empty()) { + out = m_server->outputs().front().get(); + } + if (event->fingers == 4) { + m_naturalScrollDirection = touchpadGestureDirection(event->pointer); m_accumX = 0; m_accumY = 0; - m_overviewWasOpen = overview->active(); - m_progress = m_overviewWasOpen ? 1.0 : 0.0; - m_velocity = 0; + m_output = out; m_lastTimeMsec = event->time_msec; + m_velocity = 0; + if (overview != nullptr && overview->active()) { + m_state = State::Overview; + m_overviewWasOpen = true; + m_scratchpadWasOpen = false; + m_progress = 1.0; + return; + } + if (scratchpad != nullptr && out != nullptr && scratchpad->isOutputVisible(out)) { + m_state = State::Scratchpad; + m_scratchpadWasOpen = true; + m_overviewWasOpen = false; + m_progress = 1.0; + return; + } + m_overviewWasOpen = false; + m_scratchpadWasOpen = false; + m_progress = 0.0; + m_state = State::Pending; return; } if (event->fingers == 3) { @@ -327,6 +359,23 @@ namespace umbriel { } m_output = out; + if (m_output != nullptr && !m_overviewWasOpen && !m_scratchpadWasOpen) { + if (-m_accumY * m_naturalScrollDirection > 0) { + // 4-finger swipe up -> Overview + m_state = State::Overview; + m_overviewWasOpen = false; + m_scratchpadWasOpen = false; + m_progress = 0.0; + } else { + // 4-finger swipe down -> Scratchpad + m_state = State::Scratchpad; + m_scratchpadWasOpen = false; + m_overviewWasOpen = false; + m_progress = 0.0; + } + return; + } + if (Overview* overview = m_server->overview(); overview != nullptr && overview->interactive()) { // Horizontal has no meaning over the filmstrip, and letting it through // would step rows on any swipe that drifted off true. @@ -449,6 +498,23 @@ namespace umbriel { return; } + case State::Scratchpad: { + ScratchpadManager* scratchpad = m_server->scratchpadManager(); + if (scratchpad == nullptr) { + m_state = State::Idle; + return; + } + m_accumY += event->dy; + // Swipe down opens (positive dy), swipe up closes; base is where the gesture started. + const double base = m_scratchpadWasOpen ? 1.0 : 0.0; + const double p = std::clamp(base + (m_accumY * m_naturalScrollDirection) / kOverviewDistancePx, 0.0, 1.0); + const uint32_t dt = std::max(1U, event->time_msec - m_lastTimeMsec); + m_velocity = 0.75 * m_velocity + 0.25 * ((event->dy * m_naturalScrollDirection) / static_cast(dt)); + m_lastTimeMsec = event->time_msec; + m_progress = p; + return; + } + case State::Idle: return; } @@ -492,6 +558,10 @@ namespace umbriel { finishOverview(event->cancelled); return; + case State::Scratchpad: + finishScratchpad(event->cancelled); + return; + case State::Idle: return; } @@ -518,6 +588,41 @@ namespace umbriel { overview->gestureEnd(commitOpen); } + // ===== Scratchpad finish (4-finger) ===== + + void Gestures::finishScratchpad(bool cancelled) { + m_state = State::Idle; + ScratchpadManager* scratchpad = m_server->scratchpadManager(); + Output* output = m_output != nullptr ? m_output : m_server->outputFromWlr(m_server->preferredOutput()); + if (output == nullptr && !m_server->outputs().empty()) { + output = m_server->outputs().front().get(); + } + if (scratchpad == nullptr || output == nullptr) { + return; + } + bool commitToggle = false; + if (!cancelled) { + const double base = m_scratchpadWasOpen ? 1.0 : 0.0; + const bool farEnough = std::abs(m_progress - base) > kCommitProgress; + const bool fastEnough = std::abs(m_velocity) > kCommitVelocityPxMs && (m_velocity > 0) == !m_scratchpadWasOpen; + if (farEnough || fastEnough) { + commitToggle = true; + } + } + if (!commitToggle) { + return; + } + // Closing targets whatever slot is on screen; the default slot is only the + // open-on-empty target. + std::string slotName; + if (m_scratchpadWasOpen) { + if (auto open = scratchpad->visibleSlotName(output)) { + slotName = *open; + } + } + scratchpad->toggle(output, slotName); + } + // ===== Scroll finish (Step 5) ===== void Gestures::finishScroll(bool cancelled, uint32_t timeMsec) { diff --git a/src/input/gestures.h b/src/input/gestures.h index 9f7384fe..b0522014 100644 --- a/src/input/gestures.h +++ b/src/input/gestures.h @@ -29,7 +29,7 @@ namespace umbriel { void endPointerScroll(bool cancelled, uint32_t timeMsec); private: - enum class State { Idle, Forward, Pending, Scroll, Switch, Overview, OverviewSelect }; + enum class State { Idle, Forward, Pending, Scroll, Switch, Overview, OverviewSelect, Scratchpad }; enum class ScrollSource { None, Swipe, Pointer }; static void onSwipeBegin(wl_listener* listener, void* data); @@ -54,6 +54,7 @@ namespace umbriel { void finishScroll(bool cancelled, uint32_t timeMsec); void finishSwitch(bool cancelled); void finishOverview(bool cancelled); + void finishScratchpad(bool cancelled); void silentCancel(); [[nodiscard]] bool beginScroll(Workspace* workspace, double scale, ScrollSource source); void updateScroll(double delta, uint32_t timeMsec); @@ -86,6 +87,9 @@ namespace umbriel { // Overview state (vertical 4-finger): swipe up opens, swipe down closes. bool m_overviewWasOpen = false; + // Scratchpad state (vertical 4-finger): swipe down reveals, swipe up dismisses. + bool m_scratchpadWasOpen = false; + // OverviewSelect state (vertical 3-finger, overview up) reuses m_accumY as // the travel left over since the last row step. diff --git a/src/scene/cheatsheet_rows.cpp b/src/scene/cheatsheet_rows.cpp index 415dac48..e72417d5 100644 --- a/src/scene/cheatsheet_rows.cpp +++ b/src/scene/cheatsheet_rows.cpp @@ -232,6 +232,19 @@ namespace { return name + ": " + output->output; } return name; + case umbriel::ActionArgKind::OptionalScratchpad: + if (const auto* scratchpad = umbriel::payloadIf(bind); + scratchpad != nullptr && (!scratchpad->name.empty() || !scratchpad->output.empty())) { + std::string label = name; + if (!scratchpad->name.empty()) { + label += ": " + scratchpad->name; + } + if (!scratchpad->output.empty()) { + label += "/" + scratchpad->output; + } + return label; + } + return name; case umbriel::ActionArgKind::WindowId: case umbriel::ActionArgKind::OptionalWindowId: if (const auto* window = umbriel::payloadIf(bind); @@ -343,6 +356,12 @@ namespace { case A::ToggleMaximize: case A::ToggleMaximizeToEdges: case A::ToggleFullscreen: + case A::TogglePinned: + case A::ScratchpadToggle: + case A::WindowMoveToScratchpad: + case A::WindowMoveToScratchpadSilent: + case A::WindowRestoreFromScratchpad: + case A::WindowToggleScratchpad: return Group::Windows; case A::WorkspaceSwitch: case A::ColumnMoveToWorkspace: @@ -365,6 +384,7 @@ namespace { case A::OutputFocusRight: case A::OutputFocusUp: case A::OutputFocusDown: + case A::ScratchpadFocusNext: return Group::Focus; case A::OverviewToggle: case A::OverviewOpen: diff --git a/src/server/actions.cpp b/src/server/actions.cpp index c51c1f63..15b05427 100644 --- a/src/server/actions.cpp +++ b/src/server/actions.cpp @@ -40,14 +40,33 @@ namespace umbriel { return output->workspaceGroup()->active(); } - Output* scratchpadOutput(Server& server, const Keybind& bind, std::string* error) { - const auto* arg = payloadIf(bind); - if (arg == nullptr || arg->output.empty()) { - return server.outputFromWlr(server.preferredOutput()); + Output* scratchpadTarget(Server& server, const Keybind& bind, std::string& name, std::string* error) { + name.clear(); + std::string outputName; + if (const auto* arg = payloadIf(bind)) { + name = arg->name; + outputName = arg->output; + } else if (const auto* arg = payloadIf(bind)) { + outputName = arg->output; + } + + if (outputName.empty() && !name.empty()) { + if (server.outputFromName(name) != nullptr) { + outputName = name; + name.clear(); + } + } + + if (outputName.empty()) { + Output* pref = server.outputFromWlr(server.preferredOutput()); + if (pref == nullptr && !server.outputs().empty()) { + pref = server.outputs().front().get(); + } + return pref; } - Output* output = server.outputFromName(arg->output); + Output* output = server.outputFromName(outputName); if (output == nullptr && error != nullptr) { - *error = "unknown output: " + arg->output; + *error = "unknown output: " + outputName; } return output; } @@ -950,6 +969,26 @@ namespace umbriel { // Workspaces bool actionWorkspace(Server& server, const Keybind& bind, std::string* error) { + if (const auto* selector = payloadIf(bind)) { + if (selector->name.starts_with("special:") || selector->name == "special") { + std::string slotName = selector->name == "special" ? "" : selector->name.substr(8); + Output* output = selector->output.empty() ? server.outputFromWlr(server.preferredOutput()) + : server.outputFromName(selector->output); + if (output == nullptr) { + output = server.outputFromWlr(server.preferredOutput()); + } + ScratchpadManager* scratchpad = server.scratchpadManager(); + if (scratchpad == nullptr || output == nullptr) { + return reject(error, "no output available for " + selector->name); + } + if (bind.action == KeybindAction::WindowMoveToWorkspace) { + View* view = focusedWindow(server); + return view != nullptr && scratchpad->moveToScratchpad(view, output, slotName); + } + return scratchpad->toggle(output, slotName); + } + } + const std::expected target = resolveWorkspaceSelector(server, bind); if (!target.has_value()) { return reject(error, target.error()); @@ -1253,33 +1292,54 @@ namespace umbriel { // Scratchpad bool actionMoveToScratchpad(Server& server, const Keybind& bind, std::string* error) { - Output* output = scratchpadOutput(server, bind, error); + std::string name; + Output* output = scratchpadTarget(server, bind, name, error); if (output == nullptr) { return false; } - Workspace* workspace = activeWorkspace(server); + View* view = focusedWindow(server); ScratchpadManager* scratchpad = server.scratchpadManager(); - return scratchpad != nullptr - && workspace != nullptr - && scratchpad->moveToScratchpad(workspace->focusedView(), output); + return scratchpad != nullptr && view != nullptr && scratchpad->moveToScratchpad(view, output, name); + } + + bool actionMoveToScratchpadSilent(Server& server, const Keybind& bind, std::string* error) { + std::string name; + Output* output = scratchpadTarget(server, bind, name, error); + if (output == nullptr) { + return false; + } + View* view = focusedWindow(server); + ScratchpadManager* scratchpad = server.scratchpadManager(); + if (scratchpad == nullptr || view == nullptr || !scratchpad->moveToScratchpad(view, output, name)) { + return false; + } + // Silent: focus stays on the workspace the window left, not the drawer. + if (Workspace* ws = activeWorkspace(server)) { + if (View* next = ws->focusedView()) { + server.focusView(next); + } + } + return true; } bool actionScratchpadToggle(Server& server, const Keybind& bind, std::string* error) { - Output* output = scratchpadOutput(server, bind, error); + std::string name; + Output* output = scratchpadTarget(server, bind, name, error); if (output == nullptr) { return false; } ScratchpadManager* scratchpad = server.scratchpadManager(); - return scratchpad != nullptr && scratchpad->toggle(output); + return scratchpad != nullptr && scratchpad->toggle(output, name); } bool actionRestoreFromScratchpad(Server& server, const Keybind& bind, std::string* error) { - Output* output = scratchpadOutput(server, bind, error); + std::string name; + Output* output = scratchpadTarget(server, bind, name, error); if (output == nullptr) { return false; } ScratchpadManager* scratchpad = server.scratchpadManager(); - return scratchpad != nullptr && scratchpad->restoreFocused(output); + return scratchpad != nullptr && scratchpad->restoreFocused(output, name); } // Toggles the focused window's scratchpad membership: if the focused @@ -1287,7 +1347,8 @@ namespace umbriel { // actionRestoreFromScratchpad); otherwise move it into the scratchpad // (same as actionMoveToScratchpad). bool actionToggleScratchpad(Server& server, const Keybind& bind, std::string* error) { - Output* output = scratchpadOutput(server, bind, error); + std::string name; + Output* output = scratchpadTarget(server, bind, name, error); if (output == nullptr) { return false; } @@ -1296,19 +1357,20 @@ namespace umbriel { return false; } if (scratchpad->hasFocus(output)) { - return scratchpad->restoreFocused(output); + return scratchpad->restoreFocused(output, name); } Workspace* workspace = activeWorkspace(server); - return workspace != nullptr && scratchpad->moveToScratchpad(workspace->focusedView(), output); + return workspace != nullptr && scratchpad->moveToScratchpad(workspace->focusedView(), output, name); } bool actionScratchpadFocusNext(Server& server, const Keybind& bind, std::string* error) { - Output* output = scratchpadOutput(server, bind, error); + std::string name; + Output* output = scratchpadTarget(server, bind, name, error); if (output == nullptr) { return false; } ScratchpadManager* scratchpad = server.scratchpadManager(); - return scratchpad != nullptr && scratchpad->focusNext(output); + return scratchpad != nullptr && scratchpad->focusNext(output, name); } constexpr std::array(KeybindAction::Count)> kActionHandlers = { @@ -1371,6 +1433,7 @@ namespace umbriel { &actionCheatsheetOpen, &actionCheatsheetClose, &actionMoveToScratchpad, + &actionMoveToScratchpadSilent, &actionScratchpadToggle, &actionRestoreFromScratchpad, &actionToggleScratchpad, diff --git a/src/server/server.cpp b/src/server/server.cpp index 9b471d26..d3160559 100644 --- a/src/server/server.cpp +++ b/src/server/server.cpp @@ -756,10 +756,10 @@ namespace umbriel { return m_shellLayerTrees[layer]; } - void Server::spawn(const char* command, const char* description, bool withActivationToken) { + pid_t Server::spawn(const char* command, const char* description, bool withActivationToken) { if (m_socketName.empty()) { wlr_log(WLR_ERROR, "cannot spawn before the Wayland socket exists"); - return; + return -1; } wlr_xdg_activation_token_v1* launchToken = nullptr; @@ -781,7 +781,7 @@ namespace umbriel { wlr_xdg_activation_token_v1_destroy(launchToken); } wlr_log(WLR_ERROR, "fork failed"); - return; + return -1; } if (pid == 0) { resetChildSignalState(); @@ -806,9 +806,10 @@ namespace umbriel { } wlr_log( - WLR_INFO, "spawned '%s' on WAYLAND_DISPLAY=%s", description == nullptr ? command : description, - m_socketName.c_str() + WLR_INFO, "spawned '%s' on WAYLAND_DISPLAY=%s (pid=%d)", description == nullptr ? command : description, + m_socketName.c_str(), pid ); + return pid; } void Server::updateSeatCapabilities() { m_seat->updateCapabilities(!m_keyboards.empty(), !m_touchDevices.empty()); } diff --git a/src/server/server.h b/src/server/server.h index 9a1360e3..5701fccf 100644 --- a/src/server/server.h +++ b/src/server/server.h @@ -232,7 +232,7 @@ namespace umbriel { void relayoutBanner(); void relayoutCheatsheet(); void relayoutQuitConfirm(); - void spawn(const char* command, const char* description = nullptr, bool withActivationToken = false); + pid_t spawn(const char* command, const char* description = nullptr, bool withActivationToken = false); void handleConfigReload(); // Re-evaluate application idle inhibitors after a surface's presentation // visibility changes. diff --git a/src/view/view.cpp b/src/view/view.cpp index ef828c5b..e1c39521 100644 --- a/src/view/view.cpp +++ b/src/view/view.cpp @@ -777,6 +777,27 @@ namespace umbriel { scheduleFrame(); } + void View::animateTo(int x, int y, int durationMs, const AnimationCurve& curve) { + if (!config().animation.enabled || durationMs <= 0) { + setPosition(x, y); + return; + } + m_posX.retarget(x, durationMs, curve); + m_posY.retarget(y, durationMs, curve); + scheduleFrame(); + } + + void View::beginZoomAnimation(const wlr_box& from, const wlr_box& to, int durationMs, const AnimationCurve& curve) { + m_presentation.setSize(from.width, from.height); + m_presentation.animateTo(to.width, to.height, durationMs, curve); + wlr_scene_node_set_position(&m_sceneTree->node, from.x, from.y); + m_posX.snap(from.x); + m_posY.snap(from.y); + m_posX.retarget(to.x, durationMs, curve); + m_posY.retarget(to.y, durationMs, curve); + scheduleFrame(); + } + bool View::tickAnimations(uint64_t nowMsec) { bool active = false; @@ -1694,6 +1715,40 @@ namespace umbriel { showDecorations(!m_toplevel->scheduled.fullscreen); + if (m_server->scratchpadManager() != nullptr) { + if (auto pending = m_server->scratchpadManager()->consumePendingCapture(this)) { + if (!m_tiled) { + placeInUsableArea(rule.defaultPosition); + } + if (m_server->scratchpadManager()->moveToScratchpad(this, pending->output, pending->slotName)) { + updateForeignIdentity(); + updateForeignState(); + if (!m_server->sessionLocked() && rule.defaultFocused.value_or(true)) { + m_server->focusView(this); + } + return; + } + } else if (rule.defaultScratchpad.has_value() && !rule.defaultScratchpad->empty()) { + Output* targetOutput = m_server->outputFromWlr(m_server->preferredOutput()); + if (targetOutput == nullptr && !m_server->outputs().empty()) { + targetOutput = m_server->outputs().front().get(); + } + if (targetOutput != nullptr) { + if (!m_tiled) { + placeInUsableArea(rule.defaultPosition); + } + if (m_server->scratchpadManager()->moveToScratchpad(this, targetOutput, *rule.defaultScratchpad)) { + updateForeignIdentity(); + updateForeignState(); + if (!m_server->sessionLocked() && rule.defaultFocused.value_or(true)) { + m_server->focusView(this); + } + return; + } + } + } + } + if (m_workspace != nullptr) { m_workspace->layoutAttach(this, rule.defaultWidth); } else if (!attachToAvailableWorkspace(rule)) { @@ -1964,6 +2019,54 @@ namespace umbriel { if (m_toplevel->base->initial_commit || reconfigureOpeningState) { // Resolve window rules early to influence initial tiled/float decision and size. const ResolvedWindowRule rule = resolvedRules(); + + if (m_server->scratchpadManager() != nullptr) { + std::optional targetSlot; + Output* targetOutput = nullptr; + if (auto pending = m_server->scratchpadManager()->peekPendingCapture(this)) { + targetSlot = pending->slotName; + targetOutput = pending->output; + } else if (rule.defaultScratchpad.has_value() && !rule.defaultScratchpad->empty()) { + targetSlot = *rule.defaultScratchpad; + } + if (targetSlot) { + if (targetOutput == nullptr) { + targetOutput = m_server->outputFromWlr(m_server->preferredOutput()); + if (targetOutput == nullptr && !m_server->outputs().empty()) { + targetOutput = m_server->outputs().front().get(); + } + } + if (targetOutput != nullptr) { + wlr_box targetArea = targetOutput->usableArea(); + if (targetArea.width <= 0 || targetArea.height <= 0) { + wlr_output_layout_get_box(m_server->outputLayout(), targetOutput->wlr(), &targetArea); + } + const auto slotCfg = m_server->scratchpadManager()->effectiveConfig(*targetSlot); + const bool isScaled = slotCfg.scale.has_value() ? (*slotCfg.scale > 0.0 && *slotCfg.scale <= 1.0) + : (config().animation.scratchpad.scale > 0.0); + wlr_xdg_toplevel_set_tiled(m_toplevel, 0); + if (slotCfg.fullscreen.value_or(false) || slotCfg.maximizeToEdges.value_or(false)) { + if (slotCfg.fullscreen.value_or(false)) { + wlr_xdg_toplevel_set_fullscreen(m_toplevel, true); + } + wlr_box fullBox{}; + wlr_output_layout_get_box(m_server->outputLayout(), targetOutput->wlr(), &fullBox); + wlr_xdg_toplevel_set_size(m_toplevel, fullBox.width, fullBox.height); + } else if (slotCfg.maximize.value_or(false)) { + wlr_xdg_toplevel_set_size(m_toplevel, targetArea.width, targetArea.height); + } else if (isScaled && targetArea.width > 0 && targetArea.height > 0) { + const double scale = slotCfg.scale.has_value() ? *slotCfg.scale : config().animation.scratchpad.scale; + const int targetW = std::max(100, static_cast(std::lround(targetArea.width * scale))); + const int targetH = std::max(100, static_cast(std::lround(targetArea.height * scale))); + wlr_xdg_toplevel_set_size(m_toplevel, targetW, targetH); + } else { + requestFloatingSize(0, 0); + } + return; + } + } + } + const bool wantTiled = rule.defaultFloating ? !*rule.defaultFloating : looksTiled(m_toplevel); const bool wantFullscreen = m_toplevel->requested.fullscreen || (rule.defaultFullscreen && *rule.defaultFullscreen); @@ -2115,7 +2218,15 @@ namespace umbriel { if (m_mapped && m_tiled && m_workspace != nullptr && m_workspace->active()) { m_workspace->syncViewPresentation(this); } else if (m_mapped && !m_tiled) { - if (m_toplevel->scheduled.fullscreen && m_onActiveWorkspace) { + if (m_server->scratchpadManager() != nullptr && m_server->scratchpadManager()->contains(this)) { + if (!hasActiveAnimations()) { + if (Output* out = currentOutput()) { + if (auto slotName = m_server->scratchpadManager()->slotForView(this)) { + m_server->scratchpadManager()->arrangeSlot(out, *slotName, false); + } + } + } + } else if (m_toplevel->scheduled.fullscreen && m_onActiveWorkspace) { // Keep fullscreen placement authoritative; the xdg scene helper just // reset the surface offset for this commit. applyFullscreenLayout(); @@ -2470,7 +2581,7 @@ namespace umbriel { } } - void View::setFloating(bool floating, bool focus) { + void View::setFloating(bool floating, bool focus, bool preserveSize) { if (!m_mapped || !m_toplevel->base->initialized) { return; } @@ -2631,7 +2742,7 @@ namespace umbriel { m_decoration.setBordersEnabled(!wantFullscreen); updateBorderGeometry(); if (m_workspace != nullptr) { - m_workspace->layoutAttach(this); + m_workspace->layoutAttach(this, std::nullopt, preserveSize); } applyCornerRadius(); updateShadow(); diff --git a/src/view/view.h b/src/view/view.h index edfe92e0..494e9942 100644 --- a/src/view/view.h +++ b/src/view/view.h @@ -142,8 +142,9 @@ namespace umbriel { void setOnActiveWorkspace(bool active); void setScratchpadBorder(bool scratchpad); - void animateTo(int x, int y); void setPosition(int x, int y); + void animateTo(int x, int y); + void animateTo(int x, int y, int durationMs, const AnimationCurve& curve); // The authoritative layout position: where the window's slot is, not where its scene node happens to be // mid-animation. Workspace slides and arrange reflows move nodes without touching the animation targets, so window // listings that order by position must read these instead. @@ -175,6 +176,8 @@ namespace umbriel { // Animate the presented size toward a layout-assigned size. Called by Workspace::arrange when it configures the // client, so the animation owns the presented size before the clip can report the final size. void beginResizeAnimation(int width, int height, bool allowFullscreen = false); + // Popin zoom: animate presented size and position from `from` to `to`. + void beginZoomAnimation(const wlr_box& from, const wlr_box& to, int durationMs, const AnimationCurve& curve); // Apply the presentation state derived from the view's layout box `target`: fullscreen backdrop, presented size and // surface crop, borders, shadow and blur. Containment on the view's own output is the job of the output's clipped // scene roots, so nothing here depends on where the output edges are. @@ -188,11 +191,13 @@ namespace umbriel { void snapPosition(int x, int y); void animateFadeTo(float toAlpha, int durationMs, const AnimationCurve& curve); [[nodiscard]] const ViewPresentation& presentation() const { return m_presentation; } + [[nodiscard]] bool sizeAnimating() const { return m_presentation.animating(); } // Size/position to the full output and drop tile clips (exclusive zones do not apply). void applyFullscreenLayout(bool animate = false); // Compositor-driven fullscreen toggle (keybind); client requests use handleRequestFullscreen. void toggleFullscreen(); void applyDeferredUnfullscreen(); + void setMaximized(bool maximized); void setMaximizedToEdges(bool maximized); void toggleMaximizedToEdges(); // Compositor-driven maximize toggle (keybind). A floating window fills its @@ -204,7 +209,7 @@ namespace umbriel { // windows clear their full-width state through the layout. void dropMaximizedForResize(); // Detach from the scrolling layout (float) or re-insert as a tiled column. - void setFloating(bool floating, bool focus = true); + void setFloating(bool floating, bool focus = true, bool preserveSize = false); void toggleFloating(); void togglePinned(); // Restore the global pinned scene layer after temporary drag reparenting. @@ -271,7 +276,6 @@ namespace umbriel { void handleRequestMove(); void handleRequestResize(void* data); void handleRequestMaximize(); - void setMaximized(bool maximized); void handleRequestFullscreen(); void setFullscreen(bool fullscreen); void handleSetTitle(); @@ -316,7 +320,6 @@ namespace umbriel { // Shared tail of a finished/cancelled size animation: settle the presented // size on the committed geometry and refresh the derived chrome. void finishSizeAnimation(); - [[nodiscard]] bool sizeAnimating() const { return m_presentation.animating(); } // True while the border ring exists and is showing. Fullscreen keeps the // tree but disables it, so the pointer alone does not answer this. [[nodiscard]] bool decorated() const; diff --git a/src/workspace/scratchpad.cpp b/src/workspace/scratchpad.cpp index 0cdf1d13..34718ad5 100644 --- a/src/workspace/scratchpad.cpp +++ b/src/workspace/scratchpad.cpp @@ -1,18 +1,125 @@ #include "workspace/scratchpad.h" +#include "config/resolve.h" #include "output/output.h" #include "server/server.h" #include "view/view.h" +#include "view/xdg_size.h" #include "wlr.h" #include "workspace/workspace.h" #include #include +#include #include +#include #include namespace umbriel { + namespace { + void safeSetSuspended(View* view, bool suspended) { + if (view == nullptr || !view->mapped() || view->toplevel() == nullptr) { + return; + } + if (view->toplevel()->base == nullptr || !view->toplevel()->base->initialized) { + return; + } + if (view->toplevel()->resource == nullptr) { + return; + } + if (wl_resource_get_version(view->toplevel()->resource) < 6) { + return; + } + wlr_xdg_toplevel_set_suspended(view->toplevel(), suspended); + } + + // A slot\x27s on_empty command is only allowed to claim the window it actually + // started, so a capture waits for a client whose process descends from it. + constexpr int kCaptureTimeoutSec = 30; + constexpr int kMaxAncestorWalk = 16; + + pid_t viewPid(const View* view) { + const wlr_xdg_toplevel* toplevel = view != nullptr ? view->toplevel() : nullptr; + if (toplevel == nullptr || toplevel->base == nullptr || toplevel->base->client == nullptr) { + return -1; + } + wl_client* client = toplevel->base->client->client; + if (client == nullptr) { + return -1; + } + pid_t pid = -1; + wl_client_get_credentials(client, &pid, nullptr, nullptr); + return pid; + } + + pid_t parentPid(pid_t pid) { + const std::string path = "/proc/" + std::to_string(pid) + "/stat"; + std::ifstream stat(path); + if (!stat.is_open()) { + return -1; + } + std::string line; + std::getline(stat, line); + // comm is parenthesised and may itself contain spaces, so ppid is the + // second field after the closing parenthesis. + const size_t close = line.rfind(')'); + if (close == std::string::npos) { + return -1; + } + std::istringstream rest(line.substr(close + 1)); + std::string state; + pid_t ppid = -1; + if (!(rest >> state >> ppid)) { + return -1; + } + return ppid; + } + + // Off-screen origin a window slides from (and back to), clear of the edge it + // enters through. + constexpr int kSlideClearancePx = 50; + + std::pair slideOffset(std::string_view direction, const wlr_box& area, const wlr_box& target) { + if (direction == "bottom") { + return {target.x, area.y + area.height + kSlideClearancePx}; + } + if (direction == "left") { + return {area.x - target.width - kSlideClearancePx, target.y}; + } + if (direction == "right") { + return {area.x + area.width + kSlideClearancePx, target.y}; + } + return {target.x, area.y - target.height - kSlideClearancePx}; + } + + bool isDescendantOf(pid_t pid, pid_t ancestor) { + for (int depth = 0; depth < kMaxAncestorWalk && pid > 1; ++depth) { + if (pid == ancestor) { + return true; + } + pid = parentPid(pid); + if (pid <= 0) { + break; + } + } + return false; + } + + LayoutConstraints viewLayoutConstraints(const View* view) { + const wlr_xdg_toplevel* toplevel = view != nullptr ? view->toplevel() : nullptr; + const XdgSizeHints hints = xdgSizeHints(toplevel); + return { + .minWidth = hints.minWidth, + .minHeight = hints.minHeight, + .maxWidth = hints.maxWidth, + .maxHeight = hints.maxHeight, + .fullscreen = view != nullptr && view->layoutFullscreen(), + .maximizedToEdges = view != nullptr && view->maximizedToEdges(), + }; + } + } // namespace + ScratchpadManager::ScratchpadManager(Server& server, wlr_scene_tree* root, wlr_scene_tree* shadowRoot) : m_server(&server), m_root(root), m_shadowRoot(shadowRoot) { m_server->registerAnimatable(this); @@ -29,6 +136,20 @@ namespace umbriel { } bool ScratchpadManager::tickAnimations(uint64_t nowMsec) { + // Scratchpad views have no workspace, so the usual per-frame crop refresh (Workspace::syncViewPresentation) + // never runs for them; drive it here instead while a popin zoom is animating. + for (Entry& entry : m_entries) { + if (entry.view != nullptr && entry.view->sizeAnimating()) { + const wlr_box current{ + entry.view->sceneTree()->node.x, + entry.view->sceneTree()->node.y, + entry.view->presentation().width(), + entry.view->presentation().height(), + }; + entry.view->applyPresentation(current); + } + } + bool movedBackdrop = false; for (auto& [output, fade] : m_backdropFades) { if (fade.tick(nowMsec)) { @@ -40,11 +161,25 @@ namespace umbriel { if (m_hidingViews.empty()) { return movedBackdrop; } - std::erase_if(m_hidingViews, [](View* view) { - if (view->presentedOpacity() > 0.002F) { + const auto& spCfg = config().animation.scratchpad; + std::erase_if(m_hidingViews, [this, &spCfg](View* view) { + if (view == nullptr || !view->mapped()) { + return true; + } + if (view->hasActiveAnimations()) { return false; } + view->setFadeAlpha(0.0F); view->setNodeEnabled(false); + std::string_view slotName; + const auto it = std::ranges::find_if(m_entries, [view](const Entry& entry) { return entry.view == view; }); + if (it != m_entries.end()) { + slotName = it->name; + } + const bool suspend = effectiveConfig(slotName).suspendHidden.value_or(spCfg.suspendHidden); + if (suspend) { + safeSetSuspended(view, true); + } return true; }); return movedBackdrop || !m_hidingViews.empty(); @@ -52,7 +187,10 @@ namespace umbriel { bool ScratchpadManager::hasActiveAnimations() const { return !m_hidingViews.empty() - || std::ranges::any_of(m_backdropFades, [](const auto& entry) { return entry.second.animating(); }); + || std::ranges::any_of(m_backdropFades, [](const auto& entry) { return entry.second.animating(); }) + || std::ranges::any_of(m_entries, [](const Entry& entry) { + return entry.view != nullptr && entry.view->hasActiveAnimations(); + }); } bool ScratchpadManager::animatesOn(const Output* output) const { @@ -61,10 +199,8 @@ namespace umbriel { if (fade != m_backdropFades.end() && fade->second.animating()) { return true; } - return std::ranges::any_of(m_hidingViews, [this, output](const View* view) { - return std::ranges::any_of(m_entries, [view, output](const Entry& entry) { - return entry.view == view && entry.output == output; - }); + return std::ranges::any_of(m_entries, [output](const Entry& entry) { + return entry.output == output && entry.view != nullptr && entry.view->hasActiveAnimations(); }); } @@ -72,7 +208,53 @@ namespace umbriel { return std::ranges::any_of(m_entries, [view](const Entry& entry) { return entry.view == view; }); } - bool ScratchpadManager::moveToScratchpad(View* view, Output* output) { + // An open slot stays open while empty, so it can be toggled shut again and so + // an on_empty command has somewhere to land. + bool ScratchpadManager::isSlotVisible(Output* output, std::string_view name) const { + return std::ranges::any_of(m_visibleSlots, [output, name](const VisibleSlot& slot) { + return slot.output == output && slot.name == name; + }); + } + + bool ScratchpadManager::isOutputVisible(Output* output) const { + return std::ranges::any_of(m_visibleSlots, [output](const VisibleSlot& slot) { return slot.output == output; }); + } + + std::optional ScratchpadManager::visibleSlotName(Output* output) const { + const auto slot = std::ranges::find_if(m_visibleSlots, [output](const VisibleSlot& candidate) { + return candidate.output == output; + }); + if (slot == m_visibleSlots.end()) { + return std::nullopt; + } + return slot->name; + } + + // Returned by value because every caller hides slots as it iterates, which + // erases the entries it would otherwise be walking. + std::vector ScratchpadManager::visibleSlotsOn(Output* output) const { + std::vector names; + for (const VisibleSlot& slot : m_visibleSlots) { + if (slot.output == output) { + names.push_back(slot.name); + } + } + return names; + } + + std::vector ScratchpadManager::otherVisibleSlots(Output* output, std::string_view keep) const { + std::vector names = visibleSlotsOn(output); + std::erase(names, keep); + return names; + } + + bool ScratchpadManager::slotHasWindows(Output* output, std::string_view name) const { + return std::ranges::any_of(m_entries, [output, name](const Entry& entry) { + return entry.output == output && entry.name == name && entry.view != nullptr; + }); + } + + bool ScratchpadManager::moveToScratchpad(View* view, Output* output, std::string_view name) { if (output == nullptr || m_server == nullptr || m_root == nullptr || m_shadowRoot == nullptr) { return false; } @@ -83,6 +265,7 @@ namespace umbriel { Entry entry{ .view = view, .output = output, + .name = std::string(name), .returnOutput = {}, .displacedOutput = {}, .displacedPosition = std::nullopt, @@ -101,28 +284,44 @@ namespace umbriel { view->toggleFullscreen(); } if (view->pinned()) { - view->togglePinned(); // unpins and returns to floating/tiled state + view->togglePinned(); } if (view->maximizedToEdges()) { view->setMaximizedToEdges(false); } + if (view->toplevel()->scheduled.maximized || view->toplevel()->current.maximized) { + view->setMaximized(false); + } view->setFloating(true); view->cancelPositionAnimation(); + view->cancelFadeAnimation(); wlr_box targetArea = output->usableArea(); if (targetArea.width <= 0 || targetArea.height <= 0) { wlr_output_layout_get_box(m_server->outputLayout(), output->wlr(), &targetArea); } - const auto& spCfg = config().animation.scratchpad; - if (spCfg.fullscreen) { + const auto slotCfg = effectiveConfig(name); + const bool isScaled = slotCfg.scale.has_value() ? (*slotCfg.scale > 0.0 && *slotCfg.scale <= 1.0) + : (config().animation.scratchpad.scale > 0.0); + if (slotCfg.fullscreen.value_or(false)) { if (!view->toplevel()->scheduled.fullscreen && !view->toplevel()->current.fullscreen) { view->toggleFullscreen(); } - } else if (spCfg.maximize) { - view->toggleMaximizedToEdges(); - } else if (spCfg.scale > 0.0 && spCfg.scale <= 1.0 && targetArea.width > 0 && targetArea.height > 0) { - const int targetW = std::max(100, static_cast(std::lround(targetArea.width * spCfg.scale))); - const int targetH = std::max(100, static_cast(std::lround(targetArea.height * spCfg.scale))); + } else if (slotCfg.maximizeToEdges.value_or(false)) { + view->setMaximizedToEdges(true); + } else if (slotCfg.maximize.value_or(false)) { + const int gap = slotCfg.gap.value_or(config().layoutGap()); + if (gap >= 0 && targetArea.width > 2 * gap && targetArea.height > 2 * gap) { + view->requestFloatingSize(targetArea.width - 2 * gap, targetArea.height - 2 * gap); + view->setPosition(targetArea.x + gap, targetArea.y + gap); + } else { + view->requestFloatingSize(targetArea.width, targetArea.height); + view->setPosition(targetArea.x, targetArea.y); + } + } else if (isScaled && targetArea.width > 0 && targetArea.height > 0) { + const double scale = slotCfg.scale.has_value() ? *slotCfg.scale : config().animation.scratchpad.scale; + const int targetW = std::max(100, static_cast(std::lround(targetArea.width * scale))); + const int targetH = std::max(100, static_cast(std::lround(targetArea.height * scale))); wlr_xdg_toplevel_set_size(view->toplevel(), targetW, targetH); const int newX = targetArea.x + std::max(0, (targetArea.width - targetW) / 2); const int newY = targetArea.y + std::max(0, (targetArea.height - targetH) / 2); @@ -137,106 +336,449 @@ namespace umbriel { wlr_scene_node_reparent(&view->sceneTree()->node, m_root); view->reparentShadow(m_shadowRoot); view->setScratchpadBorder(true); - const bool wasVisible = std::ranges::find(m_visibleOutputs, output) != m_visibleOutputs.end(); + + const bool wasVisible = isSlotVisible(output, name); m_entries.push_back(std::move(entry)); - setVisible(output, wasVisible); + + if (wasVisible) { + arrangeSlot(output, name, true); + } else { + view->setNodeEnabled(false); + view->setOnActiveWorkspace(false); + view->setFadeAlpha(0.0F); + if (config().animation.scratchpad.suspendHidden) { + safeSetSuspended(view, true); + } + } + m_server->refocus(sourceOutput); return true; } + ScratchpadSlotConfig ScratchpadManager::effectiveConfig(std::string_view name) const { + const auto& global = config().animation.scratchpad; + ScratchpadSlotConfig resolved{ + .name = std::string(name), + .scale = global.scale > 0.0 ? std::make_optional(global.scale) : std::nullopt, + .direction = name.empty() ? (global.scale <= 0.0 ? "" : global.direction) + : (global.direction.empty() ? "top" : global.direction), + .style = global.style.empty() ? std::nullopt : std::make_optional(global.style), + .durationMs = global.durationMs, + .curve = global.curve, + .dim = global.dim, + .blur = global.blur, + .maximize = global.maximize, + .maximizeToEdges = global.maximizeToEdges, + .fullscreen = global.fullscreen, + .suspendHidden = global.suspendHidden, + .onEmpty = std::nullopt, + .layout = std::nullopt, + .gap = std::nullopt, + }; + + for (const auto& slotCfg : config().scratchpadRules) { + if (slotCfg.name == name) { + if (slotCfg.scale.has_value()) + resolved.scale = slotCfg.scale; + if (slotCfg.direction.has_value()) + resolved.direction = slotCfg.direction; + if (slotCfg.style.has_value()) + resolved.style = slotCfg.style; + if (slotCfg.durationMs.has_value()) + resolved.durationMs = slotCfg.durationMs; + if (slotCfg.curve.has_value()) + resolved.curve = slotCfg.curve; + if (slotCfg.dim.has_value()) + resolved.dim = slotCfg.dim; + if (slotCfg.blur.has_value()) + resolved.blur = slotCfg.blur; + if (slotCfg.maximize.has_value()) + resolved.maximize = slotCfg.maximize; + if (slotCfg.maximizeToEdges.has_value()) + resolved.maximizeToEdges = slotCfg.maximizeToEdges; + if (slotCfg.fullscreen.has_value()) + resolved.fullscreen = slotCfg.fullscreen; + if (slotCfg.suspendHidden.has_value()) + resolved.suspendHidden = slotCfg.suspendHidden; + if (slotCfg.onEmpty.has_value()) + resolved.onEmpty = slotCfg.onEmpty; + if (slotCfg.layout.has_value()) + resolved.layout = slotCfg.layout; + if (slotCfg.gap.has_value()) + resolved.gap = slotCfg.gap; + break; + } + } + return resolved; + } + + void ScratchpadManager::recordPendingCapture(Output* output, std::string_view slotName, pid_t pid) { + if (pid <= 0) { + return; + } + m_pendingCaptures.push_back( + PendingCapture{ + .output = output, + .slotName = std::string(slotName), + .pid = pid, + .timestamp = std::chrono::steady_clock::now(), + } + ); + } + + std::optional ScratchpadManager::peekPendingCapture(const View* view) const { + if (m_pendingCaptures.empty() || view == nullptr) { + return std::nullopt; + } + const auto now = std::chrono::steady_clock::now(); + const pid_t clientPid = viewPid(view); + if (clientPid <= 0) { + return std::nullopt; + } + const auto match = std::ranges::find_if(m_pendingCaptures, [clientPid, &now](const PendingCapture& pending) { + if (std::chrono::duration_cast(now - pending.timestamp).count() > kCaptureTimeoutSec) { + return false; + } + return isDescendantOf(clientPid, pending.pid); + }); + if (match == m_pendingCaptures.end()) { + return std::nullopt; + } + return *match; + } + + std::optional ScratchpadManager::slotForView(const View* view) const { + const auto it = std::ranges::find_if(m_entries, [view](const Entry& entry) { return entry.view == view; }); + if (it != m_entries.end()) { + return it->name; + } + return std::nullopt; + } + + std::optional ScratchpadManager::consumePendingCapture(const View* view) { + if (m_pendingCaptures.empty() || view == nullptr) { + return std::nullopt; + } + const auto now = std::chrono::steady_clock::now(); + std::erase_if(m_pendingCaptures, [&now](const PendingCapture& pending) { + return std::chrono::duration_cast(now - pending.timestamp).count() > kCaptureTimeoutSec; + }); + + const pid_t clientPid = viewPid(view); + if (clientPid <= 0) { + return std::nullopt; + } + const auto match = std::ranges::find_if(m_pendingCaptures, [clientPid](const PendingCapture& pending) { + return isDescendantOf(clientPid, pending.pid); + }); + if (match == m_pendingCaptures.end()) { + return std::nullopt; + } + PendingCapture capture = std::move(*match); + m_pendingCaptures.erase(match); + return capture; + } + void ScratchpadManager::retargetBackdrop(Output* output, bool visible, bool animateTransition) { if (output == nullptr) { return; } const auto& animation = config().animation; - const auto& scratchpad = animation.scratchpad; auto fadeIt = m_backdropFades.try_emplace(output, 0.0).first; AnimatedValue& backdropFade = fadeIt->second; const double fadeTarget = visible ? 1.0 : 0.0; + + int durationMs = animation.scratchpad.durationMs; + AnimationCurve curve = animation.scratchpad.curve; + for (const auto& slot : m_visibleSlots) { + if (slot.output == output) { + const auto slotCfg = effectiveConfig(slot.name); + durationMs = slotCfg.durationMs.value_or(durationMs); + curve = slotCfg.curve.value_or(curve); + break; + } + } + if (animateTransition && animation.enabled - && scratchpad.enabled && (backdropFade.animating() || backdropFade.current() != fadeTarget)) { - backdropFade.retarget(fadeTarget, scratchpad.durationMs, scratchpad.curve); + backdropFade.retarget(fadeTarget, durationMs, curve); } else { backdropFade.snap(fadeTarget); } updateDimAndBlur(output); } - void ScratchpadManager::setVisible(Output* output, bool visible, bool animateTransition) { - if (output == nullptr) { - return; + ScratchpadManager::SlotLayout& + ScratchpadManager::layoutForSlot(std::string_view name, std::span entries) { + const auto slotCfg = effectiveConfig(name); + ResolvedLayoutConfig wanted = resolveGlobalLayout(config()); + wanted.mode = slotCfg.layout.value_or(wanted.mode); + if (slotCfg.gap.has_value()) { + const int delta = *slotCfg.gap - wanted.gap; + wanted.gap = *slotCfg.gap; + wanted.totalGap += delta; + wanted.edgePad += delta; } - if (visible) { - if (std::ranges::find(m_visibleOutputs, output) == m_visibleOutputs.end()) { - m_visibleOutputs.push_back(output); + + auto [it, inserted] = m_slotLayouts.try_emplace(std::string(name)); + SlotLayout& slot = it->second; + // A mode change has to rebuild the layout; anything else is just new config. + if (slot.layout == nullptr || slot.config.mode != wanted.mode) { + slot.layout = createLayout(wanted.mode); + slot.members.clear(); + } + slot.config = std::move(wanted); + slot.layout->setConfig(&slot.config); + slot.layout->setConstraints(&viewLayoutConstraints); + + std::vector wantedMembers; + wantedMembers.reserve(entries.size()); + for (const Entry* entry : entries) { + if (entry->view != nullptr && entry->view->mapped()) { + wantedMembers.push_back(entry->view); } - } else { - std::erase(m_visibleOutputs, output); + } + for (View* member : slot.members) { + if (std::ranges::find(wantedMembers, member) == wantedMembers.end()) { + slot.layout->removeView(member); + } + } + for (View* member : wantedMembers) { + if (std::ranges::find(slot.members, member) == slot.members.end()) { + slot.layout->insertView(member, static_cast(slot.layout->columns().size())); + } + } + slot.members = std::move(wantedMembers); + return slot; + } + + void ScratchpadManager::arrangeSlot(Output* output, std::string_view name, bool animate) { + if (output == nullptr) { + return; } wlr_box targetArea = output->usableArea(); if (targetArea.width <= 0 || targetArea.height <= 0) { wlr_output_layout_get_box(m_server->outputLayout(), output->wlr(), &targetArea); } + + const auto slotCfg = effectiveConfig(name); const auto& animation = config().animation; - const auto& scratchpad = animation.scratchpad; - const bool animate = animateTransition && animation.enabled && scratchpad.enabled; - retargetBackdrop(output, visible, animateTransition); - for (const Entry& entry : m_entries) { - if (entry.output != output || entry.view == nullptr) { + wlr_box fullOutputBox{}; + wlr_output_layout_get_box(m_server->outputLayout(), output->wlr(), &fullOutputBox); + + int scaledW = 0; + int scaledH = 0; + int baseX = 0; + int baseY = 0; + + const bool isScaled = slotCfg.scale.has_value() ? (*slotCfg.scale > 0.0 && *slotCfg.scale <= 1.0) + : (config().animation.scratchpad.scale > 0.0); + const bool isFullscreen = slotCfg.fullscreen.value_or(animation.scratchpad.fullscreen); + const bool isMaximizeToEdges = slotCfg.maximizeToEdges.value_or(animation.scratchpad.maximizeToEdges); + const bool isMaximize = slotCfg.maximize.value_or(animation.scratchpad.maximize); + const bool preserveFloating = + !isFullscreen && !isMaximizeToEdges && !isMaximize && !isScaled && !slotCfg.layout.has_value(); + + if (isFullscreen || isMaximizeToEdges) { + scaledW = fullOutputBox.width; + scaledH = fullOutputBox.height; + baseX = fullOutputBox.x; + baseY = fullOutputBox.y; + } else if (isMaximize) { + const int gap = slotCfg.gap.value_or(config().layoutGap()); + if (gap >= 0 && targetArea.width > 2 * gap && targetArea.height > 2 * gap) { + scaledW = targetArea.width - 2 * gap; + scaledH = targetArea.height - 2 * gap; + baseX = targetArea.x + gap; + baseY = targetArea.y + gap; + } else { + scaledW = targetArea.width; + scaledH = targetArea.height; + baseX = targetArea.x; + baseY = targetArea.y; + } + } else if (isScaled && targetArea.width > 0 && targetArea.height > 0) { + const double scale = slotCfg.scale.has_value() ? *slotCfg.scale : animation.scratchpad.scale; + scaledW = std::max(100, static_cast(std::lround(targetArea.width * scale))); + scaledH = std::max(100, static_cast(std::lround(targetArea.height * scale))); + baseX = targetArea.x + (targetArea.width - scaledW) / 2; + baseY = targetArea.y + (targetArea.height - scaledH) / 2; + } + + std::vector slotEntries; + for (Entry& entry : m_entries) { + if (entry.output == output && entry.name == name) { + slotEntries.push_back(&entry); + } + } + + const bool visible = isSlotVisible(output, name); + const int durationMs = slotCfg.durationMs.value_or(animation.scratchpad.durationMs); + const AnimationCurve curve = slotCfg.curve.value_or(animation.scratchpad.curve); + const std::string dir = slotCfg.direction.value_or(animation.scratchpad.direction); + // Unscaled slots always fade in place; their geometry is already whatever the + // window had before, so sliding is visually jarring. + const std::string style = preserveFloating + ? "fade" + : slotCfg.style.value_or( + animation.scratchpad.style.empty() ? std::string{"slide"} : animation.scratchpad.style + ); + const bool suspendHidden = slotCfg.suspendHidden.value_or(animation.scratchpad.suspendHidden); + + if (preserveFloating) { + for (Entry* entry : slotEntries) { + View* view = entry->view; + if (view == nullptr || !view->mapped()) { + continue; + } + if (visible) { + view->setOnActiveWorkspace(true); + view->enterForeignOutput(output); + std::erase(m_hidingViews, view); + view->cancelPositionAnimation(); + view->cancelFadeAnimation(); + view->setNodeEnabled(true); + if (suspendHidden) { + safeSetSuspended(view, false); + } + if (animate && animation.enabled) { + view->setFadeAlpha(0.0F); + view->animateFadeTo(1.0F, durationMs, curve); + } else { + view->setFadeAlpha(1.0F); + } + } else { + view->setOnActiveWorkspace(false); + if (animate && animation.enabled) { + view->setNodeEnabled(true); + view->animateFadeTo(0.0F, durationMs, curve); + if (std::ranges::find(m_hidingViews, view) == m_hidingViews.end()) { + m_hidingViews.push_back(view); + } + } else { + view->setFadeAlpha(0.0F); + view->setNodeEnabled(false); + if (suspendHidden) { + safeSetSuspended(view, true); + } + } + } + } + return; + } + + // The slot\x27s own layout tiles inside the scaled box, so a scratchpad obeys + // the same layout rules as a workspace. + const wlr_box slotBox{baseX, baseY, scaledW, scaledH}; + SlotLayout& slotLayout = layoutForSlot(name, slotEntries); + slotLayout.layout->arrange(slotBox); + + for (Entry* entry : slotEntries) { + View* view = entry->view; + if (view == nullptr || !view->mapped()) { continue; } - View* view = entry.view; + const wlr_box target = slotLayout.layout->targetBox(view); + if (target.width <= 0 || target.height <= 0) { + continue; + } + const auto [offX, offY] = slideOffset(dir, targetArea, target); + + constexpr double kPopinScale = 0.8; + const int shrunkW = std::max(1, static_cast(std::lround(target.width * kPopinScale))); + const int shrunkH = std::max(1, static_cast(std::lround(target.height * kPopinScale))); + const wlr_box shrunk{ + target.x + (target.width - shrunkW) / 2, + target.y + (target.height - shrunkH) / 2, + shrunkW, + shrunkH, + }; + const bool popin = style == "popin"; + const bool fadeWithMotion = popin || style == "slidefade"; + + // Request the size before presenting it, so the clip derives from the geometry the client is + // being asked for rather than the one it is about to leave. + if (view->toplevel() != nullptr) { + wlr_xdg_toplevel_set_size(view->toplevel(), target.width, target.height); + } + view->applyPresentation(target); + if (visible) { view->setOnActiveWorkspace(true); - // Workspace-less views normally advertise the pointer's output. A - // scratchpad has an explicit owner, which can move while the pointer - // is still on an output that is being destroyed. view->enterForeignOutput(output); std::erase(m_hidingViews, view); view->cancelPositionAnimation(); + view->cancelFadeAnimation(); view->setNodeEnabled(true); - // Reposition only if the window's center would land off this output's usable area - const int width = view->presentation().width(); - const int height = view->presentation().height(); - if (width > 0 && height > 0) { - const int centerX = view->sceneTree()->node.x + width / 2; - const int centerY = view->sceneTree()->node.y + height / 2; - const bool centerOnTarget = centerX >= targetArea.x - && centerX < targetArea.x + targetArea.width - && centerY >= targetArea.y - && centerY < targetArea.y + targetArea.height; - if (!centerOnTarget) { - const int newX = targetArea.x + std::max(0, (targetArea.width - width) / 2); - const int newY = targetArea.y + std::max(0, (targetArea.height - height) / 2); - view->snapPosition(newX, newY); - } + if (suspendHidden) { + safeSetSuspended(view, false); } - if (animate) { - view->animateFadeTo(1.0F, scratchpad.durationMs, scratchpad.curve); + if (animate && animation.enabled) { + if (fadeWithMotion) { + view->setFadeAlpha(0.0F); + view->animateFadeTo(1.0F, durationMs, curve); + } else { + view->setFadeAlpha(1.0F); + } + if (popin) { + view->beginZoomAnimation(shrunk, target, durationMs, curve); + } else { + view->setPosition(offX, offY); + view->animateTo(target.x, target.y, durationMs, curve); + } } else { view->cancelFadeAnimation(); + view->cancelPositionAnimation(); + view->setPosition(target.x, target.y); + view->setFadeAlpha(1.0F); } } else { view->setOnActiveWorkspace(false); - if (animate) { - // Keep the render tree alive until tickAnimations observes the completed fade. The inactive-workspace flag - // already removes this view from focus and action selection while it is still visible. + if (animate && animation.enabled) { view->setNodeEnabled(true); - view->animateFadeTo(0.0F, scratchpad.durationMs, scratchpad.curve); + if (fadeWithMotion) { + view->animateFadeTo(0.0F, durationMs, curve); + } else { + view->setFadeAlpha(1.0F); + } + if (popin) { + view->beginZoomAnimation(target, shrunk, durationMs, curve); + } else { + view->animateTo(offX, offY, durationMs, curve); + } if (std::ranges::find(m_hidingViews, view) == m_hidingViews.end()) { m_hidingViews.push_back(view); } } else { std::erase(m_hidingViews, view); view->cancelFadeAnimation(); + view->cancelPositionAnimation(); view->setFadeAlpha(0.0F); view->setNodeEnabled(false); + if (suspendHidden) { + safeSetSuspended(view, true); + } } } } + } + + void ScratchpadManager::setSlotVisible(Output* output, std::string_view name, bool visible, bool animateTransition) { + if (output == nullptr) { + return; + } + const VisibleSlot slot{.output = output, .name = std::string(name)}; + if (visible) { + if (std::ranges::find(m_visibleSlots, slot) == m_visibleSlots.end()) { + m_visibleSlots.push_back(slot); + } + } else { + std::erase(m_visibleSlots, slot); + } + retargetBackdrop(output, isOutputVisible(output), animateTransition); + arrangeSlot(output, name, animateTransition); updateDimAndBlur(output); } @@ -269,9 +811,24 @@ namespace umbriel { if (output == nullptr) { return; } - const bool visible = std::ranges::find(m_visibleOutputs, output) != m_visibleOutputs.end(); - const double dim = config().animation.scratchpad.dim; - const bool blurEnabled = config().animation.scratchpad.blur && config().appearance.blur.enabled; + const bool visible = isOutputVisible(output); + double maxDim = 0.0; + bool blurEnabled = false; + for (const auto& slot : m_visibleSlots) { + if (slot.output == output) { + const auto slotCfg = effectiveConfig(slot.name); + if (slotCfg.dim.has_value() && *slotCfg.dim > maxDim) { + maxDim = *slotCfg.dim; + } + if (slotCfg.blur.value_or(false)) { + blurEnabled = true; + } + } + } + if (!config().appearance.blur.enabled) { + blurEnabled = false; + } + const auto fade = m_backdropFades.find(output); const float curAlpha = fade != m_backdropFades.end() ? static_cast(fade->second.current()) : 0.0F; @@ -279,12 +836,12 @@ namespace umbriel { wlr_output_layout_get_box(m_server->outputLayout(), output->wlr(), &box); if (wlr_scene_rect* rect = dimRectFor(output)) { - if ((!visible && curAlpha <= 0.001F) || dim <= 0.0 || curAlpha <= 0.001F) { + if ((!visible && curAlpha <= 0.001F) || maxDim <= 0.0 || curAlpha <= 0.001F) { wlr_scene_node_set_enabled(&rect->node, false); } else { wlr_scene_node_set_position(&rect->node, box.x, box.y); wlr_scene_rect_set_size(rect, box.width, box.height); - const float color[4] = {0.0F, 0.0F, 0.0F, std::clamp(static_cast(dim * curAlpha), 0.0F, 1.0F)}; + const float color[4] = {0.0F, 0.0F, 0.0F, std::clamp(static_cast(maxDim * curAlpha), 0.0F, 1.0F)}; wlr_scene_rect_set_color(rect, color); wlr_scene_node_set_enabled(&rect->node, true); } @@ -304,7 +861,7 @@ namespace umbriel { } void ScratchpadManager::releaseOutput(Output* output) { - std::erase(m_visibleOutputs, output); + std::erase_if(m_visibleSlots, [output](const VisibleSlot& slot) { return slot.output == output; }); m_backdropFades.erase(output); if (const auto it = m_dimRects.find(output); it != m_dimRects.end()) { wlr_scene_node_destroy(&it->second->node); @@ -321,36 +878,71 @@ namespace umbriel { const bool animate = animation.enabled && animation.scratchpad.enabled; if (!animate) { for (auto& [output, fade] : m_backdropFades) { - const bool visible = std::ranges::find(m_visibleOutputs, output) != m_visibleOutputs.end(); - fade.snap(visible ? 1.0 : 0.0); + fade.snap(isOutputVisible(output) ? 1.0 : 0.0); updateDimAndBlur(output); } - for (const Entry& entry : m_entries) { - if (entry.view == nullptr) { - continue; - } - const bool visible = std::ranges::find(m_visibleOutputs, entry.output) != m_visibleOutputs.end(); - entry.view->cancelFadeAnimation(); - entry.view->setFadeAlpha(visible ? 1.0F : 0.0F); - entry.view->setNodeEnabled(visible); + for (const VisibleSlot& slot : m_visibleSlots) { + arrangeSlot(slot.output, slot.name, false); } - m_hidingViews.clear(); - return; - } - for (const auto& entry : m_backdropFades) { - updateDimAndBlur(entry.first); } } - bool ScratchpadManager::toggle(Output* output) { - if (output == nullptr - || std::ranges::none_of(m_entries, [output](const Entry& entry) { return entry.output == output; })) { + bool ScratchpadManager::toggle(Output* output, std::string_view name) { + if (output == nullptr) { return false; } - const bool show = std::ranges::find(m_visibleOutputs, output) == m_visibleOutputs.end(); - setVisible(output, show); + + if (!slotHasWindows(output, name)) { + const auto slotCfg = effectiveConfig(name); + if (isSlotVisible(output, name)) { + setSlotVisible(output, name, false); + m_server->refocus(output); + } else { + for (const std::string& oldName : otherVisibleSlots(output, name)) { + setSlotVisible(output, oldName, false); + } + setSlotVisible(output, name, true); + if (slotCfg.onEmpty.has_value() && !slotCfg.onEmpty->empty()) { + recordPendingCapture(output, name, m_server->spawn(slotCfg.onEmpty->c_str())); + } + } + return true; + } + + if (!name.empty()) { + Output* currentOwner = nullptr; + for (const Entry& entry : m_entries) { + if (entry.name == name) { + currentOwner = entry.output; + break; + } + } + + if (currentOwner != nullptr && currentOwner != output && isSlotVisible(currentOwner, name)) { + setSlotVisible(currentOwner, name, false); + for (Entry& entry : m_entries) { + if (entry.name == name) { + entry.output = output; + } + } + setSlotVisible(output, name, true); + if (View* view = focused(output, name)) { + m_server->focusView(view); + } + return true; + } + } + + const bool show = !isSlotVisible(output, name); + if (show) { + // At most one slot is visible per output; dismiss any other open slot. + for (const std::string& oldName : otherVisibleSlots(output, name)) { + setSlotVisible(output, oldName, false); + } + } + setSlotVisible(output, name, show); if (show) { - if (View* view = focused(output)) { + if (View* view = focused(output, name)) { m_server->focusView(view); } } else { @@ -360,24 +952,38 @@ namespace umbriel { } void ScratchpadManager::hideAll() { - const std::vector visibleOutputs = m_visibleOutputs; - for (Output* output : visibleOutputs) { - setVisible(output, false, false); + const std::vector visibleSlots = m_visibleSlots; + for (const auto& slot : visibleSlots) { + setSlotVisible(slot.output, slot.name, false, false); } } - View* ScratchpadManager::focused(Output* output) const { - if (std::ranges::find(m_visibleOutputs, output) == m_visibleOutputs.end()) { + View* ScratchpadManager::focused(Output* output, std::string_view name) const { + std::string slotName(name); + if (slotName.empty()) { + if (auto openSlot = visibleSlotName(output)) { + slotName = *openSlot; + } + } + if (!isSlotVisible(output, slotName)) { return nullptr; } - const auto remembered = std::ranges::find_if(m_entries, [output](const Entry& entry) { - return entry.output == output && entry.lastFocused; + if (m_focusedView != nullptr) { + const auto isCur = std::ranges::find_if(m_entries, [this, output, &slotName](const Entry& entry) { + return entry.output == output && entry.name == slotName && entry.view == m_focusedView; + }); + if (isCur != m_entries.end()) { + return m_focusedView; + } + } + const auto remembered = std::ranges::find_if(m_entries, [output, &slotName](const Entry& entry) { + return entry.output == output && entry.name == slotName && entry.lastFocused; }); if (remembered != m_entries.end()) { return remembered->view; } for (const Entry& entry : m_entries) { - if (entry.output == output) { + if (entry.output == output && entry.name == slotName) { return entry.view; } } @@ -385,25 +991,26 @@ namespace umbriel { } bool ScratchpadManager::hasFocus(Output* output) const { - if (m_focusedView == nullptr || std::ranges::find(m_visibleOutputs, output) == m_visibleOutputs.end()) { + if (m_focusedView == nullptr || !isOutputVisible(output)) { return false; } return std::ranges::any_of(m_entries, [this, output](const Entry& entry) { - return entry.view == m_focusedView && entry.output == output; + return entry.view == m_focusedView && entry.output == output && isSlotVisible(output, entry.name); }); } void ScratchpadManager::noteFocus(View* view) { m_focusedView = nullptr; - const auto focused = std::ranges::find_if(m_entries, [view](const Entry& entry) { return entry.view == view; }); - if (focused == m_entries.end()) { + const auto focusedEntry = + std::ranges::find_if(m_entries, [view](const Entry& entry) { return entry.view == view; }); + if (focusedEntry == m_entries.end()) { return; } m_focusedView = view; for (Entry& entry : m_entries) { - if (entry.output == focused->output) { - entry.lastFocused = entry.view == view; + if (entry.output == focusedEntry->output && entry.name == focusedEntry->name) { + entry.lastFocused = (entry.view == view); } } } @@ -414,10 +1021,12 @@ namespace umbriel { return; } Output* previous = it->output; + std::string slotName = it->name; if (output != nullptr) { it->output = output; - if (std::ranges::find(m_visibleOutputs, output) == m_visibleOutputs.end()) { - m_visibleOutputs.push_back(output); + const VisibleSlot slot{.output = output, .name = slotName}; + if (std::ranges::find(m_visibleSlots, slot) == m_visibleSlots.end()) { + m_visibleSlots.push_back(slot); } } if (previous != it->output) { @@ -426,14 +1035,15 @@ namespace umbriel { } if (previous != it->output && it->lastFocused) { for (Entry& entry : m_entries) { - if (&entry != &*it && entry.output == it->output) { + if (&entry != &*it && entry.output == it->output && entry.name == slotName) { entry.lastFocused = false; } } } - if (previous != it->output - && std::ranges::none_of(m_entries, [previous](const Entry& entry) { return entry.output == previous; })) { - std::erase(m_visibleOutputs, previous); + if (previous != it->output && std::ranges::none_of(m_entries, [previous, &slotName](const Entry& entry) { + return entry.output == previous && entry.name == slotName; + })) { + std::erase(m_visibleSlots, VisibleSlot{.output = previous, .name = slotName}); } restorePresentation(view); } @@ -451,28 +1061,41 @@ namespace umbriel { view->setNodeEnabled(true); } - bool ScratchpadManager::focusNext(Output* output) { - if (output == nullptr || std::ranges::find(m_visibleOutputs, output) == m_visibleOutputs.end()) { + bool ScratchpadManager::focusNext(Output* output, std::string_view name) { + if (output == nullptr || !isSlotVisible(output, name)) { return false; } - std::vector views; - for (const Entry& entry : m_entries) { - if (entry.output == output && entry.view != nullptr && entry.view->mapped()) { - views.push_back(entry.view); + std::vector visible; + for (Entry& entry : m_entries) { + if (entry.output == output && entry.name == name) { + visible.push_back(&entry); } } - if (views.empty()) { + if (visible.empty()) { return false; } - View* current = focused(output); - const auto it = std::ranges::find(views, current); - View* target = it == views.end() || std::next(it) == views.end() ? views.front() : *std::next(it); - m_server->focusView(target, FocusReason::Directional); + if (visible.size() == 1) { + m_server->focusView(visible.front()->view); + return true; + } + + size_t currentIndex = 0; + for (size_t i = 0; i < visible.size(); ++i) { + if (visible[i]->view == m_focusedView) { + currentIndex = i; + break; + } + } + const size_t nextIndex = (currentIndex + 1) % visible.size(); + m_server->focusView(visible[nextIndex]->view); return true; } - bool ScratchpadManager::restoreFocused(Output* output) { - View* view = focused(output); + bool ScratchpadManager::restoreFocused(Output* output, std::string_view name) { + if (output == nullptr) { + return false; + } + View* view = focused(output, name); if (view == nullptr) { return false; } @@ -482,9 +1105,10 @@ namespace umbriel { } Entry entry = std::move(*it); m_entries.erase(it); - if (std::ranges::none_of(m_entries, [output](const Entry& e) { return e.output == output; })) { - std::erase(m_visibleOutputs, output); - retargetBackdrop(output, false); + if (!slotHasWindows(output, name)) { + setSlotVisible(output, name, false); + } else if (isSlotVisible(output, name)) { + arrangeSlot(output, name, true); } if (m_focusedView == view) { m_focusedView = nullptr; @@ -504,7 +1128,7 @@ namespace umbriel { view->setScratchpadBorder(false); view->moveToWorkspace(workspace, false); if (entry.returnTiled) { - view->setFloating(false); + view->setFloating(false, true, /*preserveSize=*/true); } else { view->setFloating(true); if (restoreOutput != nullptr && restoreOutput != output) { @@ -533,6 +1157,7 @@ namespace umbriel { return; } Output* entryOutput = entry->output; + std::string slotName = entry->name; view->reparentShadow(nullptr); view->setScratchpadBorder(false); if (m_focusedView == view) { @@ -540,10 +1165,29 @@ namespace umbriel { } std::erase(m_hidingViews, view); m_entries.erase(entry); - if (entryOutput != nullptr - && std::ranges::none_of(m_entries, [entryOutput](const Entry& e) { return e.output == entryOutput; })) { - std::erase(m_visibleOutputs, entryOutput); - retargetBackdrop(entryOutput, false); + if (entryOutput != nullptr) { + pruneSlot(entryOutput, slotName); + if (isSlotVisible(entryOutput, slotName)) { + arrangeSlot(entryOutput, slotName, true); + } + } + } + + // A slot that is hidden and empty has nothing left to address, so it is + // dropped. A visible one stays: toggling it shut is still meaningful, and + // on_empty may be about to fill it. + void ScratchpadManager::pruneSlot(Output* output, std::string_view name) { + if (output == nullptr || slotHasWindows(output, name) || isSlotVisible(output, name)) { + return; + } + std::erase(m_visibleSlots, VisibleSlot{.output = output, .name = std::string(name)}); + // The name can still be in use on another output after a drag, and that + // slot\x27s layout state is worth keeping. + if (std::ranges::none_of(m_entries, [name](const Entry& e) { return e.name == name; })) { + m_slotLayouts.erase(std::string(name)); + } + if (!isOutputVisible(output)) { + retargetBackdrop(output, false); } } @@ -551,19 +1195,9 @@ namespace umbriel { if (from == to) { return; } - const bool movedRemembered = std::ranges::any_of(m_entries, [from](const Entry& entry) { - return entry.output == from && entry.lastFocused; - }); - if (movedRemembered && to != nullptr) { - for (Entry& entry : m_entries) { - if (entry.output == to) { - entry.lastFocused = false; - } - } - } - const bool wasVisible = std::ranges::find(m_visibleOutputs, from) != m_visibleOutputs.end(); - if (wasVisible) { - setVisible(from, false); + const std::vector wasVisibleSlots = visibleSlotsOn(from); + for (const std::string& slotName : wasVisibleSlots) { + setSlotVisible(from, slotName, false); } for (Entry& entry : m_entries) { if (entry.output == from) { @@ -599,8 +1233,10 @@ namespace umbriel { } } } - if (wasVisible && to != nullptr) { - setVisible(to, true); + if (!wasVisibleSlots.empty() && to != nullptr) { + for (const std::string& slotName : wasVisibleSlots) { + setSlotVisible(to, slotName, true); + } } } @@ -628,23 +1264,24 @@ namespace umbriel { continue; } Output* source = entry.output; - const bool wasVisible = - source != nullptr && std::ranges::find(m_visibleOutputs, source) != m_visibleOutputs.end(); + const bool wasVisible = source != nullptr && isSlotVisible(source, entry.name); if (entry.lastFocused) { for (Entry& candidate : m_entries) { - if (&candidate != &entry && candidate.output == target) { + if (&candidate != &entry && candidate.output == target && candidate.name == entry.name) { candidate.lastFocused = false; } } } entry.output = target; if (wasVisible) { - if (std::ranges::none_of(m_entries, [source](const Entry& candidate) { return candidate.output == source; })) { - setVisible(source, false); + if (std::ranges::none_of(m_entries, [source, &entry](const Entry& candidate) { + return candidate.output == source && candidate.name == entry.name; + })) { + setSlotVisible(source, entry.name, false); } - setVisible(target, true); + setSlotVisible(target, entry.name, true); } else if (entry.view != nullptr) { - const bool visible = std::ranges::find(m_visibleOutputs, target) != m_visibleOutputs.end(); + const bool visible = isSlotVisible(target, entry.name); entry.view->setOnActiveWorkspace(visible); entry.view->setNodeEnabled(visible); } diff --git a/src/workspace/scratchpad.h b/src/workspace/scratchpad.h index 0c129ece..70765a6f 100644 --- a/src/workspace/scratchpad.h +++ b/src/workspace/scratchpad.h @@ -1,10 +1,16 @@ #pragma once +#include "config/config.h" #include "core/animation.h" +#include "layout/layout.h" #include +#include +#include #include +#include #include +#include #include #include @@ -18,8 +24,17 @@ namespace umbriel { class Server; class View; class Workspace; + struct ScratchpadSlotConfig; + class ScratchpadManager : public Animatable { public: + struct PendingCapture { + Output* output = nullptr; + std::string slotName; + pid_t pid = -1; + std::chrono::steady_clock::time_point timestamp; + }; + ScratchpadManager(Server& server, wlr_scene_tree* root, wlr_scene_tree* shadowRoot); ~ScratchpadManager() override; @@ -29,12 +44,26 @@ namespace umbriel { [[nodiscard]] bool animatesOn(const Output* output) const override; [[nodiscard]] bool contains(const View* view) const; - [[nodiscard]] bool moveToScratchpad(View* view, Output* output); - bool toggle(Output* output); + [[nodiscard]] bool isSlotVisible(Output* output, std::string_view name = "") const; + [[nodiscard]] bool isOutputVisible(Output* output) const; + [[nodiscard]] bool slotHasWindows(Output* output, std::string_view name = "") const; + // Name of the slot currently shown on `output`, or nullopt when none is. + [[nodiscard]] std::optional visibleSlotName(Output* output) const; + [[nodiscard]] ScratchpadSlotConfig effectiveConfig(std::string_view name = "") const; + void recordPendingCapture(Output* output, std::string_view slotName, pid_t pid); + // Checks if `view` descends from a pending on_empty spawn without consuming it. + [[nodiscard]] std::optional peekPendingCapture(const View* view) const; + // Claims `view` only when its client descends from a pending on_empty spawn. + [[nodiscard]] std::optional consumePendingCapture(const View* view); + [[nodiscard]] std::optional slotForView(const View* view) const; + void arrangeSlot(Output* output, std::string_view name, bool animate); + + [[nodiscard]] bool moveToScratchpad(View* view, Output* output, std::string_view name = ""); + bool toggle(Output* output, std::string_view name = ""); void hideAll(); - bool restoreFocused(Output* output); - bool focusNext(Output* output); - [[nodiscard]] View* focused(Output* output) const; + bool restoreFocused(Output* output, std::string_view name = ""); + bool focusNext(Output* output, std::string_view name = ""); + [[nodiscard]] View* focused(Output* output, std::string_view name = "") const; [[nodiscard]] bool hasFocus(Output* output) const; void noteFocus(View* view); void finishMove(View* view, Output* output); @@ -51,6 +80,7 @@ namespace umbriel { struct Entry { View* view = nullptr; Output* output = nullptr; + std::string name; // slot name ("" or "special" for default, or "term", "music", etc.) std::string returnOutput; std::string displacedOutput; // Full-output-relative x/y fractions retained until the displaced output returns. @@ -60,8 +90,28 @@ namespace umbriel { bool lastFocused = false; }; - void setVisible(Output* output, bool visible, bool animateTransition = true); - // Retarget the backdrop dim/blur fade for `output` and refresh its scene nodes. + struct VisibleSlot { + Output* output = nullptr; + std::string name; + bool operator==(const VisibleSlot&) const = default; + }; + + // A slot tiles with the same layout engine a workspace uses, so windows + // parked in a scratchpad keep the arrangement rules the user configured. + struct SlotLayout { + std::unique_ptr layout; + ResolvedLayoutConfig config; + std::vector members; + }; + + // Sync `slot`'s layout membership with its entries and arrange it into `box`. + SlotLayout& layoutForSlot(std::string_view name, std::span entries); + + [[nodiscard]] std::vector visibleSlotsOn(Output* output) const; + [[nodiscard]] std::vector otherVisibleSlots(Output* output, std::string_view keep) const; + // Drop a slot that is both hidden and empty. + void pruneSlot(Output* output, std::string_view name); + void setSlotVisible(Output* output, std::string_view name, bool visible, bool animateTransition = true); void retargetBackdrop(Output* output, bool visible, bool animateTransition = true); wlr_scene_rect* dimRectFor(Output* output); wlr_scene_blur* blurNodeFor(Output* output); @@ -71,13 +121,17 @@ namespace umbriel { wlr_scene_tree* m_root = nullptr; wlr_scene_tree* m_shadowRoot = nullptr; std::vector m_entries; - std::vector m_visibleOutputs; - // Views mid fade-out on hide, still enabled until tickAnimations disables the node once the fade completes. + std::vector m_visibleSlots; + // Views mid animation on hide, still enabled until tickAnimations disables the node. std::vector m_hidingViews; std::unordered_map m_dimRects; std::unordered_map m_blurNodes; std::unordered_map m_backdropFades; View* m_focusedView = nullptr; + std::vector m_pendingCaptures; + // One layout per slot name. A slot lives on a single output at a time, so + // the name alone identifies it. + std::unordered_map m_slotLayouts; }; } // namespace umbriel diff --git a/src/workspace/workspace.cpp b/src/workspace/workspace.cpp index 4ea24391..c77177a0 100644 --- a/src/workspace/workspace.cpp +++ b/src/workspace/workspace.cpp @@ -288,7 +288,7 @@ namespace umbriel { return focusedColumn >= 0 ? focusedColumn + 1 : static_cast(m_layout->columns().size()); } - void Workspace::layoutAttach(View* view, std::optional initialWidth) { + void Workspace::layoutAttach(View* view, std::optional initialWidth, bool preserveSize) { if (view == nullptr || !view->mapped() || !view->tiled() || m_layout->columnOf(view) >= 0) { return; } @@ -307,7 +307,7 @@ namespace umbriel { if (scrolling != nullptr && !placement) { const int column = scrolling->columnOf(view); const std::optional configuredWidth = - initialWidth ? initialWidth : m_layoutConfig.scrolling.defaultWidthFraction; + preserveSize ? std::nullopt : (initialWidth ? initialWidth : m_layoutConfig.scrolling.defaultWidthFraction); if (configuredWidth) { // default_width is a viewport fraction: scrolling only. Dwindle ignores it. scrolling->setWidthFraction(column, *configuredWidth); diff --git a/src/workspace/workspace.h b/src/workspace/workspace.h index 85aa781f..fe79acf6 100644 --- a/src/workspace/workspace.h +++ b/src/workspace/workspace.h @@ -92,7 +92,7 @@ namespace umbriel { void restackFloatingViews(); void addView(View* view, bool attachToLayout = true); View* removeView(View* view, bool reconcile = true); - void layoutAttach(View* view, std::optional initialWidth = std::nullopt); + void layoutAttach(View* view, std::optional initialWidth = std::nullopt, bool preserveSize = false); // Predict the first configure by applying the same insertion and full-width // transition that the mapped path will use on the authoritative layout. [[nodiscard]] Layout::InitialSize initialMaximizedSize(View* view, const wlr_box& usable) const; diff --git a/tests/unit/config_load.cpp b/tests/unit/config_load.cpp index 8e25187d..ed9eaec4 100644 --- a/tests/unit/config_load.cpp +++ b/tests/unit/config_load.cpp @@ -1427,6 +1427,113 @@ lid_open = "notify-send awake" CHECK(store.diagnostics().empty()); } +UMBRIEL_TEST(parsesScratchpadSlotRules) { + TempConfig file; + file.write(R"( +[[scratchpad]] +name = "special:music" +scale = 0.65 +direction = "right" +duration_ms = 350 +dim = 0.5 +blur = true +layout = "master" +gap = 16 +on_empty = "spotify" + +[[scratchpad]] +name = "notes" +on_created_empty = "notes-app" +suspend_hidden = false +)"); + + ConfigStore& store = umbriel::configStore(); + store.setRootPath(file.path(), true); + const umbriel::ConfigReloadResult result = store.reload(); + const auto& slots = store.config().scratchpadRules; + + CHECK(result.success); + CHECK_EQ(slots.size(), size_t{2}); + // "special:" is stripped so special-workspace-style names address the same slot. + CHECK_EQ(slots[0].name, std::string{"music"}); + CHECK_EQ(slots[0].scale.value_or(0.0), 0.65); + CHECK_EQ(slots[0].direction.value_or(""), std::string{"right"}); + CHECK_EQ(slots[0].durationMs.value_or(0), 350); + CHECK_EQ(slots[0].dim.value_or(0.0), 0.5); + CHECK(slots[0].blur.value_or(false)); + CHECK(slots[0].layout.has_value() && *slots[0].layout == umbriel::LayoutMode::Master); + CHECK_EQ(slots[0].gap.value_or(0), 16); + CHECK_EQ(slots[0].onEmpty.value_or(""), std::string{"spotify"}); + + CHECK_EQ(slots[1].name, std::string{"notes"}); + CHECK_EQ(slots[1].onEmpty.value_or(""), std::string{"notes-app"}); + CHECK(!slots[1].suspendHidden.value_or(true)); + CHECK(!slots[1].layout.has_value()); +} + +UMBRIEL_TEST(parsesScratchpadWindowRules) { + TempConfig file; + file.write(R"( +[[window_rule]] +match.app_id = "spotify" +default_scratchpad = "music" + +[[window_rule]] +match.title = ".*term.*" +scratchpad = "special:terminal" +)"); + + ConfigStore& store = umbriel::configStore(); + store.setRootPath(file.path(), true); + const umbriel::ConfigReloadResult result = store.reload(); + const auto& rules = store.config().windowRules; + + CHECK(result.success); + CHECK_EQ(rules.size(), size_t{2}); + CHECK(rules[0].defaultScratchpad.has_value()); + CHECK_EQ(*rules[0].defaultScratchpad, std::string{"music"}); + CHECK(rules[1].defaultScratchpad.has_value()); + CHECK_EQ(*rules[1].defaultScratchpad, std::string{"terminal"}); +} + +UMBRIEL_TEST(rejectsBadScratchpadSlotValues) { + TempConfig file; + file.write(R"( +[animation.scratchpad] +direction = "sideways" + +[[scratchpad]] +name = "ok" +direction = "diagonal" +scale = 4.0 +duration_ms = 99999 +gap = "wide" +layout = "tiling" + +[[scratchpad]] +scale = 0.5 +)"); + + ConfigStore& store = umbriel::configStore(); + store.setRootPath(file.path(), true); + const umbriel::ConfigReloadResult result = store.reload(); + + CHECK(result.success); + CHECK(containsDiagnostic(store, "unknown animation.scratchpad.direction")); + CHECK(containsDiagnostic(store, "unknown scratchpad.direction")); + CHECK(containsDiagnostic(store, "scratchpad.scale = 4")); + CHECK(containsDiagnostic(store, "scratchpad.duration_ms = 99999")); + CHECK(containsDiagnostic(store, "ignoring scratchpad.gap")); + CHECK(containsDiagnostic(store, "unknown scratchpad.layout")); + // The entry without a name is dropped, so only the named one survives. + CHECK(containsDiagnostic(store, "missing 'name' string")); + CHECK_EQ(store.config().scratchpadRules.size(), size_t{1}); + // Rejected values leave the slot on the global default. + CHECK(!store.config().scratchpadRules[0].direction.has_value()); + CHECK(!store.config().scratchpadRules[0].scale.has_value()); + CHECK_EQ(store.config().animation.scratchpad.direction, std::string{"top"}); +} + UMBRIEL_TEST(packagedAnimationDefaultsMatchCompiledDefaults) { std::filesystem::path root = std::filesystem::current_path(); while (!std::filesystem::exists(root / "examples/config.toml")) { diff --git a/tests/unit/keybind_parse.cpp b/tests/unit/keybind_parse.cpp index 064e76df..c3abbace 100644 --- a/tests/unit/keybind_parse.cpp +++ b/tests/unit/keybind_parse.cpp @@ -481,34 +481,50 @@ UMBRIEL_TEST(parsesOptionalOutputActions) { return arg != nullptr ? arg->output : std::string{}; }; + Keybind bind; + CHECK(parseAction("dpms-off", bind)); + CHECK(bind.action == KeybindAction::DpmsOff); + CHECK(outputOf(bind).empty()); + CHECK(parseAction("dpms-off:DP-1", bind)); + CHECK_EQ(outputOf(bind), std::string{"DP-1"}); + CHECK(parseAction("dpms-on", bind)); + CHECK(bind.action == KeybindAction::DpmsOn); + CHECK(parseAction("dpms-on:eDP-1", bind)); + CHECK_EQ(outputOf(bind), std::string{"eDP-1"}); +} + +UMBRIEL_TEST(parsesOptionalScratchpadActions) { + const auto scratchpadOf = [](const Keybind& bind) { + const auto* arg = umbriel::payloadIf(bind); + return arg != nullptr ? *arg : umbriel::ScratchpadArg{}; + }; + Keybind bind; CHECK(parseAction("scratchpad-toggle", bind)); CHECK(bind.action == KeybindAction::ScratchpadToggle); - // The alternative is present even with no output, so the payload still says - // which action shape it belongs to. - CHECK(umbriel::payloadIf(bind) != nullptr); - CHECK(outputOf(bind).empty()); + CHECK(umbriel::payloadIf(bind) != nullptr); + CHECK(scratchpadOf(bind).name.empty()); + CHECK(scratchpadOf(bind).output.empty()); + + CHECK(parseAction("scratchpad-toggle:music", bind)); + CHECK_EQ(scratchpadOf(bind).name, std::string{"music"}); CHECK(parseAction("scratchpad-toggle:DP-2", bind)); - CHECK_EQ(outputOf(bind), std::string{"DP-2"}); + CHECK_EQ(scratchpadOf(bind).name, std::string{"DP-2"}); + + CHECK(parseAction("scratchpad-toggle:term/DP-2", bind)); + CHECK_EQ(scratchpadOf(bind).name, std::string{"term"}); + CHECK_EQ(scratchpadOf(bind).output, std::string{"DP-2"}); CHECK(parseAction("window-move-to-scratchpad", bind)); CHECK(bind.action == KeybindAction::WindowMoveToScratchpad); + CHECK(parseAction("window-move-to-scratchpad-silent:music", bind)); + CHECK(bind.action == KeybindAction::WindowMoveToScratchpadSilent); + CHECK_EQ(scratchpadOf(bind).name, std::string{"music"}); CHECK(parseAction("window-restore-from-scratchpad:eDP-1", bind)); - CHECK_EQ(outputOf(bind), std::string{"eDP-1"}); CHECK(parseAction("window-toggle-scratchpad", bind)); CHECK(bind.action == KeybindAction::WindowToggleScratchpad); CHECK(parseAction("scratchpad-focus-next", bind)); - - CHECK(parseAction("dpms-off", bind)); - CHECK(bind.action == KeybindAction::DpmsOff); - CHECK(outputOf(bind).empty()); - CHECK(parseAction("dpms-off:DP-1", bind)); - CHECK_EQ(outputOf(bind), std::string{"DP-1"}); - CHECK(parseAction("dpms-on", bind)); - CHECK(bind.action == KeybindAction::DpmsOn); - CHECK(parseAction("dpms-on:eDP-1", bind)); - CHECK_EQ(outputOf(bind), std::string{"eDP-1"}); } UMBRIEL_TEST(parsesWindowIdActions) { @@ -535,6 +551,7 @@ UMBRIEL_TEST(payloadAlternativeMatchesTheDeclaredArgKind) { switch (spec.argKind) { case ActionArgKind::None: case ActionArgKind::OptionalOutput: + case ActionArgKind::OptionalScratchpad: case ActionArgKind::OptionalWindowId: case ActionArgKind::SkipConfirmation: break; @@ -582,6 +599,9 @@ UMBRIEL_TEST(payloadAlternativeMatchesTheDeclaredArgKind) { case ActionArgKind::OptionalOutput: CHECK(umbriel::payloadIf(bind) != nullptr); break; + case ActionArgKind::OptionalScratchpad: + CHECK(umbriel::payloadIf(bind) != nullptr); + break; case ActionArgKind::WindowId: case ActionArgKind::OptionalWindowId: CHECK(umbriel::payloadIf(bind) != nullptr); @@ -616,6 +636,7 @@ UMBRIEL_TEST(everyActionSpecRoundTripsThroughParseAction) { switch (spec.argKind) { case ActionArgKind::None: case ActionArgKind::OptionalOutput: + case ActionArgKind::OptionalScratchpad: case ActionArgKind::OptionalWindowId: case ActionArgKind::SkipConfirmation: break;