1
0
Fork 0
forked from wry/wry

Compare commits

..

6 commits

57 changed files with 1418 additions and 952 deletions

View file

@ -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 In mono mode, scroll over the title bar to cycle between windows in the
container. 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 ## Fullscreen
Press `alt-u` (`toggle-fullscreen`) to make the focused window fill the entire Press `alt-u` (`toggle-fullscreen`) to make the focused window fill the entire

View file

@ -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.

View file

@ -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:
```

View file

@ -640,6 +640,22 @@ impl ConfigClient {
self.send(&ClientMessage::SetWindowWorkspace { window, workspace }); 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 { pub fn seat_split(&self, seat: Seat) -> Axis {
let res = self.send_with_response(&ClientMessage::GetSeatSplit { seat }); let res = self.send_with_response(&ClientMessage::GetSeatSplit { seat });
get_response!(res, Axis::Horizontal, GetSplit { axis }); get_response!(res, Axis::Horizontal, GetSplit { axis });
@ -2079,6 +2095,12 @@ impl ConfigClient {
self.send(&ClientMessage::SetAutotile { enabled }); 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) { pub fn set_tab_title_align(&self, align: u32) {
self.send(&ClientMessage::SetTabTitleAlign { align }); self.send(&ClientMessage::SetTabTitleAlign { align });
} }

View file

@ -286,6 +286,18 @@ pub enum ClientMessage<'a> {
seat: Seat, seat: Seat,
workspace: Workspace, workspace: Workspace,
}, },
SeatSendToScratchpad {
seat: Seat,
name: &'a str,
},
SeatToggleScratchpad {
seat: Seat,
name: &'a str,
},
SeatCycleScratchpad {
seat: Seat,
name: &'a str,
},
GetTimer { GetTimer {
name: &'a str, name: &'a str,
}, },
@ -687,6 +699,10 @@ pub enum ClientMessage<'a> {
window: Window, window: Window,
workspace: Workspace, workspace: Workspace,
}, },
WindowSendToScratchpad {
window: Window,
name: &'a str,
},
SetWindowFullscreen { SetWindowFullscreen {
window: Window, window: Window,
fullscreen: bool, fullscreen: bool,
@ -923,6 +939,7 @@ pub enum ClientMessage<'a> {
SetAutotile { SetAutotile {
enabled: bool, enabled: bool,
}, },
GetAutotile,
SetTabTitleAlign { SetTabTitleAlign {
align: u32, align: u32,
}, },
@ -1189,6 +1206,9 @@ pub enum Response {
GetCornerRadius { GetCornerRadius {
radius: f32, radius: f32,
}, },
GetAutotile {
enabled: bool,
},
} }
#[derive(Serialize, Deserialize, Debug)] #[derive(Serialize, Deserialize, Debug)]

View file

@ -466,6 +466,33 @@ impl Seat {
get!().set_seat_workspace(self, workspace) 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. /// Toggles whether the currently focused window is fullscreen.
pub fn toggle_fullscreen(self) { pub fn toggle_fullscreen(self) {
let c = get!(); let c = get!();

View file

@ -453,14 +453,21 @@ pub fn get_corner_radius() -> f32 {
/// Enables or disables autotiling. /// Enables or disables autotiling.
/// ///
/// When enabled, new windows are automatically placed in a perpendicular /// When enabled, newly tiled windows alternate split orientation from the
/// sub-container if the predicted body would be narrower than tall (or vice versa). /// 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`. /// The default is `false`.
pub fn set_autotile(enabled: bool) { pub fn set_autotile(enabled: bool) {
get!().set_autotile(enabled) 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. /// Sets the horizontal alignment of title text within tab buttons.
/// ///
/// - `"start"` — left-aligned (default) /// - `"start"` — left-aligned (default)

View file

@ -205,6 +205,13 @@ impl Window {
get!().set_window_workspace(self, workspace) 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. /// Toggles whether the currently focused window is fullscreen.
pub fn toggle_fullscreen(self) { pub fn toggle_fullscreen(self) {
self.set_fullscreen(!self.fullscreen()) self.set_fullscreen(!self.fullscreen())

View file

@ -403,6 +403,7 @@ fn start_compositor2(
bo_drop_queue: Rc::new(ObjectDropQueue::new(&ring)), bo_drop_queue: Rc::new(ObjectDropQueue::new(&ring)),
virtual_outputs: Default::default(), virtual_outputs: Default::default(),
clean_logs_older_than: Default::default(), clean_logs_older_than: Default::default(),
scratchpads: Default::default(),
}); });
state.tracker.register(ClientId::from_raw(0)); state.tracker.register(ClientId::from_raw(0));
create_dummy_output(&state); create_dummy_output(&state);

View file

@ -1100,6 +1100,32 @@ impl ConfigProxyHandler {
Ok(()) 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> { fn handle_set_window_workspace(&self, window: Window, ws: Workspace) -> Result<(), CphError> {
let window = self.get_window(window)?; let window = self.get_window(window)?;
let name = self.get_workspace(ws)?; let name = self.get_workspace(ws)?;
@ -1114,6 +1140,14 @@ impl ConfigProxyHandler {
Ok(()) 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> { fn handle_get_device_name(&self, device: InputDevice) -> Result<(), CphError> {
let dev = self.get_device_handler_data(device)?; let dev = self.get_device_handler_data(device)?;
let name = dev.device.name(); let name = dev.device.name();
@ -2989,6 +3023,15 @@ impl ConfigProxyHandler {
ClientMessage::SetSeatWorkspace { seat, workspace } => self ClientMessage::SetSeatWorkspace { seat, workspace } => self
.handle_set_seat_workspace(seat, workspace) .handle_set_seat_workspace(seat, workspace)
.wrn("set_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 } => { ClientMessage::GetConnector { ty, idx } => {
self.handle_get_connector(ty, idx).wrn("get_connector")? self.handle_get_connector(ty, idx).wrn("get_connector")?
} }
@ -3373,6 +3416,9 @@ impl ConfigProxyHandler {
ClientMessage::SetWindowWorkspace { window, workspace } => self ClientMessage::SetWindowWorkspace { window, workspace } => self
.handle_set_window_workspace(window, workspace) .handle_set_window_workspace(window, workspace)
.wrn("set_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 ClientMessage::SetWindowFullscreen { window, fullscreen } => self
.handle_set_window_fullscreen(window, fullscreen) .handle_set_window_fullscreen(window, fullscreen)
.wrn("set_window_fullscreen")?, .wrn("set_window_fullscreen")?,
@ -3587,6 +3633,11 @@ impl ConfigProxyHandler {
ClientMessage::SetAutotile { enabled } => { ClientMessage::SetAutotile { enabled } => {
self.state.theme.autotile_enabled.set(enabled); self.state.theme.autotile_enabled.set(enabled);
} }
ClientMessage::GetAutotile => {
self.respond(Response::GetAutotile {
enabled: self.state.theme.autotile_enabled.get(),
});
}
ClientMessage::SeatToggleExpand { .. } => { ClientMessage::SeatToggleExpand { .. } => {
// Removed feature; kept for binary protocol compatibility. // Removed feature; kept for binary protocol compatibility.
} }

View file

@ -1520,25 +1520,25 @@ impl WlSurface {
let bounds = self.toplevel.get().and_then(|tl| tl.tl_render_bounds()); let bounds = self.toplevel.get().and_then(|tl| tl.tl_render_bounds());
let pos = self.buffer_abs_pos.get(); let pos = self.buffer_abs_pos.get();
let apply_damage = |pos: Rect| { let apply_damage = |pos: Rect| {
if pending.damage_full { let clip_damage = |mut damage: Rect| {
let mut damage = pos; damage = damage.intersect(pos);
if let Some(bounds) = bounds { if let Some(bounds) = bounds {
damage = damage.intersect(bounds); damage = damage.intersect(bounds);
} }
self.client.state.damage(damage); damage
};
if pending.damage_full {
self.client.state.damage(clip_damage(pos));
} else { } else {
let matrix = self.damage_matrix.get(); let matrix = self.damage_matrix.get();
if let Some(buffer) = self.buffer.get() { if let Some(buffer) = self.buffer.get() {
for damage in &pending.buffer_damage { for damage in &pending.buffer_damage {
let mut damage = matrix.apply( let damage = matrix.apply(
pos.x1(), pos.x1(),
pos.y1(), pos.y1(),
damage.intersect(buffer.buffer.buf.rect), damage.intersect(buffer.buffer.buf.rect),
); );
if let Some(bounds) = bounds { self.client.state.damage(clip_damage(damage));
damage = damage.intersect(bounds);
}
self.client.state.damage(damage);
} }
} }
for damage in &pending.surface_damage { for damage in &pending.surface_damage {
@ -1550,8 +1550,7 @@ impl WlSurface {
let y2 = (damage.y2() + scale - 1) / scale; let y2 = (damage.y2() + scale - 1) / scale;
damage = Rect::new_saturating(x1, y1, x2, y2); damage = Rect::new_saturating(x1, y1, x2, y2);
} }
damage = damage.intersect(bounds.unwrap_or(pos)); self.client.state.damage(clip_damage(damage));
self.client.state.damage(damage);
} }
} }
}; };

View file

@ -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) { fn clear(&self) {
unsafe { unsafe {
if let Some(srv) = self.srv.take() { if let Some(srv) = self.srv.take() {
@ -331,6 +352,10 @@ impl TestConfig {
pub fn set_show_titles(&self, show: bool) -> TestResult { pub fn set_show_titles(&self, show: bool) -> TestResult {
self.send(ClientMessage::SetShowTitles { show }) self.send(ClientMessage::SetShowTitles { show })
} }
pub fn set_autotile(&self, enabled: bool) -> TestResult {
self.send(ClientMessage::SetAutotile { enabled })
}
} }
impl Drop for TestConfig { impl Drop for TestConfig {

View file

@ -29,6 +29,17 @@ impl TestViewport {
Ok(()) 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> { pub fn set_destination(&self, width: i32, height: i32) -> Result<(), TestError> {
self.tran.send(SetDestination { self.tran.send(SetDestination {
self_id: self.id, self_id: self.id,
@ -37,6 +48,15 @@ impl TestViewport {
})?; })?;
Ok(()) 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 { impl Drop for TestViewport {

View file

@ -85,6 +85,8 @@ mod t0051_pointer_warp;
mod t0052_bar; mod t0052_bar;
mod t0053_theme; mod t0053_theme;
mod t0054_subsurface_already_attached; mod t0054_subsurface_already_attached;
mod t0055_autotiling;
mod t0055_scratchpad;
pub trait TestCase: Sync { pub trait TestCase: Sync {
fn name(&self) -> &'static str; fn name(&self) -> &'static str;
@ -158,5 +160,7 @@ pub fn tests() -> Vec<&'static dyn TestCase> {
t0052_bar, t0052_bar,
t0053_theme, t0053_theme,
t0054_subsurface_already_attached, t0054_subsurface_already_attached,
t0055_autotiling,
t0055_scratchpad,
} }
} }

View file

@ -1,7 +1,6 @@
use { use {
crate::{ crate::{
it::{test_error::TestError, testrun::TestRun}, it::{test_error::TestError, testrun::TestRun},
rect::Rect,
tree::Node, tree::Node,
}, },
std::rc::Rc, std::rc::Rc,
@ -11,29 +10,19 @@ testcase!();
/// Create and map a single surface /// Create and map a single surface
async fn test(run: Rc<TestRun>) -> Result<(), TestError> { async fn test(run: Rc<TestRun>) -> Result<(), TestError> {
run.backend.install_default()?; let ds = run.create_default_setup().await?;
let client = run.create_client().await?; let client = run.create_client().await?;
let window = client.create_window().await?; let window = client.create_window().await?;
window.map().await?; window.map().await?;
tassert_eq!(window.tl.core.width.get(), 800); let workspace_rect = ds.output.workspace_rect.get();
tassert_eq!(
window.tl.core.height.get(),
600 - 2 * run.state.theme.title_plus_underline_height()
);
tassert_eq!( tassert_eq!(window.tl.core.width.get(), workspace_rect.width());
window.tl.server.node_absolute_position(), tassert_eq!(window.tl.core.height.get(), workspace_rect.height());
Rect::new_sized(
0, tassert_eq!(window.tl.server.node_absolute_position(), workspace_rect);
2 * run.state.theme.title_plus_underline_height(),
window.tl.core.width.get(),
window.tl.core.height.get(),
)
.unwrap()
);
Ok(()) Ok(())
} }

View file

@ -11,7 +11,7 @@ testcase!();
/// Create and map two surfaces /// Create and map two surfaces
async fn test(run: Rc<TestRun>) -> Result<(), TestError> { async fn test(run: Rc<TestRun>) -> Result<(), TestError> {
run.backend.install_default()?; let ds = run.create_default_setup().await?;
let client = run.create_client().await?; let client = run.create_client().await?;
@ -21,17 +21,30 @@ async fn test(run: Rc<TestRun>) -> Result<(), TestError> {
let window2 = client.create_window().await?; let window2 = client.create_window().await?;
window2.map().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 bw = run.state.theme.sizes.border_width.get();
let child_width = (workspace_rect.width() - bw) / 2;
tassert_eq!( tassert_eq!(
window.tl.server.node_absolute_position(), 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!( tassert_eq!(
window2.tl.server.node_absolute_position(), 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(()) Ok(())

View file

@ -48,13 +48,18 @@ async fn test(run: Rc<TestRun>) -> TestResult {
let mono_container = w_mono2.tl.container_parent()?; let mono_container = w_mono2.tl.container_parent()?;
let container_pos = mono_container.tl_data().pos.get(); let container_pos = mono_container.tl_data().pos.get();
let w_mono1_title = mono_container.render_data.borrow_mut().title_rects[0] let (tab_x, tab_y) = {
.move_(container_pos.x1(), container_pos.y1()); let tab_bar = mono_container.tab_bar.borrow();
ds.mouse.abs( let Some(tab_bar) = tab_bar.as_ref() else {
&ds.connector, bail!("no tab bar");
w_mono1_title.x1() as _, };
w_mono1_title.y1() as _, 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; client.sync().await;
tassert!(enters.next().is_err()); tassert!(enters.next().is_err());

View file

@ -26,12 +26,18 @@ async fn test(run: Rc<TestRun>) -> TestResult {
let container = w_mono2.tl.container_parent()?; let container = w_mono2.tl.container_parent()?;
let pos = container.tl_data().pos.get(); let pos = container.tl_data().pos.get();
let w_mono1_title = container.render_data.borrow_mut().title_rects[0].move_(pos.x1(), pos.y1()); let (tab_x, tab_y) = {
ds.mouse.abs( let tab_bar = container.tab_bar.borrow();
&ds.connector, let Some(tab_bar) = tab_bar.as_ref() else {
w_mono1_title.x1() as f64, bail!("no tab bar");
w_mono1_title.y1() as f64, };
); 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; client.sync().await;
let enters = dss.kb.enter.expect()?; let enters = dss.kb.enter.expect()?;

View file

@ -2,7 +2,7 @@ use {
crate::{ crate::{
ifs::wl_surface::xdg_surface::xdg_toplevel::STATE_SUSPENDED, ifs::wl_surface::xdg_surface::xdg_toplevel::STATE_SUSPENDED,
it::{ it::{
test_error::TestResult, test_error::{TestErrorExt, TestResult},
test_utils::{ test_utils::{
test_ouput_node_ext::TestOutputNodeExt, test_toplevel_node_ext::TestToplevelNodeExt, test_ouput_node_ext::TestOutputNodeExt, test_toplevel_node_ext::TestToplevelNodeExt,
}, },
@ -10,7 +10,7 @@ use {
}, },
}, },
isnt::std_1::collections::IsntHashSetExt, isnt::std_1::collections::IsntHashSetExt,
std::rc::Rc, std::{rc::Rc, time::Duration},
}; };
testcase!(); testcase!();
@ -19,6 +19,7 @@ async fn test(run: Rc<TestRun>) -> TestResult {
let ds = run.create_default_setup().await?; let ds = run.create_default_setup().await?;
let client = run.create_client().await?; let client = run.create_client().await?;
let default_seat = client.get_default_seat().await?;
let win1 = client.create_window().await?; let win1 = client.create_window().await?;
win1.set_color(255, 0, 0, 255); win1.set_color(255, 0, 0, 255);
@ -44,5 +45,23 @@ async fn test(run: Rc<TestRun>) -> TestResult {
client.sync().await; client.sync().await;
tassert!(win2.tl.core.states.borrow().not_contains(&STATE_SUSPENDED)); 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(()) Ok(())
} }

View file

@ -308,9 +308,8 @@ async fn test(run: Rc<TestRun>) -> TestResult {
let output_damage = connector_data.damage.borrow(); let output_damage = connector_data.damage.borrow();
tassert!(!output_damage.is_empty()); tassert!(!output_damage.is_empty());
// Buffer damage is transformed by the damage matrix which includes the surface position // The test window maps its 1x1 buffer through a viewport to the full window size.
// The buffer damage (0,0,1,1) should be transformed to surface coordinates let expected_buffer_damage = surface_pos;
let expected_buffer_damage = buffer_damage.move_(surface_pos.x1(), surface_pos.y1());
// Find the exact output damage that matches our expected buffer damage // Find the exact output damage that matches our expected buffer damage
let mut found_exact_buffer_damage = false; let mut found_exact_buffer_damage = false;
@ -331,10 +330,12 @@ async fn test(run: Rc<TestRun>) -> TestResult {
// Test 7: Check output damage from existing window's viewport (which already has scaling) // Test 7: Check output damage from existing window's viewport (which already has scaling)
connector_data.damage.borrow_mut().clear(); connector_data.damage.borrow_mut().clear();
// The existing window was created with create_surface_ext() which automatically creates a viewport // 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 // Commit the viewport size change separately; that commit intentionally damages the old/new extents.
// First, let's modify the viewport scaling that already exists on the window window.surface.viewport.set_destination(150, 100)?;
window.surface.viewport.set_destination(150, 100)?; // Change scaling to 150x100 window.surface.commit()?;
client.sync().await;
connector_data.damage.borrow_mut().clear();
// Add buffer damage to test viewport scaling coordinate transformation // Add buffer damage to test viewport scaling coordinate transformation
window.surface.damage_buffer(0, 0, 1, 1)?; // Damage entire 1x1 buffer window.surface.damage_buffer(0, 0, 1, 1)?; // Damage entire 1x1 buffer
@ -346,8 +347,8 @@ async fn test(run: Rc<TestRun>) -> TestResult {
let output_damage = connector_data.damage.borrow(); let output_damage = connector_data.damage.borrow();
tassert!(!output_damage.is_empty()); tassert!(!output_damage.is_empty());
// With viewporter scaling, the 1x1 buffer damage should scale to 150x100 // With viewporter scaling, the 1x1 buffer damage should scale to the viewport destination.
// and be moved by surface position (0, 36) to get output coordinates (0, 36, 150, 136) let surface_pos = window.surface.server.buffer_abs_pos.get();
let expected_scaled_damage = Rect::new_sized(0, 0, 150, 100).unwrap(); let expected_scaled_damage = Rect::new_sized(0, 0, 150, 100).unwrap();
let expected_output_damage = let expected_output_damage =
expected_scaled_damage.move_(surface_pos.x1(), surface_pos.y1()); expected_scaled_damage.move_(surface_pos.x1(), surface_pos.y1());
@ -402,8 +403,9 @@ async fn test(run: Rc<TestRun>) -> TestResult {
rotation_window.map().await?; rotation_window.map().await?;
client.sync().await; client.sync().await;
// Disable viewporter by setting destination to 0x0 to rely purely on buffer dimensions // Disable viewporter to rely purely on buffer dimensions.
rotation_window.surface.viewport.set_destination(0, 0)?; // Disable viewporter rotation_window.surface.viewport.unset_source()?;
rotation_window.surface.viewport.unset_destination()?;
// Use a rectangular buffer (4x2) so rotation has a visible geometric effect // 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 // Attach AFTER mapping to avoid being overwritten by map()'s single-pixel buffer

View file

@ -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<TestRun>) -> 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(())
}

View file

@ -0,0 +1,107 @@
use {
crate::{
it::{test_error::TestResult, testrun::TestRun},
tree::{Node, ToplevelNodeBase},
},
std::rc::Rc,
};
testcase!();
async fn test(run: Rc<TestRun>) -> 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(())
}

View file

@ -114,9 +114,11 @@ use {
tree::{ tree::{
ContainerNode, ContainerSplit, Direction, DisplayNode, FindTreeUsecase, FloatNode, ContainerNode, ContainerSplit, Direction, DisplayNode, FindTreeUsecase, FloatNode,
FoundNode, LatchListener, Node, NodeId, NodeIds, NodeVisitorBase, OutputNode, FoundNode, LatchListener, Node, NodeId, NodeIds, NodeVisitorBase, OutputNode,
PlaceholderNode, TearingMode, TileState, ToplevelData, ToplevelIdentifier, PlaceholderNode, TearingMode, TileState, ToplevelData,
ToplevelNode, ToplevelNodeBase, Transform, VrrMode, WorkspaceDisplayOrder, ToplevelIdentifier, ToplevelNode, ToplevelNodeBase, Transform, VrrMode,
WorkspaceNode, WorkspaceNodeId, WsMoveConfig, generic_node_visitor, move_ws_to_output, WorkspaceDisplayOrder, WorkspaceNode, WorkspaceNodeId, WsMoveConfig,
generic_node_visitor, move_ws_to_output, toplevel_hide_for_scratchpad,
toplevel_restore_from_scratchpad, toplevel_set_workspace,
}, },
udmabuf::UdmabufHolder, udmabuf::UdmabufHolder,
utils::{ utils::{
@ -412,6 +414,7 @@ pub struct State {
pub bo_drop_queue: Rc<ObjectDropQueue<Rc<dyn BufferObject>>>, pub bo_drop_queue: Rc<ObjectDropQueue<Rc<dyn BufferObject>>>,
pub virtual_outputs: VirtualOutputs, pub virtual_outputs: VirtualOutputs,
pub clean_logs_older_than: Cell<Option<SystemTime>>, pub clean_logs_older_than: Cell<Option<SystemTime>>,
pub scratchpads: RefCell<AHashMap<String, Vec<Rc<ScratchpadEntry>>>>,
} }
// impl Drop for State { // impl Drop for State {
@ -459,6 +462,27 @@ pub struct IdleState {
pub in_grace_period: Cell<bool>, pub in_grace_period: Cell<bool>,
} }
pub struct ScratchpadEntry {
node: Weak<dyn ToplevelNode>,
identifier: ToplevelIdentifier,
hidden: Cell<bool>,
}
impl ScratchpadEntry {
fn alive(&self) -> bool {
self.node().is_some()
}
fn node(&self) -> Option<Rc<dyn ToplevelNode>> {
let node = self.node.upgrade()?;
if node.tl_data().identifier.get() == self.identifier {
Some(node)
} else {
None
}
}
}
impl IdleState { impl IdleState {
pub fn set_timeout(&self, state: &State, timeout: Duration) { pub fn set_timeout(&self, state: &State, timeout: Duration) {
self.timeout.set(timeout); self.timeout.set(timeout);
@ -925,19 +949,39 @@ impl State {
&& node.tl_data().kind.is_app_window() && node.tl_data().kind.is_app_window()
&& !node.tl_data().visible.get(); && !node.tl_data().visible.get();
if animate_new_app_map { 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 { } 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()); self.focus_after_map(node, seat.as_deref());
} }
fn do_map_tiled(self: &Rc<Self>, seat: Option<&Rc<WlSeatGlobal>>, node: Rc<dyn ToplevelNode>) { pub fn map_tiled_without_autotile(self: &Rc<Self>, node: Rc<dyn ToplevelNode>) {
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<Self>,
seat: Option<&Rc<WlSeatGlobal>>,
node: Rc<dyn ToplevelNode>,
autotile: bool,
) {
let ws = self.ensure_map_workspace(seat); 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<Self>, node: Rc<dyn ToplevelNode>, ws: &Rc<WorkspaceNode>) { pub fn map_tiled_on(self: &Rc<Self>, node: Rc<dyn ToplevelNode>, ws: &Rc<WorkspaceNode>) {
self.map_tiled_on_(node, ws, false);
}
fn map_tiled_on_(
self: &Rc<Self>,
node: Rc<dyn ToplevelNode>,
ws: &Rc<WorkspaceNode>,
autotile: bool,
) {
if let Some(c) = ws.container.get() { if let Some(c) = ws.container.get() {
let la = c.clone().tl_last_active_child(); let la = c.clone().tl_last_active_child();
let lap = la let lap = la
@ -946,7 +990,11 @@ impl State {
.get() .get()
.and_then(|n| n.node_into_container()); .and_then(|n| n.node_into_container());
if let Some(lap) = lap { 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 { } else {
c.append_child(node); c.append_child(node);
} }
@ -999,6 +1047,146 @@ impl State {
float float
} }
pub fn send_to_scratchpad(self: &Rc<Self>, name: &str, node: Rc<dyn ToplevelNode>) {
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<Self>, seat: &Rc<WlSeatGlobal>, 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<Self>, seat: &Rc<WlSeatGlobal>, 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) = &current {
self.hide_scratchpad_entry(current);
}
if let Some(next) = &next {
self.show_scratchpad_entry(seat, name, next);
}
}
fn hide_scratchpad_entry(self: &Rc<Self>, entry: &Rc<ScratchpadEntry>) {
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<Self>,
seat: &Rc<WlSeatGlobal>,
name: &str,
entry: &Rc<ScratchpadEntry>,
) {
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<Self>,
seat: &Rc<WlSeatGlobal>,
entry: &Rc<ScratchpadEntry>,
) {
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<dyn ToplevelNode>, seat: Option<&Rc<WlSeatGlobal>>) { fn focus_after_map(&self, node: Rc<dyn ToplevelNode>, seat: Option<&Rc<WlSeatGlobal>>) {
if !node.node_visible() { if !node.node_visible() {
return; return;
@ -1274,6 +1462,7 @@ impl State {
self.node_at_tree.borrow_mut().clear(); self.node_at_tree.borrow_mut().clear();
self.position_hint_requests.clear(); self.position_hint_requests.clear();
self.pending_warp_mouse_to_focus.clear(); self.pending_warp_mouse_to_focus.clear();
self.scratchpads.borrow_mut().clear();
self.head_managers.clear(); self.head_managers.clear();
self.head_managers_async.clear(); self.head_managers_async.clear();
self.const_40hz_latch.clear(); self.const_40hz_latch.clear();

View file

@ -32,6 +32,7 @@ use {
numcell::NumCell, numcell::NumCell,
on_drop_event::OnDropEvent, on_drop_event::OnDropEvent,
rc_eq::rc_eq, rc_eq::rc_eq,
scroller::Scroller,
threshold_counter::ThresholdCounter, threshold_counter::ThresholdCounter,
}, },
}, },
@ -150,6 +151,7 @@ pub struct ContainerNode {
pub child_removed: Rc<LazyEventSource>, pub child_removed: Rc<LazyEventSource>,
pub all_children_resized: Rc<LazyEventSource>, pub all_children_resized: Rc<LazyEventSource>,
pub tab_bar: RefCell<Option<TabBar>>, pub tab_bar: RefCell<Option<TabBar>>,
scroll: Scroller,
pub update_tab_textures_scheduled: Cell<bool>, pub update_tab_textures_scheduled: Cell<bool>,
pub ephemeral: Cell<Ephemeral>, pub ephemeral: Cell<Ephemeral>,
} }
@ -266,6 +268,7 @@ impl ContainerNode {
child_removed: state.lazy_event_sources.create_source(), child_removed: state.lazy_event_sources.create_source(),
all_children_resized: state.post_layout_event_sources.create_source(), all_children_resized: state.post_layout_event_sources.create_source(),
tab_bar: RefCell::new(None), tab_bar: RefCell::new(None),
scroll: Default::default(),
update_tab_textures_scheduled: Cell::new(false), update_tab_textures_scheduled: Cell::new(false),
ephemeral: Cell::new(Ephemeral::Off), 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)); self.add_child_x(prev, new, |prev, new| self.add_child_after_(prev, new));
} }
pub fn add_tiled_child_after(self: &Rc<Self>, prev: &dyn Node, new: Rc<dyn ToplevelNode>) {
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<Self>, prev: &dyn Node, new: Rc<dyn ToplevelNode>) { pub fn add_child_before(self: &Rc<Self>, prev: &dyn Node, new: Rc<dyn ToplevelNode>) {
self.add_child_x(prev, new, |prev, new| self.add_child_before_(prev, new)); 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); self.activate_child2(child, false);
} }
fn activate_child_from_input(
self: &Rc<Self>,
child: &NodeRef<ContainerChild>,
seat: &Rc<WlSeatGlobal>,
) {
self.activate_child(child);
child
.node
.clone()
.node_do_focus(seat, Direction::Unspecified);
}
fn activate_child2(self: &Rc<Self>, child: &NodeRef<ContainerChild>, preserve_focus: bool) { fn activate_child2(self: &Rc<Self>, child: &NodeRef<ContainerChild>, preserve_focus: bool) {
if let Some(mc) = self.mono_child.get() { if let Some(mc) = self.mono_child.get() {
if mc.node.node_id() == child.node.node_id() { if mc.node.node_id() == child.node.node_id() {
@ -1369,42 +1425,6 @@ impl ContainerNode {
} }
pub fn insert_child(self: &Rc<Self>, node: Rc<dyn ToplevelNode>, direction: Direction) { pub fn insert_child(self: &Rc<Self>, node: Rc<dyn ToplevelNode>, 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); let (split, right) = direction_to_split(direction);
if split != self.split.get() || right { if split != self.split.get() || right {
self.append_child(node); self.append_child(node);
@ -1514,7 +1534,7 @@ impl ContainerNode {
fn button( fn button(
self: Rc<Self>, self: Rc<Self>,
id: CursorType, id: CursorType,
_seat: &Rc<WlSeatGlobal>, seat: &Rc<WlSeatGlobal>,
_time_usec: u64, _time_usec: u64,
pressed: bool, pressed: bool,
button: u32, button: u32,
@ -1544,7 +1564,7 @@ impl ContainerNode {
if let Some(child) = children.get(&child_id) { if let Some(child) = children.get(&child_id) {
let child_ref = child.to_ref(); let child_ref = child.to_ref();
drop(children); drop(children);
self.activate_child(&child_ref); self.activate_child_from_input(&child_ref, seat);
} }
return; return;
} }
@ -2061,31 +2081,33 @@ impl Node for ContainerNode {
self.button(id, seat, time_usec, state == ButtonState::Pressed, button); self.button(id, seat, time_usec, state == ButtonState::Pressed, button);
} }
fn node_on_axis_event(self: Rc<Self>, _seat: &Rc<WlSeatGlobal>, event: &PendingScroll) { fn node_on_axis_event(self: Rc<Self>, seat: &Rc<WlSeatGlobal>, event: &PendingScroll) {
if self.mono_child.is_none() { if self.mono_child.is_none() {
return; return;
} }
// Use vertical scroll (index 1) to switch tabs. let steps = match self.scroll.handle(event) {
let v = match event.v120[1].get() { Some(steps) => steps,
Some(v) if v != 0 => v,
_ => return, _ => return,
}; };
let mono = match self.mono_child.get() { let mut target = match self.mono_child.get() {
Some(m) => m, Some(m) => m,
None => return, None => return,
}; };
let next = if v > 0 { let current_id = target.node.node_id();
// Scroll down → next tab. for _ in 0..steps.abs() {
mono.next().or_else(|| self.children.first()) let next = if steps > 0 {
} else { target.next().or_else(|| self.children.first())
// Scroll up → previous tab. } else {
mono.prev().or_else(|| self.children.last()) target.prev().or_else(|| self.children.last())
}; };
if let Some(next) = next { match next {
if next.node.node_id() != mono.node.node_id() { Some(next) => target = next,
self.activate_child(&next); None => break,
} }
} }
if target.node.node_id() != current_id {
self.activate_child_from_input(&target, seat);
}
} }
fn node_on_leave(&self, seat: &WlSeatGlobal) { fn node_on_leave(&self, seat: &WlSeatGlobal) {

View file

@ -8,18 +8,25 @@ use {
renderer::Renderer, renderer::Renderer,
state::State, state::State,
tree::{ tree::{
FindTreeResult, FindTreeUsecase, FoundNode, Node, NodeId, NodeLayerLink, NodeLocation, Direction, FindTreeResult, FindTreeUsecase, FoundNode, Node, NodeId, NodeLayerLink,
OutputNode, StackedNode, TileDragDestination, WorkspaceDragDestination, NodeLocation, OutputNode, StackedNode, TileDragDestination, WorkspaceDragDestination,
WorkspaceNodeId, walker::NodeVisitor, WorkspaceNodeId, walker::NodeVisitor,
}, },
utils::{copyhashmap::CopyHashMap, linkedlist::LinkedList}, 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 struct DisplayNode {
pub id: NodeId, pub id: NodeId,
pub extents: Cell<Rect>, pub extents: Cell<Rect>,
visible: Cell<bool>,
suspend_restore_kb_foci: RefCell<Vec<(Rc<WlSeatGlobal>, Weak<dyn Node>)>>,
pub outputs: CopyHashMap<ConnectorId, Rc<OutputNode>>, pub outputs: CopyHashMap<ConnectorId, Rc<OutputNode>>,
pub stacked: Rc<LinkedList<Rc<dyn StackedNode>>>, pub stacked: Rc<LinkedList<Rc<dyn StackedNode>>>,
pub stacked_above_layers: Rc<LinkedList<Rc<dyn StackedNode>>>, pub stacked_above_layers: Rc<LinkedList<Rc<dyn StackedNode>>>,
@ -31,6 +38,8 @@ impl DisplayNode {
let slf = Self { let slf = Self {
id, id,
extents: Default::default(), extents: Default::default(),
visible: Default::default(),
suspend_restore_kb_foci: Default::default(),
outputs: Default::default(), outputs: Default::default(),
stacked: Default::default(), stacked: Default::default(),
stacked_above_layers: Default::default(), stacked_above_layers: Default::default(),
@ -71,6 +80,17 @@ impl DisplayNode {
pub fn update_visible(&self, state: &State) { pub fn update_visible(&self, state: &State) {
let visible = state.root_visible(); 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() { for output in self.outputs.lock().values() {
output.update_visible(); output.update_visible();
} }
@ -82,6 +102,20 @@ impl DisplayNode {
for seat in state.globals.seats.lock().values() { for seat in state.globals.seats.lock().values() {
seat.set_visible(visible); 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 { if visible {
state.damage(self.extents.get()); state.damage(self.extents.get());
} }

View file

@ -979,7 +979,7 @@ impl ToplevelData {
} }
fd.workspace.remove_fullscreen_node(); fd.workspace.remove_fullscreen_node();
if fd.placeholder.is_destroyed() { if fd.placeholder.is_destroyed() {
state.map_tiled(node); state.map_tiled_without_autotile(node);
return; return;
} }
let parent = fd.placeholder.tl_data().parent.take().unwrap(); let parent = fd.placeholder.tl_data().parent.take().unwrap();
@ -1262,7 +1262,7 @@ pub fn toplevel_set_floating(state: &Rc<State>, tl: Rc<dyn ToplevelNode>, floati
}; };
if !floating { if !floating {
parent.cnode_remove_child2(&*tl, true); parent.cnode_remove_child2(&*tl, true);
state.map_tiled(tl); state.map_tiled_without_autotile(tl);
} else if let Some(ws) = data.workspace.get() { } else if let Some(ws) = data.workspace.get() {
let node_id = data.node_id; let node_id = data.node_id;
let old_body = let old_body =
@ -1323,3 +1323,54 @@ pub fn toplevel_set_workspace(state: &Rc<State>, tl: Rc<dyn ToplevelNode>, ws: &
tl.tl_set_fullscreen(true, Some(ws.clone())); 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<dyn ToplevelNode>) -> 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<State>,
tl: Rc<dyn ToplevelNode>,
ws: &Rc<WorkspaceNode>,
) {
let (width, height) = tl.tl_data().float_size(ws);
state.map_floating(tl.clone(), width, height, ws, None);
}

View file

@ -64,6 +64,9 @@ pub enum SimpleCommand {
SetFloating(bool), SetFloating(bool),
ToggleFullscreen, ToggleFullscreen,
SetFullscreen(bool), SetFullscreen(bool),
SendToScratchpad,
ToggleScratchpad,
CycleScratchpad,
Forward(bool), Forward(bool),
EnableWindowManagement(bool), EnableWindowManagement(bool),
SetFloatAboveFullscreen(bool), SetFloatAboveFullscreen(bool),
@ -130,6 +133,15 @@ pub enum Action {
MoveToWorkspace { MoveToWorkspace {
name: String, name: String,
}, },
SendToScratchpad {
name: String,
},
ToggleScratchpad {
name: String,
},
CycleScratchpad {
name: String,
},
Multi { Multi {
actions: Vec<Action>, actions: Vec<Action>,
}, },
@ -600,6 +612,14 @@ pub struct Config {
pub simple_im: Option<SimpleIm>, pub simple_im: Option<SimpleIm>,
pub fallback_output_mode: Option<FallbackOutputMode>, pub fallback_output_mode: Option<FallbackOutputMode>,
pub mouse_follows_focus: Option<bool>, pub mouse_follows_focus: Option<bool>,
pub scratchpads: Vec<Scratchpad>,
pub autotile: Option<bool>,
}
#[derive(Debug, Clone)]
pub struct Scratchpad {
pub name: String,
pub exec: Option<Exec>,
} }
#[derive(Debug, Error)] #[derive(Debug, Error)]

View file

@ -41,6 +41,7 @@ pub mod modified_keysym;
mod output; mod output;
mod output_match; mod output_match;
mod repeat_rate; mod repeat_rate;
mod scratchpad;
pub mod shortcuts; pub mod shortcuts;
mod simple_im; mod simple_im;
mod status; mod status;

View file

@ -117,6 +117,9 @@ impl ActionParser<'_> {
"toggle-fullscreen" => ToggleFullscreen, "toggle-fullscreen" => ToggleFullscreen,
"enter-fullscreen" => SetFullscreen(true), "enter-fullscreen" => SetFullscreen(true),
"exit-fullscreen" => SetFullscreen(false), "exit-fullscreen" => SetFullscreen(false),
"send-to-scratchpad" => SendToScratchpad,
"toggle-scratchpad" => ToggleScratchpad,
"cycle-scratchpad" => CycleScratchpad,
"focus-parent" => FocusParent, "focus-parent" => FocusParent,
"close" => Close, "close" => Close,
"disable-pointer-constraint" => DisablePointerConstraint, "disable-pointer-constraint" => DisablePointerConstraint,
@ -222,6 +225,33 @@ impl ActionParser<'_> {
Ok(Action::MoveToWorkspace { name }) Ok(Action::MoveToWorkspace { name })
} }
fn parse_send_to_scratchpad(&mut self, ext: &mut Extractor<'_>) -> ParseResult<Self> {
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<Self> {
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<Self> {
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<Self> { fn parse_configure_connector(&mut self, ext: &mut Extractor<'_>) -> ParseResult<Self> {
let con = ext let con = ext
.extract(val("connector"))? .extract(val("connector"))?
@ -551,6 +581,9 @@ impl Parser for ActionParser<'_> {
"switch-to-vt" => self.parse_switch_to_vt(&mut ext), "switch-to-vt" => self.parse_switch_to_vt(&mut ext),
"show-workspace" => self.parse_show_workspace(&mut ext), "show-workspace" => self.parse_show_workspace(&mut ext),
"move-to-workspace" => self.parse_move_to_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-connector" => self.parse_configure_connector(&mut ext),
"configure-input" => self.parse_configure_input(&mut ext), "configure-input" => self.parse_configure_input(&mut ext),
"configure-output" => self.parse_configure_output(&mut ext), "configure-output" => self.parse_configure_output(&mut ext),

View file

@ -28,6 +28,7 @@ use {
log_level::LogLevelParser, log_level::LogLevelParser,
output::OutputsParser, output::OutputsParser,
repeat_rate::RepeatRateParser, repeat_rate::RepeatRateParser,
scratchpad::ScratchpadsParser,
shortcuts::{ shortcuts::{
ComplexShortcutsParser, ShortcutsParser, ShortcutsParserError, ComplexShortcutsParser, ShortcutsParser, ShortcutsParserError,
parse_modified_keysym_str, parse_modified_keysym_str,
@ -156,6 +157,7 @@ impl Parser for ConfigParser<'_> {
mouse_follows_focus, mouse_follows_focus,
animations_val, animations_val,
), ),
(scratchpads_val, autotile),
) = ext.extract(( ) = ext.extract((
( (
opt(val("keymap")), opt(val("keymap")),
@ -217,6 +219,7 @@ impl Parser for ConfigParser<'_> {
recover(opt(bol("unstable-mouse-follows-focus"))), recover(opt(bol("unstable-mouse-follows-focus"))),
opt(val("animations")), opt(val("animations")),
), ),
(opt(val("scratchpads")), recover(opt(bol("autotile")))),
))?; ))?;
let mut keymap = None; let mut keymap = None;
if let Some(value) = keymap_val { 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 { Ok(Config {
keymap, keymap,
repeat_rate, repeat_rate,
@ -618,6 +628,8 @@ impl Parser for ConfigParser<'_> {
simple_im, simple_im,
fallback_output_mode, fallback_output_mode,
mouse_follows_focus: mouse_follows_focus.despan(), mouse_follows_focus: mouse_follows_focus.despan(),
scratchpads,
autotile: autotile.despan(),
}) })
} }
} }

View file

@ -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<String>, Spanned<Value>>,
) -> ParseResult<Self> {
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<Scratchpad>;
type Error = ScratchpadParserError;
const EXPECTED: &'static [DataType] = &[DataType::Table, DataType::Array];
fn parse_array(&mut self, _span: Span, array: &[Spanned<Value>]) -> ParseResult<Self> {
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<String>, Spanned<Value>>,
) -> ParseResult<Self> {
log::warn!(
"`scratchpads` value should be an array: {}",
self.0.error3(span)
);
ScratchpadParser(self.0)
.parse_table(span, table)
.map(|v| vec![v])
}
}

View file

@ -15,7 +15,7 @@ use {
config::{ config::{
Action, AnimationCurveConfig, ClientRule, Config, ConfigConnector, ConfigDrmDevice, Action, AnimationCurveConfig, ClientRule, Config, ConfigConnector, ConfigDrmDevice,
ConfigKeymap, ConnectorMatch, DrmDeviceMatch, Exec, Input, InputMatch, Output, 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}, rules::{MatcherTemp, RuleMapper},
shortcuts::ModeState, shortcuts::ModeState,
@ -27,7 +27,7 @@ use {
client::Client, client::Client,
config, config_dir, config, config_dir,
exec::{Command, set_env, unset_env}, exec::{Command, set_env, unset_env},
get_workspace, get_autotile, get_workspace,
input::{ input::{
FocusFollowsMouseMode, InputDevice, Seat, SwitchEvent, capability::CAP_SWITCH, FocusFollowsMouseMode, InputDevice, Seat, SwitchEvent, capability::CAP_SWITCH,
get_seat, input_devices, on_input_device_removed, on_new_input_device, 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, on_devices_enumerated, on_idle, on_unload, quit, reload, set_animation_cubic_bezier,
set_animation_curve, set_animation_duration_ms, set_animation_style, set_animation_curve, set_animation_duration_ms, set_animation_style,
set_animations_enabled, set_autotile, set_color_management_enabled, set_corner_radius, set_animations_enabled, set_autotile, set_color_management_enabled, set_corner_radius,
set_default_workspace_capture, set_default_workspace_capture, set_explicit_sync_enabled, set_float_above_fullscreen,
set_explicit_sync_enabled, set_float_above_fullscreen, set_floating_titles, set_idle, set_floating_titles, set_idle, set_idle_grace_period, set_middle_click_paste_enabled,
set_idle_grace_period, set_middle_click_paste_enabled, set_show_bar, set_show_bar, set_show_float_pin_icon, set_show_titles, set_tab_title_align,
set_show_float_pin_icon, set_show_titles, set_tab_title_align, set_ui_drag_enabled, set_ui_drag_enabled, set_ui_drag_threshold,
set_ui_drag_threshold,
status::{set_i3bar_separator, set_status, set_status_command, unset_status_command}, status::{set_i3bar_separator, set_status, set_status_command, unset_status_command},
switch_to_vt, switch_to_vt,
tasks::{self, JoinHandle}, tasks::{self, JoinHandle},
@ -174,6 +173,9 @@ impl Action {
SimpleCommand::Move(dir) => window_or_seat!(s, s.move_(dir)), SimpleCommand::Move(dir) => window_or_seat!(s, s.move_(dir)),
SimpleCommand::ToggleFullscreen => window_or_seat!(s, s.toggle_fullscreen()), SimpleCommand::ToggleFullscreen => window_or_seat!(s, s.toggle_fullscreen()),
SimpleCommand::SetFullscreen(b) => window_or_seat!(s, s.set_fullscreen(b)), 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::FocusParent => b.new(move || s.focus_parent()),
SimpleCommand::Close => window_or_seat!(s, s.close()), SimpleCommand::Close => window_or_seat!(s, s.close()),
SimpleCommand::DisablePointerConstraint => { SimpleCommand::DisablePointerConstraint => {
@ -270,12 +272,7 @@ impl Action {
SimpleCommand::MoveTabLeft => b.new(move || s.move_tab(false)), SimpleCommand::MoveTabLeft => b.new(move || s.move_tab(false)),
SimpleCommand::MoveTabRight => b.new(move || s.move_tab(true)), SimpleCommand::MoveTabRight => b.new(move || s.move_tab(true)),
SimpleCommand::SetAutotile(enabled) => b.new(move || set_autotile(enabled)), SimpleCommand::SetAutotile(enabled) => b.new(move || set_autotile(enabled)),
SimpleCommand::ToggleAutotile => { SimpleCommand::ToggleAutotile => b.new(move || set_autotile(!get_autotile())),
b.new(move || {
// Toggle not directly supported; set to true
set_autotile(true)
})
}
}, },
Action::Multi { actions } => { Action::Multi { actions } => {
let actions: Vec<_> = actions.into_iter().map(|a| a.into_fn(state)).collect(); let actions: Vec<_> = actions.into_iter().map(|a| a.into_fn(state)).collect();
@ -312,6 +309,9 @@ impl Action {
let workspace = get_workspace(&name); let workspace = get_workspace(&name);
window_or_seat!(s, s.set_workspace(workspace)) 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 || { Action::ConfigureConnector { con } => b.new(move || {
for c in connectors() { for c in connectors() {
if con.match_.matches(c) { if con.match_.matches(c) {
@ -1463,6 +1463,43 @@ fn load_config(initial_load: bool, auto_reload: bool, persistent: &Rc<Persistent
window: Default::default(), window: Default::default(),
}); });
state.clear_modes_after_reload(); state.clear_modes_after_reload();
// Desugar `[[scratchpads]]` into spawn-on-graphics-init plus an internal
// window rule that parks the spawned window. Each spawned process gets a
// unique tag so only its own windows are captured, never other windows of
// the same application.
if !config.scratchpads.is_empty() {
let mut spawn_actions = vec![];
for (i, sp) in config.scratchpads.drain(..).enumerate() {
let Some(mut exec) = sp.exec else {
continue;
};
let tag = exec
.tag
.clone()
.unwrap_or_else(|| format!("__scratchpad.{i}.{}", sp.name));
exec.tag = Some(tag.clone());
spawn_actions.push(Action::Exec { exec });
config.window_rules.push(WindowRule {
name: None,
match_: WindowMatch {
tag: Some(tag),
..Default::default()
},
action: Some(Action::SendToScratchpad { name: sp.name }),
latch: None,
auto_focus: None,
initial_tile_state: None,
});
}
if !spawn_actions.is_empty() {
let mut actions = Vec::with_capacity(spawn_actions.len() + 1);
if let Some(existing) = config.on_graphics_initialized.take() {
actions.push(existing);
}
actions.extend(spawn_actions);
config.on_graphics_initialized = Some(Action::Multi { actions });
}
}
let (client_rules, client_rule_mapper) = state.create_rules(&config.client_rules); let (client_rules, client_rule_mapper) = state.create_rules(&config.client_rules);
persistent.client_rules.set(client_rules); persistent.client_rules.set(client_rules);
*state.persistent.client_rule_mapper.borrow_mut() = Some(client_rule_mapper); *state.persistent.client_rule_mapper.borrow_mut() = Some(client_rule_mapper);
@ -1747,6 +1784,9 @@ fn load_config(initial_load: bool, auto_reload: bool, persistent: &Rc<Persistent
.seat .seat
.unstable_set_mouse_follows_focus(mouse_follows_focus); .unstable_set_mouse_follows_focus(mouse_follows_focus);
} }
if let Some(v) = config.autotile {
set_autotile(v);
}
} }
fn create_command(exec: &Exec) -> Command { fn create_command(exec: &Exec) -> Command {

View file

@ -162,6 +162,54 @@
"name" "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", "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", "type": "object",
@ -1209,6 +1257,10 @@
"type": "boolean", "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" "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": { "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", "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", "type": "object",
@ -1236,6 +1288,14 @@
"egui": { "egui": {
"description": "Sets the egui settings of the compositor.\n", "description": "Sets the egui settings of the compositor.\n",
"$ref": "#/$defs/Egui" "$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": [] "required": []
@ -2050,6 +2110,23 @@
}, },
"required": [] "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": { "SimpleActionName": {
"type": "string", "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", "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", "make-group-tab",
"change-group-opposite", "change-group-opposite",
"toggle-tab", "toggle-tab",
"enable-autotile",
"disable-autotile",
"toggle-autotile",
"toggle-fullscreen", "toggle-fullscreen",
"enter-fullscreen", "enter-fullscreen",
"exit-fullscreen", "exit-fullscreen",
"send-to-scratchpad",
"toggle-scratchpad",
"cycle-scratchpad",
"focus-parent", "focus-parent",
"close", "close",
"disable-pointer-constraint", "disable-pointer-constraint",

View file

@ -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. 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`: - `move-to-output`:
Moves a workspace to a different output. Moves a workspace to a different output.
@ -1007,6 +1077,7 @@ The string should have one of the following values:
supported plan exists. supported plan exists.
<a name="types-Animations"></a> <a name="types-Animations"></a>
### `Animations` ### `Animations`
@ -1029,7 +1100,7 @@ The table has the following fields:
- `enabled` (optional): - `enabled` (optional):
Enables or disables window animations. Enables or disables window animations.
The default is `false`. The default is `false`.
The value of this field should be a boolean. The value of this field should be a boolean.
@ -1037,7 +1108,7 @@ The table has the following fields:
- `duration-ms` (optional): - `duration-ms` (optional):
Sets the animation duration in milliseconds. Sets the animation duration in milliseconds.
The default is `160`. The default is `160`.
The value of this field should be a number. The value of this field should be a number.
@ -1047,7 +1118,7 @@ The table has the following fields:
- `style` (optional): - `style` (optional):
Sets the animation style used for tiled window movement animations. Sets the animation style used for tiled window movement animations.
The default is `multiphase`. The default is `multiphase`.
The value of this field should be a [AnimationStyle](#types-AnimationStyle). The value of this field should be a [AnimationStyle](#types-AnimationStyle).
@ -1055,7 +1126,7 @@ The table has the following fields:
- `curve` (optional): - `curve` (optional):
Sets the animation curve. Sets the animation curve.
The default is `ease-out`. The default is `ease-out`.
The value of this field should be a [AnimationCurve](#types-AnimationCurve). The value of this field should be a [AnimationCurve](#types-AnimationCurve).
@ -2291,11 +2362,11 @@ The table has the following fields:
- `animations` (optional): - `animations` (optional):
Configures window animations. Configures window animations.
Animations are disabled by default. Animations are disabled by default.
- Example: - Example:
```toml ```toml
[animations] [animations]
enabled = true enabled = true
@ -2489,6 +2560,18 @@ The table has the following fields:
The value of this field should be a boolean. 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): - `modes` (optional):
Configures the input modes. 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). 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).
<a name="types-Connector"></a> <a name="types-Connector"></a>
### `Connector` ### `Connector`
@ -4522,6 +4631,40 @@ The table has the following fields:
The value of this field should be a string. The value of this field should be a string.
<a name="types-Scratchpad"></a>
### `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).
<a name="types-SimpleActionName"></a> <a name="types-SimpleActionName"></a>
### `SimpleActionName` ### `SimpleActionName`
@ -4613,6 +4756,18 @@ The string should have one of the following values:
Toggles the current group between tabbed and split mode. 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-fullscreen`:
Toggle the currently focused window between fullscreen and windowed. 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. 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-parent`:
Focus the parent of the currently focused window. 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). The value of this field should be a [XScalingMode](#types-XScalingMode).

View file

@ -345,6 +345,64 @@ Action:
description: The name of the workspace. description: The name of the workspace.
required: true required: true
kind: string 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: move-to-output:
description: | description: |
Moves a workspace to a different output. Moves a workspace to a different output.
@ -1064,12 +1122,24 @@ SimpleActionName:
description: Toggles the current group's direction. description: Toggles the current group's direction.
- value: toggle-tab - value: toggle-tab
description: Toggles the current group between tabbed and split mode. 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 - value: toggle-fullscreen
description: Toggle the currently focused window between fullscreen and windowed. description: Toggle the currently focused window between fullscreen and windowed.
- value: enter-fullscreen - value: enter-fullscreen
description: Makes the currently focused window fullscreen. description: Makes the currently focused window fullscreen.
- value: exit-fullscreen - value: exit-fullscreen
description: Makes the currently focused window windowed. 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 - value: focus-parent
description: Focus the parent of the currently focused window. description: Focus the parent of the currently focused window.
- value: close - value: close
@ -3129,10 +3199,21 @@ Config:
required: false required: false
description: | description: |
Configures whether middle-click pasting is enabled. Configures whether middle-click pasting is enabled.
Changing this has no effect on running applications. Changing this has no effect on running applications.
The default is `true`. 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: modes:
kind: map kind: map
values: values:
@ -3229,6 +3310,61 @@ Config:
required: false required: false
description: | description: |
Sets the egui settings of the compositor. 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: Idle: