diff --git a/book/src/tiling.md b/book/src/tiling.md index 650cd73a..2ff61d5e 100644 --- a/book/src/tiling.md +++ b/book/src/tiling.md @@ -77,6 +77,20 @@ You can also right-click any title in a container to toggle mono mode. In mono mode, scroll over the title bar to cycle between windows in the container. +## Autotiling + +Autotiling makes newly tiled windows alternate split direction from the focused +tiled window. The first split uses the containing group direction, then later +windows wrap the focused tile in the opposite direction, producing a horizontal, +vertical, horizontal pattern as the layout grows. + +```toml +[shortcuts] +alt-a = "toggle-autotile" +``` + +Manual grouping and split commands still use the direction you request. + ## Fullscreen Press `alt-u` (`toggle-fullscreen`) to make the focused window fill the entire diff --git a/docs/window-animations-plan.md b/docs/window-animations-plan.md deleted file mode 100644 index 7cb60d03..00000000 --- a/docs/window-animations-plan.md +++ /dev/null @@ -1,313 +0,0 @@ -# Window Animations Plan - -This document is the working plan for Wry's window animation system. It records -the decisions already made, the implementation phases, and the risks that must -be handled deliberately. - -## Accepted Decisions - -- The first landed slice is plain interpolation only, disabled by default. -- Animation is presentation-only. Logical layout, input hit testing, focus, and - Wayland configure state use final geometry immediately. -- Pointer drag and resize initiated by the mouse or tablet do not animate. -- Plain animations restart only for windows whose destination changes. Other - in-flight windows keep their existing timelines. -- Spawn-in uses scale and position for newly mapped tiled and floating app - windows. Layer-shell, overlay, override-redirect, and fullscreen surfaces do - not use this path. Spawn-out uses retained visual content after the live node - is gone, when a stable retained surface tree can be captured before unmap or - destroy. -- Command-driven tile-to-float and float-to-tile transitions may animate. - Protocol drag/drop paths do not. -- The no-overlap multiphase system is a separate phase after the plain path is - working and testable. -- Content freezing will use retained per-surface texture references, not a full - offscreen snapshot as the default design. -- Retained records should keep using the existing renderer behavior for now, - including clipping and edge stretch/clamp behavior for undersized contents. A - dedicated retained-tree scaling path is deferred to a later polish phase. -- The multiphase animation concept is original to Wry. Hy3 is relevant only as - partial inspiration for tiling style and titlebar/grouping behavior. -- Mono mode should mostly avoid animations. Exceptions are windows entering or - exiting mono mode, where a visual transition can clarify the hierarchy change. -- Multiphase shrink steps should not normally need to reduce a tiled window far - below roughly one quarter of the relevant full size. The implementation may - enforce a conservative sanity minimum, and pathological cases may fall back. -- If the no-overlap planner cannot produce a legal sequence, only the affected - group should fall back to plain animation. This is expected to be rare for - valid tiling layouts. -- When entering mono mode, the active child should animate to the mono geometry. - Inactive siblings may snap invisible. Floats may overlap normally and do not - need the no-overlap planner. - -## Texture Freezing Decision - -The approved freezing design is to capture a renderable surface tree at animation -start: - -- texture references -- source sample rects -- target sizes -- alpha and color metadata -- subsurface offsets and stacking order -- enough synchronization/release state to keep referenced buffers alive safely - -This is lighter than rendering every toplevel into a compositor-owned offscreen -texture, and it should handle normal GPU-backed windows without an extra full -window copy. It also gives spawn-out a path: capture the surface tree before the -toplevel is logically destroyed, then animate the retained records after the live -node is gone. - -Tradeoffs: - -- Retained references can delay buffer release. For dmabuf clients this can - increase memory pressure or throttle clients if many large windows animate. -- SHM buffers still matter for simple clients, fallback paths, some utilities, - and cursor-like surfaces. They are probably not the common case for large app - windows, but the implementation must still treat SHM texture flipping as a - correctness issue. -- The release/sync contract must be explicit. A retained texture must not be - released back to the client while the compositor may still render it. -- True offscreen snapshots remain a possible fallback for cases where retained - references cannot safely preserve the rendered content. - -## Phase 1: Linear Presentation Animations - -Goal: add the smallest correct animation layer without changing layout semantics. - -Implementation shape: - -- Add animation state owned by `State`. -- Track per-toplevel animation entries keyed by `NodeId`. -- Store logical target rect, current presentation rect, previous damaged rect, - start time, duration, and curve. -- On command-driven tiled layout geometry changes, animate from current - presentation rect to new final rect. -- On interruption, restart only the affected window from its current - presentation rect. -- Drive frames from the existing output latch/presentation event flow. -- Damage the union of previous presentation rect, current presentation rect, and - final logical rect. - -Initial scope: - -- Tiled reflow animation. -- Floating command-driven moves and resizes are animated. Pointer and tablet - drag/resize paths still snap directly to the live cursor position. -- Cross-output and cross-scale movements snap for now. -- Linear mode may overlap windows during swaps. That is expected for the classic - interpolation mode; no-overlap is Phase 3. -- Live client buffers are rendered in Phase 1. Retained content freezing is - deferred, but animated windows must still be clipped to their presentation - bounds and must preserve the existing stretch behavior for undersized contents. -- Spawn-out is retained-content-only. If the surface cannot be retained safely - the window snaps out instead of animating an empty frame. -- No multiphase no-overlap planner. - -Tests: - -- rect interpolation is direction-independent -- interruption restarts only changed windows -- unchanged in-flight windows keep their original timeline -- drag-driven floating movement bypasses animation -- damage includes old, current, and final rects -- command-driven tile-to-float and float-to-tile transitions use linear motion -- command-driven floating moves and resizes animate without affecting pointer - drag/resize behavior -- pointer/header double-click unfloat bypasses the command-animation gate - -## Phase 2: Retained Texture Freezing - -Goal: freeze visual contents during movement and enable spawn-out. - -Initial retained-record implementation status: - -- Tiled animation can retain GPU/dmabuf-backed XDG and Xwayland surface trees. -- Spawn-in animation can retain GPU/dmabuf-backed XDG and Xwayland surface trees - for both tiled windows and floating child contents. -- Tile-to-float and float-to-tile transitions retain GPU/dmabuf-backed child - contents while the presentation geometry changes. -- Spawn-out captures retained app-window contents before XDG/Xwayland unmap or - destroy, then renders a detached shrinking presentation record until the - animation completes. -- Retained records hold both `GfxTexture` and `SurfaceBuffer` references so the - existing buffer release/sync path remains authoritative. -- Single-pixel buffers can be retained as color records. -- Retained records render through the same texture and stretch/clamp paths used - by live surfaces. This is the expected Phase 2 behavior. -- Async SHM textures are not retained yet because Wry's per-surface SHM - front/back textures can be reused by later commits while an animation is still - running. Those surfaces fall back to live rendering until an explicit offscreen - copy fallback exists. - -Implementation shape: - -- Add a retained render-record tree for toplevel surfaces. -- Capture records before movement animations that require freezing. -- Capture records before destroy/unmap for spawn-out. -- Render retained records through the normal renderer primitives where possible. -- Extend event/sync handling so retained buffers remain valid until the animation - is complete. - -Deferred/future polish: - -- Whether retained records participate in frame callbacks or presentation - feedback. Default assumption: no, because they are compositor animation frames, - not client commits. -- How to fall back when a buffer cannot be safely retained. -- A distinct retained-tree scaling render path for true spawn-in/spawn-out - content scaling. If added, start with retained GPU-backed records only, keep - the animated frame as the clip boundary, and avoid live SHM scaling until there - is an explicit snapshot/copy fallback. - -## Phase 3: Multiphase No-Overlap Animations - -Goal: implement Wry's staged no-overlap planner while preserving the rule that -windows never overlap. - -Manual verification checklist: `docs/window-animations-testing.md`. - -Core rules: - -- Each phase is a discrete animation using the full curve. -- A phase performs only one action kind per window: move or scale. -- Movement and scaling are split by axis. -- No diagonal motion. Mixed-action phases may combine different per-window - actions, but no single window may move or resize on more than one axis in one - step. -- A window or synchronized group owns its own timeline. -- New layout changes interrupt only windows/groups with changed destinations. -- Current hierarchy and target hierarchy both matter. The planner must know - whether a window is ascending toward a higher-level/toplevel position, - descending into a container, or moving between containers at the same depth. -- If some child windows require fewer phases than their parent/container - context, parent/container-space changes generally happen first so space exists - before the child moves into it. This rule can be overridden only when the - non-overlap invariant still clearly holds. -- Windows that become peers in the target hierarchy may synchronize later - phases even if they were not peers in the source hierarchy. - -Important parent/child synchronization issue: - -The planner must not let a parent container and child window animate independent -axes at the same time in a way that violates the visual rules. For example, a -parent scaling horizontally while a child scales vertically can accidentally -produce a diagonal or multi-axis motion in screen space. - -Preferred approach: - -- Plan in terms of leaf toplevel visual rectangles first. -- Treat containers as constraints and grouping boundaries, not as independently - animated visual actors. -- Derive every leaf's per-phase rect from one phase schedule so parent and child - effects cannot compose into forbidden motion. -- Build the planner as pure geometry first. Live integration should collect - eligible leaf `(old, new)` rects across a command-driven layout pass, then - submit planner-produced phases as a batch. Per-node `tl_change_extents` calls - are too incremental to plan safely by themselves. -- Add container-level grouping only after the leaf planner proves correct. -- Include hierarchy-transition metadata in the planner input: source parent, - target parent, source depth, target depth, and whether the window is ascending, - descending, or staying at the same hierarchy level. -- For mono containers, suppress ordinary in-mono focus/tab changes. Animate only - transitions into mono, out of mono, or across the mono boundary. -- When entering mono, the active child animates to the full mono area and - inactive siblings snap invisible. When exiting mono, ordinary tiled geometry - may animate from the mono child where that produces a clear hierarchy - transition. -- If a legal no-overlap sequence cannot be found for a group, fall back to the - linear animator for that group only. Float windows are outside this invariant. - -Current pure planner status: - -- Two-window same-axis swaps use shrink lanes, move, then grow. -- Swap lane choice follows motion direction, not node identity: right/down - moving windows take the first lane, and left/up moving windows take the second - lane. -- Stack extraction/return patterns are covered in both horizontal and vertical - orientations: peer/container space scales first, the extracted child moves - only after space exists, and orthogonal growth happens in the final phase. -- Same-axis size redistribution is handled as a single scale phase when the - exact validator proves adjacent windows stay non-overlapping. -- Nested size redistribution can use hierarchy metadata to decompose two-axis - resizing into parent-axis then child-axis scale phases, but only when the - source/target ancestor split depths give a deterministic order. -- A phase can carry mixed per-window actions when each window still performs one - classified move/scale on one axis and the exact validator proves the combined - phase is non-overlapping. -- Every produced plan is checked analytically for overlap over the full duration - of each phase before it is accepted. This solves the linear edge inequalities - for each pair of moving rectangles instead of relying on sampled frames. -- Live layout batches are partitioned by overlapping motion bounds, so unrelated - groups can still use multiphase animation when another group falls back to - linear motion. -- Planner requests now carry per-window hierarchy metadata for source/target - parent, depth, sibling index, split axis, nearest ancestor split depth per - axis, mono state, and transition kind. The current planner uses this for - parent-before-child scale ordering, but not yet for full nested move planning. -- Multiphase planning has a diagnostic entry point used by live fallback logs. - It distinguishes request validation errors, missing patterns, shrink-bound - rejections, invalid phase steps, and exact validation failures such as stale - starts or phase overlap. -- Multiphase planning also has an explained-plan entry point. Accepted plans - report the deterministic strategy, phase reasons, participating nodes, and - validation result; rejected plans report every attempted strategy and failure. -- Rejection diagnostics are treated as contractual test output for unsupported - patterns and analytically invalid candidate plans, including attempted strategy - order and exact validation failure. -- Planner tests now include a deterministic split-tree generator. It builds - valid weighted tiling layouts, derives leaf hierarchy metadata, mutates them - through supported transitions, and runs the real planner plus exact validator. -- The generated tests also include a bounded corpus of supported split-tree - transitions across both axes and directions. Each case is planned twice and - compared exactly to catch nondeterministic planner output. - -Tests: - -- horizontal swaps shrink, move, then grow without overlap -- extraction from a stack creates space before moving the extracted window -- nested size redistribution scales the parent axis before the child axis -- nested containers do not produce simultaneous cross-axis motion -- interruption restarts only affected phase groups -- reversing direction produces equivalent motion in reverse -- accepted and rejected plans expose deterministic strategy explanations -- bounded generated split-tree corpus produces identical plans on repeated runs -- unsupported and invalid candidate plans produce exact expected diagnostics -- mixed-action phases are accepted only under exact continuous validation -- diagonal per-window motion remains a hard rejection even inside mixed phases -- child waits for parent/container-space phases when moving upward into a - toplevel peer position -- mono-mode tab switches do not animate, while entering/exiting mono can animate - -## Configuration - -Phase 1 should expose a disabled-by-default setting for: - -- enabled/disabled -- duration -- style: `plain` or `multiphase` -- curve preset or cubic bezier - -Initial TOML shape: - -```toml -[animations] -enabled = false -duration-ms = 160 -style = "multiphase" -curve = "ease-out" -# or: -curve = [0.25, 0.1, 0.25, 1.0] -``` - -Bezier curves are analyzed when configuration is applied and stored as a -piecewise curve that is cheap to evaluate during rendering. Custom curves use -CSS cubic-bezier semantics: `(0, 0)` and `(1, 1)` are implicit, while the four -configured numbers are `x1`, `y1`, `x2`, and `y2`. The x control points must be -between `0` and `1`. - -## Existing Note - -`docs/animation-integration.md` appears to document a prior animation attempt -whose `src/animation/` implementation is not present in this checkout. Treat -this plan as the current source of truth until implementation docs are updated. diff --git a/docs/window-animations-testing.md b/docs/window-animations-testing.md deleted file mode 100644 index f5bdd416..00000000 --- a/docs/window-animations-testing.md +++ /dev/null @@ -1,493 +0,0 @@ -# Window Animations Manual Test Plan - -This is the manual verification checklist for Wry's animation work. Use it after -building a test compositor and booting into a normal graphical session. - -The goal is to catch visual, synchronization, damage, and retained-content -problems that unit tests cannot prove from geometry alone. - -## Setup - -- Build and install the `codex-anims-next` branch. -- Start with animations enabled and a deliberately slow duration, around - `400-700ms`, so phase ordering and damage artifacts are visible. -- Test at least one normal Wayland/XDG app, one Xwayland app if available, and - one fast-updating app such as a terminal running output, a browser animation, - a video, or a GL/Vulkan demo. -- Use visible gaps, borders, titlebars, and rounding. These make clipping and - damage mistakes much easier to see. -- If available, test on both a single-output setup and a multi-output setup. -- If logging is convenient, run with debug logging and keep any multiphase - fallback messages. A fallback is useful evidence, not automatically a bug. - -Relevant internal config hooks: - -- `SetAnimationsEnabled` -- `SetAnimationDurationMs` -- `SetAnimationStyle` -- `SetAnimationCurve` -- `SetAnimationCubicBezier` - -TOML example: - -```toml -[animations] -enabled = true -duration-ms = 600 -style = "multiphase" -curve = "ease-out" -``` - -Set `style = "plain"` to force ordinary one-step movement interpolation while -keeping the configured curve. `curve = "linear"` only changes easing; it does -not select the plain animation style. - -Current curve IDs in code: - -- `0`: linear -- `1`: CSS `ease` -- `2`: CSS `ease-in` -- `3` or any other unrecognized value: CSS `ease-out` -- `4`: CSS `ease-in-out` - -## Enabling Multiphase Tests - -To exercise the multiphase planner: - -1. Enable animations with `SetAnimationsEnabled`. -2. Set a slow duration with `SetAnimationDurationMs`, around `400-700ms`. -3. Select `style = "multiphase"` in TOML, or call `SetAnimationStyle` with - `AnimationStyle::MULTIPHASE`. -4. Use tiled layout commands that are wired through `State::with_layout_animations`. -5. Use layouts where at least two tiled windows change geometry in the same - container layout batch. - -The compositor then attempts multiphase planning automatically when the batched -layout pass completes. If the planner proves a legal no-overlap sequence, that -group uses phased animation. If it cannot prove one, only that motion group falls -back to ordinary plain animation. - -Good command families for multiphase testing: - -- seat/window move in a tiled direction -- split changes -- tab/group operations -- group-opposite changes -- equalize -- move-tab -- mono enter/exit -- command-driven window resize - -These paths should not be used as evidence of multiphase behavior: - -- tile-to-float and float-to-tile, which deliberately use plain animation -- command-driven floating move/resize, which may animate but can overlap -- pointer or tablet drag/resize, which should not animate -- spawn-in and spawn-out, which are single-phase and use the configured curve -- cross-output or cross-scale movement, which should snap -- layer-shell, overlay, override-redirect, and fullscreen map/unmap paths - -Useful debug signal: - -- `falling back to plain layout animation for group ...` means the group entered - the multiphase gate but the planner rejected it. That is acceptable for - unsupported patterns, but unexpected for the supported swap/extraction cases - below. - -## Pass Criteria - -A test passes when: - -- layout, focus, hit testing, and configure behavior use the final logical - geometry immediately -- visible presentation motion is smooth and bounded by the animated frame -- no old pixels, trails, black strips, transparent holes, or stale titlebar - fragments remain after motion -- tiled multiphase movement never overlaps and never moves a single window - diagonally during a phase -- interruption starts changed windows from their current visual rect without - restarting unaffected windows -- drag-driven pointer movement remains direct and does not lag behind the cursor -- cross-output or cross-scale movements snap instead of animating - -Record a failure with: - -- the layout before and after -- whether the window was tiled, floating, mono, XDG, Xwayland, or layer-shell -- whether the app was GPU/dmabuf-backed or likely SHM, if known -- animation duration and curve -- whether the failure was visual overlap, diagonal motion, debris, clipping, - stale content, a missing retained frame, or an incorrect animation trigger - -## Known Current Limits - -These are acceptable unless they produce worse behavior than described: - -- Spawn-out is retained-content-only. If the surface cannot be retained safely, - it should snap out rather than animate an empty frame. -- Async SHM surfaces are not retained yet. GPU/dmabuf-backed app windows are the - primary retained-content path for this phase. -- A dedicated retained-tree scaling/offscreen fallback is deferred. Retained - records currently render through the normal texture and stretch/clamp paths. -- Floats may overlap. The no-overlap invariant is for tiled multiphase motion. -- Linear fallback may overlap. This should be rare for valid tiled layouts, and - the fallback should be scoped to the affected motion group. -- Cross-output and cross-scale movements should not animate yet. - -## 1. Basic Enable/Disable - -1. Disable animations. -2. Move, resize, spawn, close, and toggle floating on a few windows. -3. Confirm all affected windows snap with no delayed presentation state. -4. Enable animations at a slow duration. -5. Repeat the same operations and confirm only eligible paths animate. -6. Disable animations while an animation is in flight. - -Expected: - -- disabling animations clears any in-flight visual state -- no stale damage remains after disabling -- newly enabled animations use the configured duration and curve - -## 2. Spawn-In - -Test newly mapped windows: - -- tiled XDG window -- floating XDG window -- Xwayland window, if available -- fullscreen window -- layer-shell or overlay surface, such as a bar, launcher, menu, notification, - or lock/overlay component, if available - -Expected: - -- newly mapped tiled and floating app windows animate in -- layer-shell, overlay, override-redirect, and fullscreen surfaces do not use - the app-window spawn-in path -- contents stay clipped to the animated frame -- if contents are smaller than the frame during the animation, no empty strips - are visible - -## 3. Spawn-Out - -Close windows from these states: - -- tiled app window -- floating app window -- Xwayland app window -- fast-updating app window -- a likely SHM/simple app, if available - -Expected: - -- retained app content shrinks out after the live node is gone -- there is no black, transparent, or unfilled moving rectangle -- if content cannot be retained, the window snaps out cleanly -- neighboring tiled windows reflow without debris left in the old area - -Hard failure: - -- a destroyed window leaves a moving empty frame -- the last frame shows unrelated newer content -- screen debris remains after the animation completes - -## 4. Linear Tiled Reflow - -Use a slow duration and a non-linear curve, then repeat with linear. - -Cases: - -- open two tiled windows and change split ratio by command -- open three tiled windows and resize the active split repeatedly -- move focus and issue command-driven swaps -- interrupt a resize by issuing another resize before the first completes -- create a layout that forces a linear fallback if possible - -Expected: - -- final layout is usable immediately -- changed windows animate from their current visual rect on interruption -- unaffected windows keep their existing timeline -- linear fallback is visually smooth, even if overlap occurs -- no pointer drag path becomes animated - -## 5. Float Movement And Tile/Float Transitions - -Cases: - -- command-toggle a tiled window to floating -- command-toggle the same window back to tiled -- command-move and command-resize a floating window -- mouse-drag a floating window -- mouse-resize a floating window -- double-click/header pointer path if that is part of the local workflow - -Expected: - -- command-driven tile-to-float and float-to-tile transitions animate linearly -- command-driven floating move/resize animates -- mouse or tablet drag/resize remains direct and tracks the pointer -- pointer/header paths that are intentionally outside the command-animation gate - do not unexpectedly use delayed animation -- retained child content remains clipped during tile/float transitions - -## 6. Multiphase Horizontal And Vertical Swaps - -Horizontal: - -1. Create two horizontally adjacent tiled windows. -2. Swap their positions. -3. Reverse the swap. - -Vertical: - -1. Create two vertically adjacent tiled windows. -2. Swap their positions. -3. Reverse the swap. - -Expected: - -- first phase shrinks into lanes on the orthogonal axis -- second phase moves only horizontally or only vertically -- third phase grows out of lanes -- no phase overlaps windows -- no window moves diagonally -- reverse direction uses the same visual logic in reverse -- titlebars, borders, gaps, and rounded corners remain respected - -## 7. Stack Extraction And Return - -Build this shape: - -```text -[ A | [ B - C ] ] -``` - -Then move `B` out so the target is: - -```text -[ A | B | C ] -``` - -Reverse the operation by putting `B` back into the stack. - -Expected: - -- peer/container space opens first -- `B` waits until there is a legal horizontal or vertical lane -- `B` moves on one axis only -- `B` and the affected peer grow together in the final phase when appropriate -- reversing the operation is visually equivalent in reverse - -## 8. Nested Parent/Child Synchronization - -Create nested split layouts where a parent changes one axis and children change -the other. Use both horizontal-parent/vertical-child and -vertical-parent/horizontal-child variants. - -Expected: - -- parent/container-space axis changes happen before child-axis changes when the - hierarchy metadata gives a deterministic order -- child windows do not visually compose parent and child transforms into - diagonal motion -- any unsupported group falls back as a group rather than partially violating the - one-axis rule - -Hard failure: - -- a child visibly changes width and height in the same phase -- a child moves diagonally because parent and child animation compound -- a child clips outside its animated frame - -## 9. Mixed-Action Phases - -This case is easiest to prove with the planner tests because Wry does not yet -have a confirmed stock command that reliably creates a same-batch move for one -tiled window and an independent resize for another. The canonical geometry is: - -```text -start: A = (0,0)-(80,80) B = (200,0)-(280,80) -target: A = (40,0)-(120,80) B = (200,0)-(280,120) -``` - -`A` moves horizontally while `B` scales vertically. The windows are far enough -apart that the mixed phase is provably non-overlapping. - -To exercise the current proof directly, run: - -```sh -cargo test animation::multiphase::tests::mixed_single_phase_accepts_move_and_scale_when_proven -``` - -For visual/manual testing, the target shape is: - -```text -before: [ A ] [ B ] -after: [ A ] [ B ] - [ ] -``` - -`A` must move horizontally without resizing. `B` must resize vertically without -moving. The two motion bounds must remain separate for the whole animation. If a -normal command sequence cannot produce that in one layout batch, treat the unit -test as the authority and record the visual test as not applicable rather than a -failure. - -Expected: - -- mixed phases are allowed only when each individual window performs one legal - move or scale on one axis -- no individual window moves diagonally -- no overlap occurs at any point during the phase - -A fallback here is acceptable if no normal user command can create this geometry; -the planner test above is the authority for the mixed-action rule. - -## 10. Mono Mode - -Cases: - -- enter mono mode with several siblings -- exit mono mode -- switch active tabs/windows inside mono -- move a window into mono -- move a window out of mono - -Expected: - -- entering/exiting mono may animate where it clarifies hierarchy change -- active child animates to the mono geometry -- inactive siblings snap invisible -- ordinary tab switches inside mono do not animate -- no hidden inactive sibling leaves debris or stale retained content - -## 11. Interruption And Retargeting - -Use a long duration, then issue commands mid-animation: - -- swap, then reverse before completion -- resize, then resize in the other direction before completion -- build `[A | [B | C | D]]`, move `C` left to form `[A | C | [B | D]]`, - then move `C` back into the stack before completion -- start a multiphase group, then change only one window's destination if a - command sequence allows it - -Expected: - -- affected windows restart from their current visual rect -- unaffected windows do not restart if their destination is unchanged -- a new valid multiphase plan replaces the old plan cleanly -- retained content remains the same frozen content during the retarget -- damage covers old, current, and new visual regions - -## 12. Damage And Clipping Stress - -Use a high-contrast wallpaper/background and high contrast window contents. - -Cases: - -- fast repeated swaps -- repeated spawn-in/spawn-out -- rounded corners with large gaps -- titlebar-heavy layouts -- resize while a terminal is rapidly updating -- move/resize over another window's old location -- run on different output scales if available - -Expected: - -- no trails remain in gaps, borders, or titlebar strips -- rounded corners do not reveal old pixels outside the frame -- contents never draw outside the animated bounds -- final frame exactly matches the steady layout - -## 13. Texture Freezing - -Use fast-updating contents so freezing is obvious. - -Cases: - -- tiled GPU/dmabuf-backed app during reflow -- floating GPU/dmabuf-backed app during command move/resize -- tile-to-float and float-to-tile with dynamic content -- spawn-out with dynamic content -- likely SHM/simple app, if available - -Expected: - -- retained GPU/dmabuf-backed windows freeze visually during animation -- spawn-out uses the last retained content, not a blank or unrelated frame -- undersized contents stretch/clamp to avoid unfilled frame regions -- SHM/unretained surfaces either render live safely or snap where retention is - required - -Record separately: - -- content continues updating during movement -- content freezes but samples the wrong source region -- edges show empty/black strips while scaling -- spawn-out skips because capture was unavailable - -## 14. Cross-Output And Scale Boundaries - -Cases: - -- move a tiled window to another output -- move a floating window to another output -- move between outputs with different scale factors, if available -- move a workspace between outputs, if supported locally - -Expected: - -- movement snaps instead of animating -- no retained content is rendered at the wrong scale -- no stale damage remains on the source output -- destination output renders the final layout immediately - -## 15. Regression Sweep - -After visual tests, return to normal animation duration and curve. - -Repeat: - -- ordinary tiling navigation -- workspace switching -- fullscreen enter/exit -- focus changes -- app launch/close loops -- suspend/resume or VT switch if convenient - -Expected: - -- animation state does not survive across unrelated compositor state changes -- no stuck retained frames -- no persistent high CPU/GPU use after animations stop -- no obvious client throttling after many retained-content animations - -## Summary Result Template - -```text -Commit: -Build: -Outputs/scales: -GPU/session: -Animation config: - -Passed: -- - -Known-limit observations: -- - -Failures: -- case: - app: - layout: - expected: - actual: - reproducible: - logs: -``` diff --git a/jay-config/src/_private/client.rs b/jay-config/src/_private/client.rs index 71927bbc..151e7591 100644 --- a/jay-config/src/_private/client.rs +++ b/jay-config/src/_private/client.rs @@ -640,6 +640,22 @@ impl ConfigClient { self.send(&ClientMessage::SetWindowWorkspace { window, workspace }); } + pub fn seat_send_to_scratchpad(&self, seat: Seat, name: &str) { + self.send(&ClientMessage::SeatSendToScratchpad { seat, name }); + } + + pub fn seat_toggle_scratchpad(&self, seat: Seat, name: &str) { + self.send(&ClientMessage::SeatToggleScratchpad { seat, name }); + } + + pub fn seat_cycle_scratchpad(&self, seat: Seat, name: &str) { + self.send(&ClientMessage::SeatCycleScratchpad { seat, name }); + } + + pub fn window_send_to_scratchpad(&self, window: Window, name: &str) { + self.send(&ClientMessage::WindowSendToScratchpad { window, name }); + } + pub fn seat_split(&self, seat: Seat) -> Axis { let res = self.send_with_response(&ClientMessage::GetSeatSplit { seat }); get_response!(res, Axis::Horizontal, GetSplit { axis }); @@ -2079,6 +2095,12 @@ impl ConfigClient { self.send(&ClientMessage::SetAutotile { enabled }); } + pub fn get_autotile(&self) -> bool { + let res = self.send_with_response(&ClientMessage::GetAutotile); + get_response!(res, false, GetAutotile { enabled }); + enabled + } + pub fn set_tab_title_align(&self, align: u32) { self.send(&ClientMessage::SetTabTitleAlign { align }); } diff --git a/jay-config/src/_private/ipc.rs b/jay-config/src/_private/ipc.rs index e86e79ca..743acc57 100644 --- a/jay-config/src/_private/ipc.rs +++ b/jay-config/src/_private/ipc.rs @@ -286,6 +286,18 @@ pub enum ClientMessage<'a> { seat: Seat, workspace: Workspace, }, + SeatSendToScratchpad { + seat: Seat, + name: &'a str, + }, + SeatToggleScratchpad { + seat: Seat, + name: &'a str, + }, + SeatCycleScratchpad { + seat: Seat, + name: &'a str, + }, GetTimer { name: &'a str, }, @@ -687,6 +699,10 @@ pub enum ClientMessage<'a> { window: Window, workspace: Workspace, }, + WindowSendToScratchpad { + window: Window, + name: &'a str, + }, SetWindowFullscreen { window: Window, fullscreen: bool, @@ -923,6 +939,7 @@ pub enum ClientMessage<'a> { SetAutotile { enabled: bool, }, + GetAutotile, SetTabTitleAlign { align: u32, }, @@ -1189,6 +1206,9 @@ pub enum Response { GetCornerRadius { radius: f32, }, + GetAutotile { + enabled: bool, + }, } #[derive(Serialize, Deserialize, Debug)] diff --git a/jay-config/src/input.rs b/jay-config/src/input.rs index dbdef1ba..450597e2 100644 --- a/jay-config/src/input.rs +++ b/jay-config/src/input.rs @@ -466,6 +466,33 @@ impl Seat { get!().set_seat_workspace(self, workspace) } + /// Sends the currently focused window to a scratchpad. + /// + /// Use an empty string for the default scratchpad. + pub fn send_to_scratchpad(self, name: &str) { + get!().seat_send_to_scratchpad(self, name) + } + + /// Toggles a scratchpad. + /// + /// If the scratchpad has a visible window, that window is hidden. Otherwise, the + /// most recently hidden window in the scratchpad is shown on the current workspace. + /// Scratchpad windows are always shown floating. + /// Use an empty string for the default scratchpad. + pub fn toggle_scratchpad(self, name: &str) { + get!().seat_toggle_scratchpad(self, name) + } + + /// Cycles through the windows of a scratchpad, one at a time. + /// + /// With nothing shown, the first window is brought up; each further invocation + /// hides the current window and shows the next; after the last window the + /// scratchpad is hidden again. Scratchpad windows are always shown floating. + /// Use an empty string for the default scratchpad. + pub fn cycle_scratchpad(self, name: &str) { + get!().seat_cycle_scratchpad(self, name) + } + /// Toggles whether the currently focused window is fullscreen. pub fn toggle_fullscreen(self) { let c = get!(); diff --git a/jay-config/src/lib.rs b/jay-config/src/lib.rs index c95c6620..fff94506 100644 --- a/jay-config/src/lib.rs +++ b/jay-config/src/lib.rs @@ -453,14 +453,21 @@ pub fn get_corner_radius() -> f32 { /// Enables or disables autotiling. /// -/// When enabled, new windows are automatically placed in a perpendicular -/// sub-container if the predicted body would be narrower than tall (or vice versa). +/// When enabled, newly tiled windows alternate split orientation from the +/// focused tiled window: the first split uses the containing group's direction, +/// then subsequent splits wrap the focused window in the perpendicular +/// direction. /// /// The default is `false`. pub fn set_autotile(enabled: bool) { get!().set_autotile(enabled) } +/// Returns whether autotiling is enabled. +pub fn get_autotile() -> bool { + get!(false).get_autotile() +} + /// Sets the horizontal alignment of title text within tab buttons. /// /// - `"start"` — left-aligned (default) diff --git a/jay-config/src/window.rs b/jay-config/src/window.rs index 662cda44..96e4d3b1 100644 --- a/jay-config/src/window.rs +++ b/jay-config/src/window.rs @@ -205,6 +205,13 @@ impl Window { get!().set_window_workspace(self, workspace) } + /// Sends the window to a scratchpad. + /// + /// Use an empty string for the default scratchpad. + pub fn send_to_scratchpad(self, name: &str) { + get!().window_send_to_scratchpad(self, name) + } + /// Toggles whether the currently focused window is fullscreen. pub fn toggle_fullscreen(self) { self.set_fullscreen(!self.fullscreen()) diff --git a/src/compositor.rs b/src/compositor.rs index 11f23808..4dd47342 100644 --- a/src/compositor.rs +++ b/src/compositor.rs @@ -403,6 +403,7 @@ fn start_compositor2( bo_drop_queue: Rc::new(ObjectDropQueue::new(&ring)), virtual_outputs: Default::default(), clean_logs_older_than: Default::default(), + scratchpads: Default::default(), }); state.tracker.register(ClientId::from_raw(0)); create_dummy_output(&state); diff --git a/src/config/handler.rs b/src/config/handler.rs index 336da9ff..68ea93f5 100644 --- a/src/config/handler.rs +++ b/src/config/handler.rs @@ -1100,6 +1100,32 @@ impl ConfigProxyHandler { Ok(()) } + fn handle_seat_send_to_scratchpad(&self, seat: Seat, name: &str) -> Result<(), CphError> { + self.state.with_linear_layout_animations(|| { + let seat = self.get_seat(seat)?; + if let Some(toplevel) = seat.get_keyboard_node().node_toplevel() { + self.state.send_to_scratchpad(name, toplevel); + } + Ok(()) + }) + } + + fn handle_seat_toggle_scratchpad(&self, seat: Seat, name: &str) -> Result<(), CphError> { + self.state.with_linear_layout_animations(|| { + let seat = self.get_seat(seat)?; + self.state.toggle_scratchpad(&seat, name); + Ok(()) + }) + } + + fn handle_seat_cycle_scratchpad(&self, seat: Seat, name: &str) -> Result<(), CphError> { + self.state.with_linear_layout_animations(|| { + let seat = self.get_seat(seat)?; + self.state.cycle_scratchpad(&seat, name); + Ok(()) + }) + } + fn handle_set_window_workspace(&self, window: Window, ws: Workspace) -> Result<(), CphError> { let window = self.get_window(window)?; let name = self.get_workspace(ws)?; @@ -1114,6 +1140,14 @@ impl ConfigProxyHandler { Ok(()) } + fn handle_window_send_to_scratchpad(&self, window: Window, name: &str) -> Result<(), CphError> { + self.state.with_linear_layout_animations(|| { + let window = self.get_window(window)?; + self.state.send_to_scratchpad(name, window); + Ok(()) + }) + } + fn handle_get_device_name(&self, device: InputDevice) -> Result<(), CphError> { let dev = self.get_device_handler_data(device)?; let name = dev.device.name(); @@ -2989,6 +3023,15 @@ impl ConfigProxyHandler { ClientMessage::SetSeatWorkspace { seat, workspace } => self .handle_set_seat_workspace(seat, workspace) .wrn("set_seat_workspace")?, + ClientMessage::SeatSendToScratchpad { seat, name } => self + .handle_seat_send_to_scratchpad(seat, name) + .wrn("seat_send_to_scratchpad")?, + ClientMessage::SeatToggleScratchpad { seat, name } => self + .handle_seat_toggle_scratchpad(seat, name) + .wrn("seat_toggle_scratchpad")?, + ClientMessage::SeatCycleScratchpad { seat, name } => self + .handle_seat_cycle_scratchpad(seat, name) + .wrn("seat_cycle_scratchpad")?, ClientMessage::GetConnector { ty, idx } => { self.handle_get_connector(ty, idx).wrn("get_connector")? } @@ -3373,6 +3416,9 @@ impl ConfigProxyHandler { ClientMessage::SetWindowWorkspace { window, workspace } => self .handle_set_window_workspace(window, workspace) .wrn("set_window_workspace")?, + ClientMessage::WindowSendToScratchpad { window, name } => self + .handle_window_send_to_scratchpad(window, name) + .wrn("window_send_to_scratchpad")?, ClientMessage::SetWindowFullscreen { window, fullscreen } => self .handle_set_window_fullscreen(window, fullscreen) .wrn("set_window_fullscreen")?, @@ -3587,6 +3633,11 @@ impl ConfigProxyHandler { ClientMessage::SetAutotile { enabled } => { self.state.theme.autotile_enabled.set(enabled); } + ClientMessage::GetAutotile => { + self.respond(Response::GetAutotile { + enabled: self.state.theme.autotile_enabled.get(), + }); + } ClientMessage::SeatToggleExpand { .. } => { // Removed feature; kept for binary protocol compatibility. } diff --git a/src/ifs/wl_surface.rs b/src/ifs/wl_surface.rs index 547b7e2a..4224e727 100644 --- a/src/ifs/wl_surface.rs +++ b/src/ifs/wl_surface.rs @@ -1520,25 +1520,25 @@ impl WlSurface { let bounds = self.toplevel.get().and_then(|tl| tl.tl_render_bounds()); let pos = self.buffer_abs_pos.get(); let apply_damage = |pos: Rect| { - if pending.damage_full { - let mut damage = pos; + let clip_damage = |mut damage: Rect| { + damage = damage.intersect(pos); if let Some(bounds) = bounds { damage = damage.intersect(bounds); } - self.client.state.damage(damage); + damage + }; + if pending.damage_full { + self.client.state.damage(clip_damage(pos)); } else { let matrix = self.damage_matrix.get(); if let Some(buffer) = self.buffer.get() { for damage in &pending.buffer_damage { - let mut damage = matrix.apply( + let damage = matrix.apply( pos.x1(), pos.y1(), damage.intersect(buffer.buffer.buf.rect), ); - if let Some(bounds) = bounds { - damage = damage.intersect(bounds); - } - self.client.state.damage(damage); + self.client.state.damage(clip_damage(damage)); } } for damage in &pending.surface_damage { @@ -1550,8 +1550,7 @@ impl WlSurface { let y2 = (damage.y2() + scale - 1) / scale; damage = Rect::new_saturating(x1, y1, x2, y2); } - damage = damage.intersect(bounds.unwrap_or(pos)); - self.client.state.damage(damage); + self.client.state.damage(clip_damage(damage)); } } }; diff --git a/src/it/test_config.rs b/src/it/test_config.rs index 56ee5272..8cb39935 100644 --- a/src/it/test_config.rs +++ b/src/it/test_config.rs @@ -284,6 +284,27 @@ impl TestConfig { }) } + pub fn send_to_scratchpad(&self, seat: SeatId, name: &str) -> TestResult { + self.send(ClientMessage::SeatSendToScratchpad { + seat: Seat(seat.raw() as _), + name, + }) + } + + pub fn toggle_scratchpad(&self, seat: SeatId, name: &str) -> TestResult { + self.send(ClientMessage::SeatToggleScratchpad { + seat: Seat(seat.raw() as _), + name, + }) + } + + pub fn cycle_scratchpad(&self, seat: SeatId, name: &str) -> TestResult { + self.send(ClientMessage::SeatCycleScratchpad { + seat: Seat(seat.raw() as _), + name, + }) + } + fn clear(&self) { unsafe { if let Some(srv) = self.srv.take() { @@ -331,6 +352,10 @@ impl TestConfig { pub fn set_show_titles(&self, show: bool) -> TestResult { self.send(ClientMessage::SetShowTitles { show }) } + + pub fn set_autotile(&self, enabled: bool) -> TestResult { + self.send(ClientMessage::SetAutotile { enabled }) + } } impl Drop for TestConfig { diff --git a/src/it/test_ifs/test_viewport.rs b/src/it/test_ifs/test_viewport.rs index b25105c8..e08266de 100644 --- a/src/it/test_ifs/test_viewport.rs +++ b/src/it/test_ifs/test_viewport.rs @@ -29,6 +29,17 @@ impl TestViewport { Ok(()) } + pub fn unset_source(&self) -> Result<(), TestError> { + self.tran.send(SetSource { + self_id: self.id, + x: Fixed::from_int(-1), + y: Fixed::from_int(-1), + width: Fixed::from_int(-1), + height: Fixed::from_int(-1), + })?; + Ok(()) + } + pub fn set_destination(&self, width: i32, height: i32) -> Result<(), TestError> { self.tran.send(SetDestination { self_id: self.id, @@ -37,6 +48,15 @@ impl TestViewport { })?; Ok(()) } + + pub fn unset_destination(&self) -> Result<(), TestError> { + self.tran.send(SetDestination { + self_id: self.id, + width: -1, + height: -1, + })?; + Ok(()) + } } impl Drop for TestViewport { diff --git a/src/it/tests.rs b/src/it/tests.rs index dc28888c..35b6be97 100644 --- a/src/it/tests.rs +++ b/src/it/tests.rs @@ -85,6 +85,8 @@ mod t0051_pointer_warp; mod t0052_bar; mod t0053_theme; mod t0054_subsurface_already_attached; +mod t0055_autotiling; +mod t0055_scratchpad; pub trait TestCase: Sync { fn name(&self) -> &'static str; @@ -158,5 +160,7 @@ pub fn tests() -> Vec<&'static dyn TestCase> { t0052_bar, t0053_theme, t0054_subsurface_already_attached, + t0055_autotiling, + t0055_scratchpad, } } diff --git a/src/it/tests/t0002_window.rs b/src/it/tests/t0002_window.rs index 84571c57..28ee359f 100644 --- a/src/it/tests/t0002_window.rs +++ b/src/it/tests/t0002_window.rs @@ -1,7 +1,6 @@ use { crate::{ it::{test_error::TestError, testrun::TestRun}, - rect::Rect, tree::Node, }, std::rc::Rc, @@ -11,29 +10,19 @@ testcase!(); /// Create and map a single surface async fn test(run: Rc) -> Result<(), TestError> { - run.backend.install_default()?; + let ds = run.create_default_setup().await?; let client = run.create_client().await?; let window = client.create_window().await?; window.map().await?; - tassert_eq!(window.tl.core.width.get(), 800); - tassert_eq!( - window.tl.core.height.get(), - 600 - 2 * run.state.theme.title_plus_underline_height() - ); + let workspace_rect = ds.output.workspace_rect.get(); - tassert_eq!( - window.tl.server.node_absolute_position(), - Rect::new_sized( - 0, - 2 * run.state.theme.title_plus_underline_height(), - window.tl.core.width.get(), - window.tl.core.height.get(), - ) - .unwrap() - ); + tassert_eq!(window.tl.core.width.get(), workspace_rect.width()); + tassert_eq!(window.tl.core.height.get(), workspace_rect.height()); + + tassert_eq!(window.tl.server.node_absolute_position(), workspace_rect); Ok(()) } diff --git a/src/it/tests/t0003_multi_window.rs b/src/it/tests/t0003_multi_window.rs index 3fbf599c..db726f90 100644 --- a/src/it/tests/t0003_multi_window.rs +++ b/src/it/tests/t0003_multi_window.rs @@ -11,7 +11,7 @@ testcase!(); /// Create and map two surfaces async fn test(run: Rc) -> Result<(), TestError> { - run.backend.install_default()?; + let ds = run.create_default_setup().await?; let client = run.create_client().await?; @@ -21,17 +21,30 @@ async fn test(run: Rc) -> Result<(), TestError> { let window2 = client.create_window().await?; window2.map().await?; - let otop = 2 * run.state.theme.title_plus_underline_height(); + let workspace_rect = ds.output.workspace_rect.get(); let bw = run.state.theme.sizes.border_width.get(); + let child_width = (workspace_rect.width() - bw) / 2; tassert_eq!( window.tl.server.node_absolute_position(), - Rect::new_sized(0, otop, (800 - bw) / 2, 600 - otop).unwrap() + Rect::new_sized( + workspace_rect.x1(), + workspace_rect.y1(), + child_width, + workspace_rect.height(), + ) + .unwrap() ); tassert_eq!( window2.tl.server.node_absolute_position(), - Rect::new_sized((800 - bw) / 2 + bw, otop, (800 - bw) / 2, 600 - otop).unwrap() + Rect::new_sized( + workspace_rect.x1() + child_width + bw, + workspace_rect.y1(), + child_width, + workspace_rect.height(), + ) + .unwrap() ); Ok(()) diff --git a/src/it/tests/t0007_subsurface/screenshot_1.qoi b/src/it/tests/t0007_subsurface/screenshot_1.qoi index 230c0408..b5954651 100644 Binary files a/src/it/tests/t0007_subsurface/screenshot_1.qoi and b/src/it/tests/t0007_subsurface/screenshot_1.qoi differ diff --git a/src/it/tests/t0007_subsurface/screenshot_2.qoi b/src/it/tests/t0007_subsurface/screenshot_2.qoi index 722271f6..718d5c29 100644 Binary files a/src/it/tests/t0007_subsurface/screenshot_2.qoi and b/src/it/tests/t0007_subsurface/screenshot_2.qoi differ diff --git a/src/it/tests/t0014_container_scroll_focus.rs b/src/it/tests/t0014_container_scroll_focus.rs index 0186cbaf..dccd1096 100644 --- a/src/it/tests/t0014_container_scroll_focus.rs +++ b/src/it/tests/t0014_container_scroll_focus.rs @@ -48,13 +48,18 @@ async fn test(run: Rc) -> TestResult { let mono_container = w_mono2.tl.container_parent()?; let container_pos = mono_container.tl_data().pos.get(); - let w_mono1_title = mono_container.render_data.borrow_mut().title_rects[0] - .move_(container_pos.x1(), container_pos.y1()); - ds.mouse.abs( - &ds.connector, - w_mono1_title.x1() as _, - w_mono1_title.y1() as _, - ); + let (tab_x, tab_y) = { + let tab_bar = mono_container.tab_bar.borrow(); + let Some(tab_bar) = tab_bar.as_ref() else { + bail!("no tab bar"); + }; + let w_mono1_title = &tab_bar.entries[0]; + ( + container_pos.x1() + w_mono1_title.x.get() + w_mono1_title.width.get() / 2, + container_pos.y1() + tab_bar.height / 2, + ) + }; + ds.mouse.abs(&ds.connector, tab_x as _, tab_y as _); client.sync().await; tassert!(enters.next().is_err()); diff --git a/src/it/tests/t0015_scroll_partial.rs b/src/it/tests/t0015_scroll_partial.rs index c6cf49b7..f5cb6e3c 100644 --- a/src/it/tests/t0015_scroll_partial.rs +++ b/src/it/tests/t0015_scroll_partial.rs @@ -26,12 +26,18 @@ async fn test(run: Rc) -> TestResult { let container = w_mono2.tl.container_parent()?; let pos = container.tl_data().pos.get(); - let w_mono1_title = container.render_data.borrow_mut().title_rects[0].move_(pos.x1(), pos.y1()); - ds.mouse.abs( - &ds.connector, - w_mono1_title.x1() as f64, - w_mono1_title.y1() as f64, - ); + let (tab_x, tab_y) = { + let tab_bar = container.tab_bar.borrow(); + let Some(tab_bar) = tab_bar.as_ref() else { + bail!("no tab bar"); + }; + let w_mono1_title = &tab_bar.entries[0]; + ( + pos.x1() + w_mono1_title.x.get() + w_mono1_title.width.get() / 2, + pos.y1() + tab_bar.height / 2, + ) + }; + ds.mouse.abs(&ds.connector, tab_x as f64, tab_y as f64); client.sync().await; let enters = dss.kb.enter.expect()?; diff --git a/src/it/tests/t0020_surface_offset/screenshot_1.qoi b/src/it/tests/t0020_surface_offset/screenshot_1.qoi index eef5f37a..4c826f86 100644 Binary files a/src/it/tests/t0020_surface_offset/screenshot_1.qoi and b/src/it/tests/t0020_surface_offset/screenshot_1.qoi differ diff --git a/src/it/tests/t0020_surface_offset/screenshot_2.qoi b/src/it/tests/t0020_surface_offset/screenshot_2.qoi index 7e8cf143..0fb763e2 100644 Binary files a/src/it/tests/t0020_surface_offset/screenshot_2.qoi and b/src/it/tests/t0020_surface_offset/screenshot_2.qoi differ diff --git a/src/it/tests/t0022_toplevel_suspended.rs b/src/it/tests/t0022_toplevel_suspended.rs index 1fdacb1a..524856e3 100644 --- a/src/it/tests/t0022_toplevel_suspended.rs +++ b/src/it/tests/t0022_toplevel_suspended.rs @@ -2,7 +2,7 @@ use { crate::{ ifs::wl_surface::xdg_surface::xdg_toplevel::STATE_SUSPENDED, it::{ - test_error::TestResult, + test_error::{TestErrorExt, TestResult}, test_utils::{ test_ouput_node_ext::TestOutputNodeExt, test_toplevel_node_ext::TestToplevelNodeExt, }, @@ -10,7 +10,7 @@ use { }, }, isnt::std_1::collections::IsntHashSetExt, - std::rc::Rc, + std::{rc::Rc, time::Duration}, }; testcase!(); @@ -19,6 +19,7 @@ async fn test(run: Rc) -> TestResult { let ds = run.create_default_setup().await?; let client = run.create_client().await?; + let default_seat = client.get_default_seat().await?; let win1 = client.create_window().await?; win1.set_color(255, 0, 0, 255); @@ -44,5 +45,23 @@ async fn test(run: Rc) -> TestResult { client.sync().await; tassert!(win2.tl.core.states.borrow().not_contains(&STATE_SUSPENDED)); + let leaves = default_seat.kb.leave.expect()?; + let enters = default_seat.kb.enter.expect()?; + + run.cfg.set_idle(Duration::from_micros(100))?; + run.cfg.set_idle_grace_period(Duration::from_secs(0))?; + run.state.wheel.timeout(3).await?; + + client.sync().await; + tassert!(win2.tl.core.states.borrow().contains(&STATE_SUSPENDED)); + let leave = leaves.next().with_context(|| "no leave on suspend")?; + tassert_eq!(leave.surface, win2.surface.id); + + ds.mouse.rel(1.0, 1.0); + client.sync().await; + tassert!(win2.tl.core.states.borrow().not_contains(&STATE_SUSPENDED)); + let enter = enters.next().with_context(|| "no enter on restore")?; + tassert_eq!(enter.surface, win2.surface.id); + Ok(()) } diff --git a/src/it/tests/t0023_xdg_activation/screenshot_1.qoi b/src/it/tests/t0023_xdg_activation/screenshot_1.qoi index 1fa8d204..960da20a 100644 Binary files a/src/it/tests/t0023_xdg_activation/screenshot_1.qoi and b/src/it/tests/t0023_xdg_activation/screenshot_1.qoi differ diff --git a/src/it/tests/t0026_output_transform/screenshot_1.qoi b/src/it/tests/t0026_output_transform/screenshot_1.qoi index 2206fc85..f11111bb 100644 Binary files a/src/it/tests/t0026_output_transform/screenshot_1.qoi and b/src/it/tests/t0026_output_transform/screenshot_1.qoi differ diff --git a/src/it/tests/t0028_top_level_restacking/screenshot_1.qoi b/src/it/tests/t0028_top_level_restacking/screenshot_1.qoi index f7bf53bf..9f5fca3c 100644 Binary files a/src/it/tests/t0028_top_level_restacking/screenshot_1.qoi and b/src/it/tests/t0028_top_level_restacking/screenshot_1.qoi differ diff --git a/src/it/tests/t0028_top_level_restacking/screenshot_2.qoi b/src/it/tests/t0028_top_level_restacking/screenshot_2.qoi index b454acd3..aaf1b108 100644 Binary files a/src/it/tests/t0028_top_level_restacking/screenshot_2.qoi and b/src/it/tests/t0028_top_level_restacking/screenshot_2.qoi differ diff --git a/src/it/tests/t0029_double_click_float/screenshot_1.qoi b/src/it/tests/t0029_double_click_float/screenshot_1.qoi index dd974ccf..e08dc525 100644 Binary files a/src/it/tests/t0029_double_click_float/screenshot_1.qoi and b/src/it/tests/t0029_double_click_float/screenshot_1.qoi differ diff --git a/src/it/tests/t0029_double_click_float/screenshot_2.qoi b/src/it/tests/t0029_double_click_float/screenshot_2.qoi index f49edd4d..e08dc525 100644 Binary files a/src/it/tests/t0029_double_click_float/screenshot_2.qoi and b/src/it/tests/t0029_double_click_float/screenshot_2.qoi differ diff --git a/src/it/tests/t0037_toplevel_drag/screenshot_2.qoi b/src/it/tests/t0037_toplevel_drag/screenshot_2.qoi index b9826001..36c68e4e 100644 Binary files a/src/it/tests/t0037_toplevel_drag/screenshot_2.qoi and b/src/it/tests/t0037_toplevel_drag/screenshot_2.qoi differ diff --git a/src/it/tests/t0038_subsurface_parent_state/screenshot_1.qoi b/src/it/tests/t0038_subsurface_parent_state/screenshot_1.qoi index 988bc767..e6f6db74 100644 Binary files a/src/it/tests/t0038_subsurface_parent_state/screenshot_1.qoi and b/src/it/tests/t0038_subsurface_parent_state/screenshot_1.qoi differ diff --git a/src/it/tests/t0038_subsurface_parent_state/screenshot_2.qoi b/src/it/tests/t0038_subsurface_parent_state/screenshot_2.qoi index a7509404..9abc8de3 100644 Binary files a/src/it/tests/t0038_subsurface_parent_state/screenshot_2.qoi and b/src/it/tests/t0038_subsurface_parent_state/screenshot_2.qoi differ diff --git a/src/it/tests/t0039_alpha_modifier/screenshot_1.qoi b/src/it/tests/t0039_alpha_modifier/screenshot_1.qoi index 8fe5d0b2..80a29c84 100644 Binary files a/src/it/tests/t0039_alpha_modifier/screenshot_1.qoi and b/src/it/tests/t0039_alpha_modifier/screenshot_1.qoi differ diff --git a/src/it/tests/t0039_alpha_modifier/screenshot_2.qoi b/src/it/tests/t0039_alpha_modifier/screenshot_2.qoi index 9874e2f5..735af290 100644 Binary files a/src/it/tests/t0039_alpha_modifier/screenshot_2.qoi and b/src/it/tests/t0039_alpha_modifier/screenshot_2.qoi differ diff --git a/src/it/tests/t0041_input_method/screenshot_1.qoi b/src/it/tests/t0041_input_method/screenshot_1.qoi index d25fcf64..cd07ecd4 100644 Binary files a/src/it/tests/t0041_input_method/screenshot_1.qoi and b/src/it/tests/t0041_input_method/screenshot_1.qoi differ diff --git a/src/it/tests/t0041_input_method/screenshot_2.qoi b/src/it/tests/t0041_input_method/screenshot_2.qoi index 7f93231a..d76ea9a0 100644 Binary files a/src/it/tests/t0041_input_method/screenshot_2.qoi and b/src/it/tests/t0041_input_method/screenshot_2.qoi differ diff --git a/src/it/tests/t0041_input_method/screenshot_3.qoi b/src/it/tests/t0041_input_method/screenshot_3.qoi index d25fcf64..cd07ecd4 100644 Binary files a/src/it/tests/t0041_input_method/screenshot_3.qoi and b/src/it/tests/t0041_input_method/screenshot_3.qoi differ diff --git a/src/it/tests/t0042_toplevel_select/screenshot_1.qoi b/src/it/tests/t0042_toplevel_select/screenshot_1.qoi index 6423ef6d..6d57d140 100644 Binary files a/src/it/tests/t0042_toplevel_select/screenshot_1.qoi and b/src/it/tests/t0042_toplevel_select/screenshot_1.qoi differ diff --git a/src/it/tests/t0042_toplevel_select/screenshot_2.qoi b/src/it/tests/t0042_toplevel_select/screenshot_2.qoi index 823fd750..478b3c43 100644 Binary files a/src/it/tests/t0042_toplevel_select/screenshot_2.qoi and b/src/it/tests/t0042_toplevel_select/screenshot_2.qoi differ diff --git a/src/it/tests/t0042_toplevel_select/screenshot_3.qoi b/src/it/tests/t0042_toplevel_select/screenshot_3.qoi index 823fd750..478b3c43 100644 Binary files a/src/it/tests/t0042_toplevel_select/screenshot_3.qoi and b/src/it/tests/t0042_toplevel_select/screenshot_3.qoi differ diff --git a/src/it/tests/t0042_toplevel_select/screenshot_4.qoi b/src/it/tests/t0042_toplevel_select/screenshot_4.qoi index 714222f1..07dd87fb 100644 Binary files a/src/it/tests/t0042_toplevel_select/screenshot_4.qoi and b/src/it/tests/t0042_toplevel_select/screenshot_4.qoi differ diff --git a/src/it/tests/t0047_surface_damage.rs b/src/it/tests/t0047_surface_damage.rs index d9760bc8..c2d0d6dd 100644 --- a/src/it/tests/t0047_surface_damage.rs +++ b/src/it/tests/t0047_surface_damage.rs @@ -308,9 +308,8 @@ async fn test(run: Rc) -> TestResult { let output_damage = connector_data.damage.borrow(); tassert!(!output_damage.is_empty()); - // Buffer damage is transformed by the damage matrix which includes the surface position - // The buffer damage (0,0,1,1) should be transformed to surface coordinates - let expected_buffer_damage = buffer_damage.move_(surface_pos.x1(), surface_pos.y1()); + // The test window maps its 1x1 buffer through a viewport to the full window size. + let expected_buffer_damage = surface_pos; // Find the exact output damage that matches our expected buffer damage let mut found_exact_buffer_damage = false; @@ -331,10 +330,12 @@ async fn test(run: Rc) -> TestResult { // Test 7: Check output damage from existing window's viewport (which already has scaling) connector_data.damage.borrow_mut().clear(); - // The existing window was created with create_surface_ext() which automatically creates a viewport - // Let's verify that the viewport's existing scaling affects buffer damage correctly - // First, let's modify the viewport scaling that already exists on the window - window.surface.viewport.set_destination(150, 100)?; // Change scaling to 150x100 + // The existing window was created with create_surface_ext() which automatically creates a viewport. + // Commit the viewport size change separately; that commit intentionally damages the old/new extents. + window.surface.viewport.set_destination(150, 100)?; + window.surface.commit()?; + client.sync().await; + connector_data.damage.borrow_mut().clear(); // Add buffer damage to test viewport scaling coordinate transformation window.surface.damage_buffer(0, 0, 1, 1)?; // Damage entire 1x1 buffer @@ -346,8 +347,8 @@ async fn test(run: Rc) -> TestResult { let output_damage = connector_data.damage.borrow(); tassert!(!output_damage.is_empty()); - // With viewporter scaling, the 1x1 buffer damage should scale to 150x100 - // and be moved by surface position (0, 36) to get output coordinates (0, 36, 150, 136) + // With viewporter scaling, the 1x1 buffer damage should scale to the viewport destination. + let surface_pos = window.surface.server.buffer_abs_pos.get(); let expected_scaled_damage = Rect::new_sized(0, 0, 150, 100).unwrap(); let expected_output_damage = expected_scaled_damage.move_(surface_pos.x1(), surface_pos.y1()); @@ -402,8 +403,9 @@ async fn test(run: Rc) -> TestResult { rotation_window.map().await?; client.sync().await; - // Disable viewporter by setting destination to 0x0 to rely purely on buffer dimensions - rotation_window.surface.viewport.set_destination(0, 0)?; // Disable viewporter + // Disable viewporter to rely purely on buffer dimensions. + rotation_window.surface.viewport.unset_source()?; + rotation_window.surface.viewport.unset_destination()?; // Use a rectangular buffer (4x2) so rotation has a visible geometric effect // Attach AFTER mapping to avoid being overwritten by map()'s single-pixel buffer diff --git a/src/it/tests/t0055_autotiling.rs b/src/it/tests/t0055_autotiling.rs new file mode 100644 index 00000000..4b3611c4 --- /dev/null +++ b/src/it/tests/t0055_autotiling.rs @@ -0,0 +1,58 @@ +use { + crate::{ + it::{test_error::TestResult, testrun::TestRun}, + tree::{ContainerSplit, Node, ToplevelNodeBase}, + }, + std::rc::Rc, +}; + +testcase!(); + +async fn test(run: Rc) -> TestResult { + run.backend.install_default()?; + run.cfg.set_autotile(true)?; + + let client = run.create_client().await?; + + let win1 = client.create_window().await?; + win1.map().await?; + let root = win1.tl.container_parent()?; + tassert_eq!(root.split.get(), ContainerSplit::Horizontal); + + let win2 = client.create_window().await?; + win2.map().await?; + client.sync().await; + + tassert_eq!(root.split.get(), ContainerSplit::Horizontal); + tassert_eq!(win1.tl.container_parent()?.node_id(), root.node_id()); + tassert_eq!(win2.tl.container_parent()?.node_id(), root.node_id()); + + let win3 = client.create_window().await?; + win3.map().await?; + client.sync().await; + + let v_group = win3.tl.container_parent()?; + tassert_eq!(root.split.get(), ContainerSplit::Horizontal); + tassert_eq!(v_group.split.get(), ContainerSplit::Vertical); + tassert_eq!(win2.tl.container_parent()?.node_id(), v_group.node_id()); + + let win4 = client.create_window().await?; + win4.map().await?; + client.sync().await; + + let h_group = win4.tl.container_parent()?; + tassert_eq!(h_group.split.get(), ContainerSplit::Horizontal); + tassert_eq!(win3.tl.container_parent()?.node_id(), h_group.node_id()); + let h_parent = match h_group + .tl_data() + .parent + .get() + .and_then(|p| p.node_into_container()) + { + Some(parent) => parent, + None => bail!("autotile group does not have a container parent"), + }; + tassert_eq!(h_parent.node_id(), v_group.node_id()); + + Ok(()) +} diff --git a/src/it/tests/t0055_scratchpad.rs b/src/it/tests/t0055_scratchpad.rs new file mode 100644 index 00000000..5abf2440 --- /dev/null +++ b/src/it/tests/t0055_scratchpad.rs @@ -0,0 +1,107 @@ +use { + crate::{ + it::{test_error::TestResult, testrun::TestRun}, + tree::{Node, ToplevelNodeBase}, + }, + std::rc::Rc, +}; + +testcase!(); + +async fn test(run: Rc) -> TestResult { + let ds = run.create_default_setup().await?; + + let client = run.create_client().await?; + let win1 = client.create_window().await?; + win1.map2().await?; + let win2 = client.create_window().await?; + win2.map2().await?; + + run.cfg.send_to_scratchpad(ds.seat.id(), "term")?; + client.sync().await; + tassert!(win1.tl.server.node_visible()); + tassert!(!win2.tl.server.node_visible()); + + run.cfg.show_workspace(ds.seat.id(), "2")?; + run.cfg.toggle_scratchpad(ds.seat.id(), "term")?; + client.sync().await; + tassert!(win2.tl.server.node_visible()); + tassert_eq!(ds.output.workspace.get().unwrap().name.as_str(), "2"); + + run.cfg.toggle_scratchpad(ds.seat.id(), "term")?; + client.sync().await; + tassert!(!win2.tl.server.node_visible()); + + run.cfg.toggle_scratchpad(ds.seat.id(), "term")?; + client.sync().await; + tassert!(win2.tl.server.node_visible()); + tassert_eq!(ds.output.workspace.get().unwrap().name.as_str(), "2"); + + run.cfg.show_workspace(ds.seat.id(), "3")?; + client.sync().await; + tassert!(!win2.tl.server.node_visible()); + + run.cfg.toggle_scratchpad(ds.seat.id(), "term")?; + client.sync().await; + tassert!(win2.tl.server.node_visible()); + tassert_eq!(ds.output.workspace.get().unwrap().name.as_str(), "3"); + // Scratchpad windows are always shown floating. + tassert!(win2.tl.server.tl_data().parent_is_float.get()); + + // Park win2 again, then build a multi-window scratchpad and cycle it. + run.cfg.toggle_scratchpad(ds.seat.id(), "term")?; + client.sync().await; + tassert!(!win2.tl.server.node_visible()); + + // Build a three-window scratchpad. Each window is focused right after it is + // mapped, so sending the focused window parks them in a known order. + let cyc1 = client.create_window().await?; + cyc1.map2().await?; + run.cfg.send_to_scratchpad(ds.seat.id(), "cyc")?; + let cyc2 = client.create_window().await?; + cyc2.map2().await?; + run.cfg.send_to_scratchpad(ds.seat.id(), "cyc")?; + let cyc3 = client.create_window().await?; + cyc3.map2().await?; + run.cfg.send_to_scratchpad(ds.seat.id(), "cyc")?; + client.sync().await; + tassert!(!cyc1.tl.server.node_visible()); + tassert!(!cyc2.tl.server.node_visible()); + tassert!(!cyc3.tl.server.node_visible()); + + // Nothing shown: cycle brings up the first window (insertion order: cyc1). + run.cfg.cycle_scratchpad(ds.seat.id(), "cyc")?; + client.sync().await; + tassert!(cyc1.tl.server.node_visible()); + tassert!(!cyc2.tl.server.node_visible()); + tassert!(!cyc3.tl.server.node_visible()); + // Scratchpad windows are always shown floating. + tassert!(cyc1.tl.server.tl_data().parent_is_float.get()); + + // Cycle advances one at a time. + run.cfg.cycle_scratchpad(ds.seat.id(), "cyc")?; + client.sync().await; + tassert!(!cyc1.tl.server.node_visible()); + tassert!(cyc2.tl.server.node_visible()); + tassert!(!cyc3.tl.server.node_visible()); + + run.cfg.cycle_scratchpad(ds.seat.id(), "cyc")?; + client.sync().await; + tassert!(!cyc1.tl.server.node_visible()); + tassert!(!cyc2.tl.server.node_visible()); + tassert!(cyc3.tl.server.node_visible()); + + // On the final window, the next cycle hides everything. + run.cfg.cycle_scratchpad(ds.seat.id(), "cyc")?; + client.sync().await; + tassert!(!cyc1.tl.server.node_visible()); + tassert!(!cyc2.tl.server.node_visible()); + tassert!(!cyc3.tl.server.node_visible()); + + // And it wraps back to the first window. + run.cfg.cycle_scratchpad(ds.seat.id(), "cyc")?; + client.sync().await; + tassert!(cyc1.tl.server.node_visible()); + + Ok(()) +} diff --git a/src/state.rs b/src/state.rs index 42dd909d..74facaf1 100644 --- a/src/state.rs +++ b/src/state.rs @@ -114,9 +114,11 @@ use { tree::{ ContainerNode, ContainerSplit, Direction, DisplayNode, FindTreeUsecase, FloatNode, FoundNode, LatchListener, Node, NodeId, NodeIds, NodeVisitorBase, OutputNode, - PlaceholderNode, TearingMode, TileState, ToplevelData, ToplevelIdentifier, - ToplevelNode, ToplevelNodeBase, Transform, VrrMode, WorkspaceDisplayOrder, - WorkspaceNode, WorkspaceNodeId, WsMoveConfig, generic_node_visitor, move_ws_to_output, + PlaceholderNode, TearingMode, TileState, ToplevelData, + ToplevelIdentifier, ToplevelNode, ToplevelNodeBase, Transform, VrrMode, + WorkspaceDisplayOrder, WorkspaceNode, WorkspaceNodeId, WsMoveConfig, + generic_node_visitor, move_ws_to_output, toplevel_hide_for_scratchpad, + toplevel_restore_from_scratchpad, toplevel_set_workspace, }, udmabuf::UdmabufHolder, utils::{ @@ -412,6 +414,7 @@ pub struct State { pub bo_drop_queue: Rc>>, pub virtual_outputs: VirtualOutputs, pub clean_logs_older_than: Cell>, + pub scratchpads: RefCell>>>, } // impl Drop for State { @@ -459,6 +462,27 @@ pub struct IdleState { pub in_grace_period: Cell, } +pub struct ScratchpadEntry { + node: Weak, + identifier: ToplevelIdentifier, + hidden: Cell, +} + +impl ScratchpadEntry { + fn alive(&self) -> bool { + self.node().is_some() + } + + fn node(&self) -> Option> { + let node = self.node.upgrade()?; + if node.tl_data().identifier.get() == self.identifier { + Some(node) + } else { + None + } + } +} + impl IdleState { pub fn set_timeout(&self, state: &State, timeout: Duration) { self.timeout.set(timeout); @@ -925,19 +949,39 @@ impl State { && node.tl_data().kind.is_app_window() && !node.tl_data().visible.get(); if animate_new_app_map { - self.with_layout_animations(|| self.do_map_tiled(seat.as_deref(), node.clone())); + self.with_layout_animations(|| self.do_map_tiled(seat.as_deref(), node.clone(), true)); } else { - self.do_map_tiled(seat.as_deref(), node.clone()); + self.do_map_tiled(seat.as_deref(), node.clone(), true); } self.focus_after_map(node, seat.as_deref()); } - fn do_map_tiled(self: &Rc, seat: Option<&Rc>, node: Rc) { + pub fn map_tiled_without_autotile(self: &Rc, node: Rc) { + let seat = self.seat_queue.last(); + self.do_map_tiled(seat.as_deref(), node.clone(), false); + self.focus_after_map(node, seat.as_deref()); + } + + fn do_map_tiled( + self: &Rc, + seat: Option<&Rc>, + node: Rc, + autotile: bool, + ) { let ws = self.ensure_map_workspace(seat); - self.map_tiled_on(node, &ws); + self.map_tiled_on_(node, &ws, autotile); } pub fn map_tiled_on(self: &Rc, node: Rc, ws: &Rc) { + self.map_tiled_on_(node, ws, false); + } + + fn map_tiled_on_( + self: &Rc, + node: Rc, + ws: &Rc, + autotile: bool, + ) { if let Some(c) = ws.container.get() { let la = c.clone().tl_last_active_child(); let lap = la @@ -946,7 +990,11 @@ impl State { .get() .and_then(|n| n.node_into_container()); if let Some(lap) = lap { - lap.add_child_after(&*la, node); + if autotile { + lap.add_tiled_child_after(&*la, node); + } else { + lap.add_child_after(&*la, node); + } } else { c.append_child(node); } @@ -999,6 +1047,146 @@ impl State { float } + pub fn send_to_scratchpad(self: &Rc, name: &str, node: Rc) { + if node.node_is_placeholder() { + return; + } + let identifier = node.tl_data().identifier.get(); + if !toplevel_hide_for_scratchpad(node.clone()) { + return; + } + let entry = Rc::new(ScratchpadEntry { + node: Rc::downgrade(&node), + identifier, + hidden: Cell::new(true), + }); + { + let mut scratchpads = self.scratchpads.borrow_mut(); + for entries in scratchpads.values_mut() { + entries.retain(|entry| entry.alive() && entry.identifier != identifier); + } + scratchpads + .entry(name.to_string()) + .or_default() + .push(entry); + } + self.tree_changed(); + } + + pub fn toggle_scratchpad(self: &Rc, seat: &Rc, name: &str) { + let entry = { + let mut scratchpads = self.scratchpads.borrow_mut(); + let Some(entries) = scratchpads.get_mut(name) else { + return; + }; + entries.retain(|entry| entry.alive()); + // Prefer the currently-shown window; otherwise act on the most recent. + entries + .iter() + .rev() + .find(|entry| !entry.hidden.get()) + .or_else(|| entries.last()) + .cloned() + }; + let Some(entry) = entry else { + return; + }; + if entry.hidden.get() { + self.show_scratchpad_entry(seat, name, &entry); + } else if entry.node().is_some_and(|node| !node.node_visible()) { + self.move_scratchpad_entry_to_current_workspace(seat, &entry); + } else { + self.hide_scratchpad_entry(&entry); + } + } + + /// Cycles through the windows of a scratchpad, one at a time: + /// nothing shown -> first window -> ... -> last window -> nothing shown. + pub fn cycle_scratchpad(self: &Rc, seat: &Rc, name: &str) { + let (current, next) = { + let mut scratchpads = self.scratchpads.borrow_mut(); + let Some(entries) = scratchpads.get_mut(name) else { + return; + }; + entries.retain(|entry| entry.alive()); + match entries.iter().position(|entry| !entry.hidden.get()) { + // Nothing shown yet: bring up the first window. + None => (None, entries.first().cloned()), + // Hide the shown window and advance; on the last window, `next` + // is `None`, so the scratchpad toggles off. + Some(i) => (entries.get(i).cloned(), entries.get(i + 1).cloned()), + } + }; + if let Some(current) = ¤t { + self.hide_scratchpad_entry(current); + } + if let Some(next) = &next { + self.show_scratchpad_entry(seat, name, next); + } + } + + fn hide_scratchpad_entry(self: &Rc, entry: &Rc) { + if entry.hidden.get() { + return; + } + let Some(node) = entry.node() else { + return; + }; + if toplevel_hide_for_scratchpad(node) { + entry.hidden.set(true); + self.tree_changed(); + } + } + + fn show_scratchpad_entry( + self: &Rc, + seat: &Rc, + name: &str, + entry: &Rc, + ) { + if !entry.hidden.get() { + return; + } + let Some(node) = entry.node() else { + return; + }; + // Only one window of a scratchpad is visible at a time. + let siblings: Vec<_> = { + let scratchpads = self.scratchpads.borrow(); + scratchpads + .get(name) + .into_iter() + .flatten() + .filter(|sibling| !Rc::ptr_eq(sibling, entry) && !sibling.hidden.get()) + .cloned() + .collect() + }; + for sibling in siblings { + self.hide_scratchpad_entry(&sibling); + } + let ws = seat.get_fallback_output().ensure_workspace(); + toplevel_restore_from_scratchpad(self, node.clone(), &ws); + entry.hidden.set(false); + node.node_do_focus(seat, Direction::Unspecified); + seat.maybe_schedule_warp_mouse_to_focus(); + self.tree_changed(); + } + + fn move_scratchpad_entry_to_current_workspace( + self: &Rc, + seat: &Rc, + entry: &Rc, + ) { + let Some(node) = entry.node() else { + return; + }; + let ws = seat.get_fallback_output().ensure_workspace(); + toplevel_set_workspace(self, node.clone(), &ws); + node.node_do_focus(seat, Direction::Unspecified); + seat.maybe_schedule_warp_mouse_to_focus(); + self.tree_changed(); + } + fn focus_after_map(&self, node: Rc, seat: Option<&Rc>) { if !node.node_visible() { return; @@ -1274,6 +1462,7 @@ impl State { self.node_at_tree.borrow_mut().clear(); self.position_hint_requests.clear(); self.pending_warp_mouse_to_focus.clear(); + self.scratchpads.borrow_mut().clear(); self.head_managers.clear(); self.head_managers_async.clear(); self.const_40hz_latch.clear(); diff --git a/src/tree/container.rs b/src/tree/container.rs index b8de7b25..44a6a778 100644 --- a/src/tree/container.rs +++ b/src/tree/container.rs @@ -32,6 +32,7 @@ use { numcell::NumCell, on_drop_event::OnDropEvent, rc_eq::rc_eq, + scroller::Scroller, threshold_counter::ThresholdCounter, }, }, @@ -150,6 +151,7 @@ pub struct ContainerNode { pub child_removed: Rc, pub all_children_resized: Rc, pub tab_bar: RefCell>, + scroll: Scroller, pub update_tab_textures_scheduled: Cell, pub ephemeral: Cell, } @@ -266,6 +268,7 @@ impl ContainerNode { child_removed: state.lazy_event_sources.create_source(), all_children_resized: state.post_layout_event_sources.create_source(), tab_bar: RefCell::new(None), + scroll: Default::default(), update_tab_textures_scheduled: Cell::new(false), ephemeral: Cell::new(Ephemeral::Off), }); @@ -290,6 +293,47 @@ impl ContainerNode { self.add_child_x(prev, new, |prev, new| self.add_child_after_(prev, new)); } + pub fn add_tiled_child_after(self: &Rc, prev: &dyn Node, new: Rc) { + if !self.state.theme.autotile_enabled.get() + || self.mono_child.is_some() + || self.num_children.get() <= 1 + { + self.add_child_after(prev, new); + return; + } + let focused = self + .child_nodes + .borrow() + .get(&prev.node_id()) + .map(|n| n.to_ref()); + let Some(focused) = focused else { + log::error!( + "Tried to autotile a child into a container but the preceding node is not in the container" + ); + return; + }; + let focused_node = focused.node.clone(); + let focused_active = focused_node.tl_data().active(); + let sub = ContainerNode::new( + &self.state, + &self.workspace.get(), + focused_node.clone(), + self.split.get().other(), + ); + // Autotile-created groups are structural and collapse once only one + // child remains. Explicit make-group commands control their own + // grouping through the regular manual paths. + sub.ephemeral.set(Ephemeral::On); + sub.append_child(new); + let sub_id = sub.node_id(); + self.clone().cnode_replace_child(&*focused_node, sub); + if focused_active + && let Some(group) = self.child_nodes.borrow().get(&sub_id).map(|n| n.to_ref()) + { + self.update_child_active(&group, true, 1); + } + } + pub fn add_child_before(self: &Rc, prev: &dyn Node, new: Rc) { self.add_child_x(prev, new, |prev, new| self.add_child_before_(prev, new)); } @@ -752,6 +796,18 @@ impl ContainerNode { self.activate_child2(child, false); } + fn activate_child_from_input( + self: &Rc, + child: &NodeRef, + seat: &Rc, + ) { + self.activate_child(child); + child + .node + .clone() + .node_do_focus(seat, Direction::Unspecified); + } + fn activate_child2(self: &Rc, child: &NodeRef, preserve_focus: bool) { if let Some(mc) = self.mono_child.get() { if mc.node.node_id() == child.node.node_id() { @@ -1369,42 +1425,6 @@ impl ContainerNode { } pub fn insert_child(self: &Rc, node: Rc, direction: Direction) { - // Autotile: if the container would become too narrow/tall, wrap the - // focused child and new node in a perpendicular sub-container. - if self.state.theme.autotile_enabled.get() && self.mono_child.is_none() { - let (pw, ph) = self.predict_child_body_size(); - let opposite = match self.split.get() { - ContainerSplit::Horizontal if pw > 0 && ph > 0 && pw < ph => { - Some(ContainerSplit::Vertical) - } - ContainerSplit::Vertical if pw > 0 && ph > 0 && ph < pw => { - Some(ContainerSplit::Horizontal) - } - _ => None, - }; - if let Some(opp_split) = opposite { - if let Some(focused) = self.focus_history.last() { - if self.num_children.get() <= 1 { - // Single child, autotile not applicable. - } else { - let focused_node = focused.node.clone(); - let was_ephemeral = self.ephemeral.replace(Ephemeral::Off); - self.clone().cnode_remove_child2(&*focused_node, true); - self.ephemeral.set(was_ephemeral); - let sub = ContainerNode::new( - &self.state, - &self.workspace.get(), - focused_node, - opp_split, - ); - sub.ephemeral.set(Ephemeral::On); - sub.append_child(node); - self.append_child(sub); - return; - } - } - } - } let (split, right) = direction_to_split(direction); if split != self.split.get() || right { self.append_child(node); @@ -1514,7 +1534,7 @@ impl ContainerNode { fn button( self: Rc, id: CursorType, - _seat: &Rc, + seat: &Rc, _time_usec: u64, pressed: bool, button: u32, @@ -1544,7 +1564,7 @@ impl ContainerNode { if let Some(child) = children.get(&child_id) { let child_ref = child.to_ref(); drop(children); - self.activate_child(&child_ref); + self.activate_child_from_input(&child_ref, seat); } return; } @@ -2061,31 +2081,33 @@ impl Node for ContainerNode { self.button(id, seat, time_usec, state == ButtonState::Pressed, button); } - fn node_on_axis_event(self: Rc, _seat: &Rc, event: &PendingScroll) { + fn node_on_axis_event(self: Rc, seat: &Rc, event: &PendingScroll) { if self.mono_child.is_none() { return; } - // Use vertical scroll (index 1) to switch tabs. - let v = match event.v120[1].get() { - Some(v) if v != 0 => v, + let steps = match self.scroll.handle(event) { + Some(steps) => steps, _ => return, }; - let mono = match self.mono_child.get() { + let mut target = match self.mono_child.get() { Some(m) => m, None => return, }; - let next = if v > 0 { - // Scroll down → next tab. - mono.next().or_else(|| self.children.first()) - } else { - // Scroll up → previous tab. - mono.prev().or_else(|| self.children.last()) - }; - if let Some(next) = next { - if next.node.node_id() != mono.node.node_id() { - self.activate_child(&next); + let current_id = target.node.node_id(); + for _ in 0..steps.abs() { + let next = if steps > 0 { + target.next().or_else(|| self.children.first()) + } else { + target.prev().or_else(|| self.children.last()) + }; + match next { + Some(next) => target = next, + None => break, } } + if target.node.node_id() != current_id { + self.activate_child_from_input(&target, seat); + } } fn node_on_leave(&self, seat: &WlSeatGlobal) { diff --git a/src/tree/display.rs b/src/tree/display.rs index 440916bf..26b31a88 100644 --- a/src/tree/display.rs +++ b/src/tree/display.rs @@ -8,18 +8,25 @@ use { renderer::Renderer, state::State, tree::{ - FindTreeResult, FindTreeUsecase, FoundNode, Node, NodeId, NodeLayerLink, NodeLocation, - OutputNode, StackedNode, TileDragDestination, WorkspaceDragDestination, + Direction, FindTreeResult, FindTreeUsecase, FoundNode, Node, NodeId, NodeLayerLink, + NodeLocation, OutputNode, StackedNode, TileDragDestination, WorkspaceDragDestination, WorkspaceNodeId, walker::NodeVisitor, }, utils::{copyhashmap::CopyHashMap, linkedlist::LinkedList}, }, - std::{cell::Cell, ops::Deref, rc::Rc}, + std::{ + cell::{Cell, RefCell}, + mem, + ops::Deref, + rc::{Rc, Weak}, + }, }; pub struct DisplayNode { pub id: NodeId, pub extents: Cell, + visible: Cell, + suspend_restore_kb_foci: RefCell, Weak)>>, pub outputs: CopyHashMap>, pub stacked: Rc>>, pub stacked_above_layers: Rc>>, @@ -31,6 +38,8 @@ impl DisplayNode { let slf = Self { id, extents: Default::default(), + visible: Default::default(), + suspend_restore_kb_foci: Default::default(), outputs: Default::default(), stacked: Default::default(), stacked_above_layers: Default::default(), @@ -71,6 +80,17 @@ impl DisplayNode { pub fn update_visible(&self, state: &State) { let visible = state.root_visible(); + let was_visible = self.visible.replace(visible); + if !visible && was_visible { + let mut foci = self.suspend_restore_kb_foci.borrow_mut(); + foci.clear(); + for seat in state.globals.seats.lock().values() { + let node = seat.get_keyboard_node(); + if node.node_id() != self.id { + foci.push((seat.clone(), Rc::downgrade(&node))); + } + } + } for output in self.outputs.lock().values() { output.update_visible(); } @@ -82,6 +102,20 @@ impl DisplayNode { for seat in state.globals.seats.lock().values() { seat.set_visible(visible); } + if visible && !was_visible { + for (seat, node) in mem::take(&mut *self.suspend_restore_kb_foci.borrow_mut()) { + if seat.get_keyboard_node().node_id() == self.id { + if let Some(node) = node.upgrade() + && node.node_visible() + { + seat.focus_node(node); + } else { + seat.get_fallback_output() + .take_keyboard_navigation_focus(&seat, Direction::Unspecified); + } + } + } + } if visible { state.damage(self.extents.get()); } diff --git a/src/tree/toplevel.rs b/src/tree/toplevel.rs index 312b4ac6..c0a2f013 100644 --- a/src/tree/toplevel.rs +++ b/src/tree/toplevel.rs @@ -979,7 +979,7 @@ impl ToplevelData { } fd.workspace.remove_fullscreen_node(); if fd.placeholder.is_destroyed() { - state.map_tiled(node); + state.map_tiled_without_autotile(node); return; } let parent = fd.placeholder.tl_data().parent.take().unwrap(); @@ -1262,7 +1262,7 @@ pub fn toplevel_set_floating(state: &Rc, tl: Rc, floati }; if !floating { parent.cnode_remove_child2(&*tl, true); - state.map_tiled(tl); + state.map_tiled_without_autotile(tl); } else if let Some(ws) = data.workspace.get() { let node_id = data.node_id; let old_body = @@ -1323,3 +1323,54 @@ pub fn toplevel_set_workspace(state: &Rc, tl: Rc, ws: & tl.tl_set_fullscreen(true, Some(ws.clone())); } } + +/// Removes a toplevel from the tree so it can be parked in a scratchpad. +/// +/// Returns `true` if the window was hidden. A placeholder, a window without a +/// parent, or a window that refuses to leave fullscreen cannot be parked. +pub fn toplevel_hide_for_scratchpad(tl: Rc) -> bool { + if tl.node_is_placeholder() { + return false; + } + let data = tl.tl_data(); + let workspace = data.workspace.get(); + if data.is_fullscreen.get() { + tl.clone().tl_set_fullscreen(false, None); + if data.is_fullscreen.get() { + return false; + } + } + let Some(parent) = data.parent.get() else { + return false; + }; + let kb_foci = collect_kb_foci(tl.clone()); + parent.cnode_remove_child2(&*tl, true); + data.parent.take(); + data.float.take(); + if data.parent_is_float.replace(false) { + data.property_changed(TL_CHANGED_FLOATING); + } + if data.workspace.take().is_some() { + data.property_changed(TL_CHANGED_WORKSPACE); + } + tl.tl_set_visible(false); + if let Some(workspace) = &workspace { + for seat in kb_foci { + workspace + .clone() + .node_do_focus(&seat, Direction::Unspecified); + } + } + true +} + +/// Maps a parked scratchpad window back onto `ws`. Scratchpad windows always +/// return floating, regardless of how they were laid out before parking. +pub fn toplevel_restore_from_scratchpad( + state: &Rc, + tl: Rc, + ws: &Rc, +) { + let (width, height) = tl.tl_data().float_size(ws); + state.map_floating(tl.clone(), width, height, ws, None); +} diff --git a/toml-config/src/config.rs b/toml-config/src/config.rs index 35aca02c..b57de5ad 100644 --- a/toml-config/src/config.rs +++ b/toml-config/src/config.rs @@ -64,6 +64,9 @@ pub enum SimpleCommand { SetFloating(bool), ToggleFullscreen, SetFullscreen(bool), + SendToScratchpad, + ToggleScratchpad, + CycleScratchpad, Forward(bool), EnableWindowManagement(bool), SetFloatAboveFullscreen(bool), @@ -130,6 +133,15 @@ pub enum Action { MoveToWorkspace { name: String, }, + SendToScratchpad { + name: String, + }, + ToggleScratchpad { + name: String, + }, + CycleScratchpad { + name: String, + }, Multi { actions: Vec, }, @@ -600,6 +612,14 @@ pub struct Config { pub simple_im: Option, pub fallback_output_mode: Option, pub mouse_follows_focus: Option, + pub scratchpads: Vec, + pub autotile: Option, +} + +#[derive(Debug, Clone)] +pub struct Scratchpad { + pub name: String, + pub exec: Option, } #[derive(Debug, Error)] diff --git a/toml-config/src/config/parsers.rs b/toml-config/src/config/parsers.rs index e353a2f8..98d3ab73 100644 --- a/toml-config/src/config/parsers.rs +++ b/toml-config/src/config/parsers.rs @@ -41,6 +41,7 @@ pub mod modified_keysym; mod output; mod output_match; mod repeat_rate; +mod scratchpad; pub mod shortcuts; mod simple_im; mod status; diff --git a/toml-config/src/config/parsers/action.rs b/toml-config/src/config/parsers/action.rs index 7581198d..29fdc3e4 100644 --- a/toml-config/src/config/parsers/action.rs +++ b/toml-config/src/config/parsers/action.rs @@ -117,6 +117,9 @@ impl ActionParser<'_> { "toggle-fullscreen" => ToggleFullscreen, "enter-fullscreen" => SetFullscreen(true), "exit-fullscreen" => SetFullscreen(false), + "send-to-scratchpad" => SendToScratchpad, + "toggle-scratchpad" => ToggleScratchpad, + "cycle-scratchpad" => CycleScratchpad, "focus-parent" => FocusParent, "close" => Close, "disable-pointer-constraint" => DisablePointerConstraint, @@ -222,6 +225,33 @@ impl ActionParser<'_> { Ok(Action::MoveToWorkspace { name }) } + fn parse_send_to_scratchpad(&mut self, ext: &mut Extractor<'_>) -> ParseResult { + let name = ext + .extract(opt(str("name")))? + .map(|name| name.value) + .unwrap_or("") + .to_string(); + Ok(Action::SendToScratchpad { name }) + } + + fn parse_toggle_scratchpad(&mut self, ext: &mut Extractor<'_>) -> ParseResult { + let name = ext + .extract(opt(str("name")))? + .map(|name| name.value) + .unwrap_or("") + .to_string(); + Ok(Action::ToggleScratchpad { name }) + } + + fn parse_cycle_scratchpad(&mut self, ext: &mut Extractor<'_>) -> ParseResult { + let name = ext + .extract(opt(str("name")))? + .map(|name| name.value) + .unwrap_or("") + .to_string(); + Ok(Action::CycleScratchpad { name }) + } + fn parse_configure_connector(&mut self, ext: &mut Extractor<'_>) -> ParseResult { let con = ext .extract(val("connector"))? @@ -551,6 +581,9 @@ impl Parser for ActionParser<'_> { "switch-to-vt" => self.parse_switch_to_vt(&mut ext), "show-workspace" => self.parse_show_workspace(&mut ext), "move-to-workspace" => self.parse_move_to_workspace(&mut ext), + "send-to-scratchpad" => self.parse_send_to_scratchpad(&mut ext), + "toggle-scratchpad" => self.parse_toggle_scratchpad(&mut ext), + "cycle-scratchpad" => self.parse_cycle_scratchpad(&mut ext), "configure-connector" => self.parse_configure_connector(&mut ext), "configure-input" => self.parse_configure_input(&mut ext), "configure-output" => self.parse_configure_output(&mut ext), diff --git a/toml-config/src/config/parsers/config.rs b/toml-config/src/config/parsers/config.rs index d82be95b..8e776860 100644 --- a/toml-config/src/config/parsers/config.rs +++ b/toml-config/src/config/parsers/config.rs @@ -28,6 +28,7 @@ use { log_level::LogLevelParser, output::OutputsParser, repeat_rate::RepeatRateParser, + scratchpad::ScratchpadsParser, shortcuts::{ ComplexShortcutsParser, ShortcutsParser, ShortcutsParserError, parse_modified_keysym_str, @@ -156,6 +157,7 @@ impl Parser for ConfigParser<'_> { mouse_follows_focus, animations_val, ), + (scratchpads_val, autotile), ) = ext.extract(( ( opt(val("keymap")), @@ -217,6 +219,7 @@ impl Parser for ConfigParser<'_> { recover(opt(bol("unstable-mouse-follows-focus"))), opt(val("animations")), ), + (opt(val("scratchpads")), recover(opt(bol("autotile")))), ))?; let mut keymap = None; if let Some(value) = keymap_val { @@ -568,6 +571,13 @@ impl Parser for ConfigParser<'_> { } } } + let mut scratchpads = vec![]; + if let Some(value) = scratchpads_val { + match value.parse(&mut ScratchpadsParser(self.0)) { + Ok(v) => scratchpads = v, + Err(e) => log::warn!("Could not parse the scratchpads: {}", self.0.error(e)), + } + } Ok(Config { keymap, repeat_rate, @@ -618,6 +628,8 @@ impl Parser for ConfigParser<'_> { simple_im, fallback_output_mode, mouse_follows_focus: mouse_follows_focus.despan(), + scratchpads, + autotile: autotile.despan(), }) } } diff --git a/toml-config/src/config/parsers/scratchpad.rs b/toml-config/src/config/parsers/scratchpad.rs new file mode 100644 index 00000000..17cc5238 --- /dev/null +++ b/toml-config/src/config/parsers/scratchpad.rs @@ -0,0 +1,87 @@ +use { + crate::{ + config::{ + Scratchpad, + context::Context, + extractor::{Extractor, ExtractorError, opt, str, val}, + parser::{DataType, ParseResult, Parser, UnexpectedDataType}, + parsers::exec::{ExecParser, ExecParserError}, + }, + toml::{ + toml_span::{Span, Spanned}, + toml_value::Value, + }, + }, + indexmap::IndexMap, + thiserror::Error, +}; + +#[derive(Debug, Error)] +pub enum ScratchpadParserError { + #[error(transparent)] + Expected(#[from] UnexpectedDataType), + #[error(transparent)] + Extract(#[from] ExtractorError), + #[error(transparent)] + Exec(#[from] ExecParserError), +} + +pub struct ScratchpadParser<'a>(pub &'a Context<'a>); + +impl Parser for ScratchpadParser<'_> { + type Value = Scratchpad; + type Error = ScratchpadParserError; + const EXPECTED: &'static [DataType] = &[DataType::Table]; + + fn parse_table( + &mut self, + span: Span, + table: &IndexMap, Spanned>, + ) -> ParseResult { + let mut ext = Extractor::new(self.0, span, table); + let (name, exec_val) = ext.extract((str("name"), opt(val("exec"))))?; + let exec = match exec_val { + None => None, + Some(e) => Some(e.parse_map(&mut ExecParser(self.0))?), + }; + Ok(Scratchpad { + name: name.value.to_string(), + exec, + }) + } +} + +pub struct ScratchpadsParser<'a>(pub &'a Context<'a>); + +impl Parser for ScratchpadsParser<'_> { + type Value = Vec; + type Error = ScratchpadParserError; + const EXPECTED: &'static [DataType] = &[DataType::Table, DataType::Array]; + + fn parse_array(&mut self, _span: Span, array: &[Spanned]) -> ParseResult { + let mut res = vec![]; + for el in array { + match el.parse(&mut ScratchpadParser(self.0)) { + Ok(o) => res.push(o), + Err(e) => { + log::warn!("Could not parse scratchpad: {}", self.0.error(e)); + } + } + } + Ok(res) + } + + fn parse_table( + &mut self, + span: Span, + table: &IndexMap, Spanned>, + ) -> ParseResult { + log::warn!( + "`scratchpads` value should be an array: {}", + self.0.error3(span) + ); + ScratchpadParser(self.0) + .parse_table(span, table) + .map(|v| vec![v]) + } +} diff --git a/toml-config/src/lib.rs b/toml-config/src/lib.rs index 4dbf8e74..6e3430f8 100644 --- a/toml-config/src/lib.rs +++ b/toml-config/src/lib.rs @@ -15,7 +15,7 @@ use { config::{ Action, AnimationCurveConfig, ClientRule, Config, ConfigConnector, ConfigDrmDevice, ConfigKeymap, ConnectorMatch, DrmDeviceMatch, Exec, Input, InputMatch, Output, - OutputMatch, SimpleCommand, Status, Theme, WindowRule, parse_config, + OutputMatch, SimpleCommand, Status, Theme, WindowMatch, WindowRule, parse_config, }, rules::{MatcherTemp, RuleMapper}, shortcuts::ModeState, @@ -27,7 +27,7 @@ use { client::Client, config, config_dir, exec::{Command, set_env, unset_env}, - get_workspace, + get_autotile, get_workspace, input::{ FocusFollowsMouseMode, InputDevice, Seat, SwitchEvent, capability::CAP_SWITCH, get_seat, input_devices, on_input_device_removed, on_new_input_device, @@ -40,11 +40,10 @@ use { on_devices_enumerated, on_idle, on_unload, quit, reload, set_animation_cubic_bezier, set_animation_curve, set_animation_duration_ms, set_animation_style, set_animations_enabled, set_autotile, set_color_management_enabled, set_corner_radius, - set_default_workspace_capture, - set_explicit_sync_enabled, set_float_above_fullscreen, set_floating_titles, set_idle, - set_idle_grace_period, set_middle_click_paste_enabled, set_show_bar, - set_show_float_pin_icon, set_show_titles, set_tab_title_align, set_ui_drag_enabled, - set_ui_drag_threshold, + set_default_workspace_capture, set_explicit_sync_enabled, set_float_above_fullscreen, + set_floating_titles, set_idle, set_idle_grace_period, set_middle_click_paste_enabled, + set_show_bar, set_show_float_pin_icon, set_show_titles, set_tab_title_align, + set_ui_drag_enabled, set_ui_drag_threshold, status::{set_i3bar_separator, set_status, set_status_command, unset_status_command}, switch_to_vt, tasks::{self, JoinHandle}, @@ -174,6 +173,9 @@ impl Action { SimpleCommand::Move(dir) => window_or_seat!(s, s.move_(dir)), SimpleCommand::ToggleFullscreen => window_or_seat!(s, s.toggle_fullscreen()), SimpleCommand::SetFullscreen(b) => window_or_seat!(s, s.set_fullscreen(b)), + SimpleCommand::SendToScratchpad => window_or_seat!(s, s.send_to_scratchpad("")), + SimpleCommand::ToggleScratchpad => b.new(move || s.toggle_scratchpad("")), + SimpleCommand::CycleScratchpad => b.new(move || s.cycle_scratchpad("")), SimpleCommand::FocusParent => b.new(move || s.focus_parent()), SimpleCommand::Close => window_or_seat!(s, s.close()), SimpleCommand::DisablePointerConstraint => { @@ -270,12 +272,7 @@ impl Action { SimpleCommand::MoveTabLeft => b.new(move || s.move_tab(false)), SimpleCommand::MoveTabRight => b.new(move || s.move_tab(true)), SimpleCommand::SetAutotile(enabled) => b.new(move || set_autotile(enabled)), - SimpleCommand::ToggleAutotile => { - b.new(move || { - // Toggle not directly supported; set to true - set_autotile(true) - }) - } + SimpleCommand::ToggleAutotile => b.new(move || set_autotile(!get_autotile())), }, Action::Multi { actions } => { let actions: Vec<_> = actions.into_iter().map(|a| a.into_fn(state)).collect(); @@ -312,6 +309,9 @@ impl Action { let workspace = get_workspace(&name); window_or_seat!(s, s.set_workspace(workspace)) } + Action::SendToScratchpad { name } => window_or_seat!(s, s.send_to_scratchpad(&name)), + Action::ToggleScratchpad { name } => b.new(move || s.toggle_scratchpad(&name)), + Action::CycleScratchpad { name } => b.new(move || s.cycle_scratchpad(&name)), Action::ConfigureConnector { con } => b.new(move || { for c in connectors() { if con.match_.matches(c) { @@ -1463,6 +1463,43 @@ fn load_config(initial_load: bool, auto_reload: bool, persistent: &Rc Command { diff --git a/toml-spec/spec/spec.generated.json b/toml-spec/spec/spec.generated.json index 50cc8887..4469c157 100644 --- a/toml-spec/spec/spec.generated.json +++ b/toml-spec/spec/spec.generated.json @@ -162,6 +162,54 @@ "name" ] }, + { + "description": "Sends the currently focused window to a scratchpad and hides it.\n\nA scratchpad can hold any number of windows. If `name` is omitted, the\ndefault scratchpad is used.\n\n- Example:\n\n ```toml\n [shortcuts]\n alt-shift-minus = { type = \"send-to-scratchpad\", name = \"terminal\" }\n ```\n", + "type": "object", + "properties": { + "type": { + "const": "send-to-scratchpad" + }, + "name": { + "type": "string", + "description": "The name of the scratchpad." + } + }, + "required": [ + "type" + ] + }, + { + "description": "Toggles a scratchpad.\n\nIf the scratchpad has a visible window, that window is hidden. Otherwise, the\nmost recently hidden window in the scratchpad is shown on the current workspace.\nOnly one window of a scratchpad is shown at a time, and scratchpad windows are\nalways shown floating. If `name` is omitted, the default scratchpad is used.\n\n- Example:\n\n ```toml\n [shortcuts]\n alt-minus = { type = \"toggle-scratchpad\", name = \"terminal\" }\n ```\n", + "type": "object", + "properties": { + "type": { + "const": "toggle-scratchpad" + }, + "name": { + "type": "string", + "description": "The name of the scratchpad." + } + }, + "required": [ + "type" + ] + }, + { + "description": "Cycles through the windows of a scratchpad, one at a time.\n\nWith no window shown, the first window is brought up. Each further invocation\nhides the current window and shows the next; after the last window the\nscratchpad is hidden again. Scratchpad windows are always shown floating.\nIf `name` is omitted, the default scratchpad is used.\n\n- Example:\n\n ```toml\n [shortcuts]\n alt-minus = { type = \"cycle-scratchpad\", name = \"terminal\" }\n ```\n", + "type": "object", + "properties": { + "type": { + "const": "cycle-scratchpad" + }, + "name": { + "type": "string", + "description": "The name of the scratchpad." + } + }, + "required": [ + "type" + ] + }, { "description": "Moves a workspace to a different output.\n\n- Example 1: Move a specific workspace to a named output\n\n ```toml\n [shortcuts]\n alt-F1 = { type = \"move-to-output\", workspace = \"1\", output.name = \"right\" }\n ```\n\n- Example 2: Move the current workspace to a named output\n\n ```toml\n [shortcuts]\n alt-F1 = { type = \"move-to-output\", output.name = \"right\" }\n ```\n\n- Example 3: Move the current workspace to the output on the right (directional)\n\n ```toml\n [shortcuts]\n \"logo+ctrl+shift+Right\" = { type = \"move-to-output\", direction = \"right\" }\n \"logo+ctrl+shift+Left\" = { type = \"move-to-output\", direction = \"left\" }\n \"logo+ctrl+shift+Up\" = { type = \"move-to-output\", direction = \"up\" }\n \"logo+ctrl+shift+Down\" = { type = \"move-to-output\", direction = \"down\" }\n ```\n", "type": "object", @@ -1209,6 +1257,10 @@ "type": "boolean", "description": "Configures whether middle-click pasting is enabled.\n\nChanging this has no effect on running applications.\n\nThe default is `true`.\n" }, + "autotile": { + "type": "boolean", + "description": "Configures whether autotiling is enabled by default.\n\nWhen enabled, newly mapped tiled windows alternate their split\norientation automatically. This can also be toggled at runtime via the\n`enable-autotile`, `disable-autotile`, and `toggle-autotile` actions.\n\nThe default is `false`.\n" + }, "modes": { "description": "Configures the input modes.\n\nModes can be used to define shortcuts that are only active when the mode is\nactive.\n\n- Example\n\n ```toml\n [modes.\"navigation\".shortcuts]\n w = \"focus-up\"\n a = \"focus-left\"\n s = \"focus-down\"\n d = \"focus-right\"\n r = \"focus-above\"\n f = \"focus-below\"\n q = \"focus-prev\"\n e = \"focus-next\"\n ```\n\nModes can be activated with the `push-mode` and `latch-mode` actions.\n", "type": "object", @@ -1236,6 +1288,14 @@ "egui": { "description": "Sets the egui settings of the compositor.\n", "$ref": "#/$defs/Egui" + }, + "scratchpads": { + "type": "array", + "description": "An array of pre-configured scratchpads.\n\nEach entry launches a program when the graphics are first initialized and\nimmediately parks its window in the named scratchpad. The window is captured\nvia a unique tag attached to the spawned process, so other windows of the\nsame application are never affected.\n\nUse a `toggle-scratchpad` or `cycle-scratchpad` action to bring the windows\nup; they are always shown floating.\n\n- Example:\n\n ```toml\n [[scratchpads]]\n name = \"term\"\n exec = \"foot\"\n\n [[scratchpads]]\n name = \"notes\"\n exec = [\"obsidian\"]\n ```\n", + "items": { + "description": "", + "$ref": "#/$defs/Scratchpad" + } } }, "required": [] @@ -2050,6 +2110,23 @@ }, "required": [] }, + "Scratchpad": { + "description": "A pre-configured scratchpad whose program is launched at startup and parked\nin the scratchpad.\n\n- Example:\n\n ```toml\n [[scratchpads]]\n name = \"term\"\n exec = \"foot\"\n ```\n", + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "The name of the scratchpad that the spawned window is parked in." + }, + "exec": { + "description": "The program to launch when the graphics are first initialized.\n\nIf omitted, no program is launched and the scratchpad is only created on\ndemand by `send-to-scratchpad`.\n", + "$ref": "#/$defs/Exec" + } + }, + "required": [ + "name" + ] + }, "SimpleActionName": { "type": "string", "description": "The name of a `simple` Action.\n\nWhen used inside a window rule, the following actions apply to the matched window\ninstead fo the focused window:\n\n- `move-left`\n- `move-down`\n- `move-up`\n- `move-right`\n- `toggle-fullscreen`\n- `enter-fullscreen`\n- `exit-fullscreen`\n- `close`\n- `toggle-floating`\n- `float`\n- `tile`\n- `toggle-float-pinned`\n- `pin-float`\n- `unpin-float`\n\n\n- Example:\n\n ```toml\n [shortcuts]\n alt-q = \"quit\"\n ```\n", @@ -2068,9 +2145,15 @@ "make-group-tab", "change-group-opposite", "toggle-tab", + "enable-autotile", + "disable-autotile", + "toggle-autotile", "toggle-fullscreen", "enter-fullscreen", "exit-fullscreen", + "send-to-scratchpad", + "toggle-scratchpad", + "cycle-scratchpad", "focus-parent", "close", "disable-pointer-constraint", diff --git a/toml-spec/spec/spec.generated.md b/toml-spec/spec/spec.generated.md index a31a3767..21682ada 100644 --- a/toml-spec/spec/spec.generated.md +++ b/toml-spec/spec/spec.generated.md @@ -286,6 +286,76 @@ This table is a tagged union. The variant is determined by the `type` field. It The value of this field should be a string. +- `send-to-scratchpad`: + + Sends the currently focused window to a scratchpad and hides it. + + A scratchpad can hold any number of windows. If `name` is omitted, the + default scratchpad is used. + + - Example: + + ```toml + [shortcuts] + alt-shift-minus = { type = "send-to-scratchpad", name = "terminal" } + ``` + + The table has the following fields: + + - `name` (optional): + + The name of the scratchpad. + + The value of this field should be a string. + +- `toggle-scratchpad`: + + Toggles a scratchpad. + + If the scratchpad has a visible window, that window is hidden. Otherwise, the + most recently hidden window in the scratchpad is shown on the current workspace. + Only one window of a scratchpad is shown at a time, and scratchpad windows are + always shown floating. If `name` is omitted, the default scratchpad is used. + + - Example: + + ```toml + [shortcuts] + alt-minus = { type = "toggle-scratchpad", name = "terminal" } + ``` + + The table has the following fields: + + - `name` (optional): + + The name of the scratchpad. + + The value of this field should be a string. + +- `cycle-scratchpad`: + + Cycles through the windows of a scratchpad, one at a time. + + With no window shown, the first window is brought up. Each further invocation + hides the current window and shows the next; after the last window the + scratchpad is hidden again. Scratchpad windows are always shown floating. + If `name` is omitted, the default scratchpad is used. + + - Example: + + ```toml + [shortcuts] + alt-minus = { type = "cycle-scratchpad", name = "terminal" } + ``` + + The table has the following fields: + + - `name` (optional): + + The name of the scratchpad. + + The value of this field should be a string. + - `move-to-output`: Moves a workspace to a different output. @@ -1007,6 +1077,7 @@ The string should have one of the following values: supported plan exists. + ### `Animations` @@ -1029,7 +1100,7 @@ The table has the following fields: - `enabled` (optional): Enables or disables window animations. - + The default is `false`. The value of this field should be a boolean. @@ -1037,7 +1108,7 @@ The table has the following fields: - `duration-ms` (optional): Sets the animation duration in milliseconds. - + The default is `160`. The value of this field should be a number. @@ -1047,7 +1118,7 @@ The table has the following fields: - `style` (optional): Sets the animation style used for tiled window movement animations. - + The default is `multiphase`. The value of this field should be a [AnimationStyle](#types-AnimationStyle). @@ -1055,7 +1126,7 @@ The table has the following fields: - `curve` (optional): Sets the animation curve. - + The default is `ease-out`. The value of this field should be a [AnimationCurve](#types-AnimationCurve). @@ -2291,11 +2362,11 @@ The table has the following fields: - `animations` (optional): Configures window animations. - + Animations are disabled by default. - + - Example: - + ```toml [animations] enabled = true @@ -2489,6 +2560,18 @@ The table has the following fields: The value of this field should be a boolean. +- `autotile` (optional): + + Configures whether autotiling is enabled by default. + + When enabled, newly mapped tiled windows alternate their split + orientation automatically. This can also be toggled at runtime via the + `enable-autotile`, `disable-autotile`, and `toggle-autotile` actions. + + The default is `false`. + + The value of this field should be a boolean. + - `modes` (optional): Configures the input modes. @@ -2589,6 +2672,32 @@ The table has the following fields: The value of this field should be a [Egui](#types-Egui). +- `scratchpads` (optional): + + An array of pre-configured scratchpads. + + Each entry launches a program when the graphics are first initialized and + immediately parks its window in the named scratchpad. The window is captured + via a unique tag attached to the spawned process, so other windows of the + same application are never affected. + + Use a `toggle-scratchpad` or `cycle-scratchpad` action to bring the windows + up; they are always shown floating. + + - Example: + + ```toml + [[scratchpads]] + name = "term" + exec = "foot" + + [[scratchpads]] + name = "notes" + exec = ["obsidian"] + ``` + + The value of this field should be an array of [Scratchpads](#types-Scratchpad). + ### `Connector` @@ -4522,6 +4631,40 @@ The table has the following fields: The value of this field should be a string. + +### `Scratchpad` + +A pre-configured scratchpad whose program is launched at startup and parked +in the scratchpad. + +- Example: + + ```toml + [[scratchpads]] + name = "term" + exec = "foot" + ``` + +Values of this type should be tables. + +The table has the following fields: + +- `name` (required): + + The name of the scratchpad that the spawned window is parked in. + + The value of this field should be a string. + +- `exec` (optional): + + The program to launch when the graphics are first initialized. + + If omitted, no program is launched and the scratchpad is only created on + demand by `send-to-scratchpad`. + + The value of this field should be a [Exec](#types-Exec). + + ### `SimpleActionName` @@ -4613,6 +4756,18 @@ The string should have one of the following values: Toggles the current group between tabbed and split mode. +- `enable-autotile`: + + Enables alternating split orientation for newly tiled windows. + +- `disable-autotile`: + + Disables alternating split orientation for newly tiled windows. + +- `toggle-autotile`: + + Toggles alternating split orientation for newly tiled windows. + - `toggle-fullscreen`: Toggle the currently focused window between fullscreen and windowed. @@ -4625,6 +4780,18 @@ The string should have one of the following values: Makes the currently focused window windowed. +- `send-to-scratchpad`: + + Sends the currently focused window to the default scratchpad. + +- `toggle-scratchpad`: + + Toggles the default scratchpad. + +- `cycle-scratchpad`: + + Cycles through the windows of the default scratchpad. + - `focus-parent`: Focus the parent of the currently focused window. @@ -5807,3 +5974,4 @@ The table has the following fields: The value of this field should be a [XScalingMode](#types-XScalingMode). + diff --git a/toml-spec/spec/spec.yaml b/toml-spec/spec/spec.yaml index 706c016a..315c74b9 100644 --- a/toml-spec/spec/spec.yaml +++ b/toml-spec/spec/spec.yaml @@ -345,6 +345,64 @@ Action: description: The name of the workspace. required: true kind: string + send-to-scratchpad: + description: | + Sends the currently focused window to a scratchpad and hides it. + + A scratchpad can hold any number of windows. If `name` is omitted, the + default scratchpad is used. + + - Example: + + ```toml + [shortcuts] + alt-shift-minus = { type = "send-to-scratchpad", name = "terminal" } + ``` + fields: + name: + description: The name of the scratchpad. + required: false + kind: string + toggle-scratchpad: + description: | + Toggles a scratchpad. + + If the scratchpad has a visible window, that window is hidden. Otherwise, the + most recently hidden window in the scratchpad is shown on the current workspace. + Only one window of a scratchpad is shown at a time, and scratchpad windows are + always shown floating. If `name` is omitted, the default scratchpad is used. + + - Example: + + ```toml + [shortcuts] + alt-minus = { type = "toggle-scratchpad", name = "terminal" } + ``` + fields: + name: + description: The name of the scratchpad. + required: false + kind: string + cycle-scratchpad: + description: | + Cycles through the windows of a scratchpad, one at a time. + + With no window shown, the first window is brought up. Each further invocation + hides the current window and shows the next; after the last window the + scratchpad is hidden again. Scratchpad windows are always shown floating. + If `name` is omitted, the default scratchpad is used. + + - Example: + + ```toml + [shortcuts] + alt-minus = { type = "cycle-scratchpad", name = "terminal" } + ``` + fields: + name: + description: The name of the scratchpad. + required: false + kind: string move-to-output: description: | Moves a workspace to a different output. @@ -1064,12 +1122,24 @@ SimpleActionName: description: Toggles the current group's direction. - value: toggle-tab description: Toggles the current group between tabbed and split mode. + - value: enable-autotile + description: Enables alternating split orientation for newly tiled windows. + - value: disable-autotile + description: Disables alternating split orientation for newly tiled windows. + - value: toggle-autotile + description: Toggles alternating split orientation for newly tiled windows. - value: toggle-fullscreen description: Toggle the currently focused window between fullscreen and windowed. - value: enter-fullscreen description: Makes the currently focused window fullscreen. - value: exit-fullscreen description: Makes the currently focused window windowed. + - value: send-to-scratchpad + description: Sends the currently focused window to the default scratchpad. + - value: toggle-scratchpad + description: Toggles the default scratchpad. + - value: cycle-scratchpad + description: Cycles through the windows of the default scratchpad. - value: focus-parent description: Focus the parent of the currently focused window. - value: close @@ -3129,10 +3199,21 @@ Config: required: false description: | Configures whether middle-click pasting is enabled. - + Changing this has no effect on running applications. The default is `true`. + autotile: + kind: boolean + required: false + description: | + Configures whether autotiling is enabled by default. + + When enabled, newly mapped tiled windows alternate their split + orientation automatically. This can also be toggled at runtime via the + `enable-autotile`, `disable-autotile`, and `toggle-autotile` actions. + + The default is `false`. modes: kind: map values: @@ -3229,6 +3310,61 @@ Config: required: false description: | Sets the egui settings of the compositor. + scratchpads: + kind: array + items: + ref: Scratchpad + required: false + description: | + An array of pre-configured scratchpads. + + Each entry launches a program when the graphics are first initialized and + immediately parks its window in the named scratchpad. The window is captured + via a unique tag attached to the spawned process, so other windows of the + same application are never affected. + + Use a `toggle-scratchpad` or `cycle-scratchpad` action to bring the windows + up; they are always shown floating. + + - Example: + + ```toml + [[scratchpads]] + name = "term" + exec = "foot" + + [[scratchpads]] + name = "notes" + exec = ["obsidian"] + ``` + + +Scratchpad: + kind: table + description: | + A pre-configured scratchpad whose program is launched at startup and parked + in the scratchpad. + + - Example: + + ```toml + [[scratchpads]] + name = "term" + exec = "foot" + ``` + fields: + name: + kind: string + required: true + description: The name of the scratchpad that the spawned window is parked in. + exec: + ref: Exec + required: false + description: | + The program to launch when the graphics are first initialized. + + If omitted, no program is launched and the scratchpad is only created on + demand by `send-to-scratchpad`. Idle: