aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authoruser <user@clank>2026-08-20 23:22:33 +0200
committeruser <user@clank>2026-08-20 23:22:33 +0200
commitb33e8c13ff7981effc7ec2735e6aeaaaf1705af7 (patch)
tree4933eb476b5d09e9d8131986c84bb966bca317a2
parentfuzz: libFuzzer harness for all six parser surfaces (diff)
downloadslosh-b33e8c13ff7981effc7ec2735e6aeaaaf1705af7.tar.gz
slosh-b33e8c13ff7981effc7ec2735e6aeaaaf1705af7.tar.xz
slosh-b33e8c13ff7981effc7ec2735e6aeaaaf1705af7.zip
slosh: floating panes and the drop grammar
A pane can float: lifted out of the layout, drawn over the tiled panes and under the modals, freely moved and shaped. The model is a flag and a rect on the leaf, exactly like `minimized` -- the seat in the tree is kept, layout skips it, the siblings absorb its share, and un-floating returns it home. The drawn rect is the wanted one clamped to the tab every frame, never written back, so a terminal squeeze round-trips exactly; an explicit move or resize *does* write back, because the hand is the author of the intent. Floating pops the pane to the centre: the jump is what says it happened. Painted after everything tiled, so the hit list -- searched backwards -- resolves an overlap to the float you can see: the z-order and the click order are the same list. It casts a shadow (float_shadow, one number), and `floating` joins the states table above `unfocused`, so a float is never dimmed as merely-unfocused. The hands: the title moves it, any other border resizes it (a bottom corner on both axes; the grabbed edge pins at walls and floors, never sliding the window), H J K L move a focused float, the =/+ key grows it about its centre, `-` shrinks it (its split was a refusal anyway), and hovering shows which edges a grab would take, from the same derivation the press uses. Equalize moved to `0` to free the key; `C-a f` floats (finder went to `s`, where it reads as search); `C-a F` opens a new floating shell -- the throwaway terminal; the function row f1..f12 became nameable in configs along the way. Guards throughout: the last tiled pane cannot float or minimize away, closing it lands the top float, splits are refused with their own words, floats are not swap targets. Dropping got a grammar: while a pane is carried, the pane under the pointer subdivides into a swap centre and four edge bands meaning "insert me beside this pane, on this side" -- detach_leaf + place_beside, the surgery cross-tab moves already had. Bands are thirds (the first map of quarters showed the swap owning half of each axis), the hovered band fills the half the drop would hand over, the release resolves against the painted frame's hits so it cannot disagree with the fill, and a drop between siblings along the axis is a reorder that always fits -- refusing it was refusing the most ordinary drag there is. Also: rename-pane/rename-tab as palette actions (renaming was a double-click and nothing else, the one verb the palette could not reach), and C-u clears the rename editor like it clears the picker's query. Design log in PLAN-FLOATING-PANES.md; to fold into DESIGN.md as D22/M10-11.
-rw-r--r--PLAN-FLOATING-PANES.md254
-rw-r--r--config/config.kdl42
-rw-r--r--docs/config.md4
-rw-r--r--docs/keys.md30
-rw-r--r--docs/panes.md80
-rw-r--r--include/app.h11
-rw-r--r--include/app_internal.h52
-rw-r--r--include/config.h18
-rw-r--r--src/app.c198
-rw-r--r--src/app_draw.c262
-rw-r--r--src/app_layout.c390
-rw-r--r--src/app_ui.c20
-rw-r--r--src/cmd.c38
-rw-r--r--src/config.c70
-rw-r--r--tests/test_drop_zones.py246
-rwxr-xr-xtests/test_equalize.py12
-rw-r--r--tests/test_finder.py18
-rw-r--r--tests/test_float.py872
-rw-r--r--tests/test_help.py4
-rw-r--r--tests/test_hover.py2
-rw-r--r--tests/test_palette.py44
-rwxr-xr-xtests/test_rotate.py2
-rw-r--r--tests/test_shaders.py25
23 files changed, 2594 insertions, 100 deletions
diff --git a/PLAN-FLOATING-PANES.md b/PLAN-FLOATING-PANES.md
new file mode 100644
index 0000000..f3fdaeb
--- /dev/null
+++ b/PLAN-FLOATING-PANES.md
@@ -0,0 +1,254 @@
+# Floating panes — plan
+
+Status: **M10a and M10b built and green** (56 checks in
+`tests/test_float.py`, full suite passing). When agreed, the decision below
+becomes D22 and the milestone becomes M10 in DESIGN.md.
+
+A floating pane renders on top of the tiled panes — below the modals, the
+toasts and the splash — and can be freely moved and resized. The claim this
+plan defends: **almost all of it already exists**, because the two structural
+commitments (one geometry; layout as a pure function) were designed for
+exactly this shape of feature. The hit list *is* a z-order, and intent-plus-
+derivation *is* how a remembered rect survives a resize.
+
+## The model: a flag and a rect, like `minimized`
+
+A floating pane is **a leaf still in the tree**, carrying two new pieces of
+intent:
+
+```c
+bool floating; /* out of the layout, drawn on top */
+rect_t float_rect; /* where it wants to be, in cells; intent, never clamped
+ in place */
+uint32_t raised; /* monotonic stamp; highest paints last (= on top) */
+```
+
+Not a separate per-tab list. `minimized` already proved the pattern: the leaf
+keeps its seat in the tree, `layout_node` skips it, the siblings absorb its
+share, and un-floating returns it home — same id, same place, nothing that
+referred to it has to be told anything (the D14 property). Everything that
+walks leaves — `pane_by_id`, the finder, `close_leaf`, `dump-layout`, the D6
+flatten — keeps working with zero new cases, because there is no second
+collection to forget to walk.
+
+Rules that fall out of reusing the pattern:
+
+- **The last tiled pane cannot float**, same guard and same reason as
+ minimise: a tab that is only an overlay is an overlay over nothing.
+- **Floating and minimized compose.** A minimized float sits in the strip like
+ anyone; restoring it restores it floating. Two orthogonal flags, no matrix.
+- **Closing a float is `close_leaf`**, unchanged. Its tree seat collapses like
+ any leaf's.
+
+### The drawn rect is derived, the wanted rect is remembered
+
+`float_rect` is what you asked for. What is painted each frame is
+`clamp(float_rect, tab_area)` — inside the tab's content area (below the tab
+strip, above the status line; those rows own global verbs and nothing may
+cover them), floored at `MIN_PANE_COLS/ROWS` plus the frame. The clamp **never
+writes back**: shrink the terminal and the float is squeezed in; grow it again
+and the float is exactly where you put it. Same rule as the layout tree's
+rects — recomputed every frame, never trusted between them.
+
+Floating an existing tiled pane seeds `float_rect` from its current rect inset
+by one cell per side, so the transition reads as the pane lifting off the page
+rather than teleporting.
+
+### Z-order is the raise stamp, and the hit list makes it honest
+
+Floats paint in ascending `raised`; focusing a float stamps it. That is the
+whole z-machine. Because `hit_test` searches the hit list **backwards**, the
+float painted last owns the topmost hits automatically — a click lands on
+what you see, with no routing code written. The modals still paint after the
+floats, so a picker over a float behaves today's way for free. This is the
+"one geometry" commitment paying out: the z-order cannot disagree with the
+click order because they are the same list.
+
+Draw order in `app_compose` becomes:
+
+```
+tab strip → tree → corners → min bar → status line
+→ floats, in raise order ← new
+→ scrim → picker/help → toasts → splash
+```
+
+## Input
+
+**Mouse.** All from the hit list, like the existing four drag verbs:
+
+| target on a floating frame | drag does |
+|---|---|
+| the title row | **move** the float (`DRAG_FLOAT_MOVE`) — not swap |
+| any border cell / corner | **resize** from that side (`DRAG_FLOAT_RESIZE`) |
+
+A press that never moves is a click (the distinction the drag machine already
+makes): click anywhere on a float focuses and raises it. The ▬ □ ✕ buttons
+keep their verbs. A float's frame registers `floatmove:`/`floatresize:`
+actions instead of `border:`/`edge:`, so the split guide never arms on it —
+derived from the hit list, so it cannot half-arm. **Splitting a float is
+refused with a word**: a split is a statement about the tree's arrangement,
+and a float is precisely not arranged. Any keystroke ends a drag, as today.
+
+**Keyboard.** One toggle action, `float`, reachable from the palette on day
+one (D19 gives that for free). For a focused float, the existing resize
+family gets a second meaning the same way it already means "move the
+boundary of whatever is focused": `H J K L` **moves** the float by a step;
+resize gets its own chord (open question below). `h j k l` stays focus
+movement — floats participate in `focus_dir` geometrically, by their drawn
+rects, and in `focus_next` by tree order, both for free.
+
+## Interactions with what exists
+
+- **Zoom**: unchanged. A zoomed pane fills the tab whether it floats or not
+ (zoom reuses the solo path); unzooming a float returns it to `float_rect`.
+- **D6 flatten**: a flattened tab lists floats with everyone else — they are
+ leaves in the tree, so the flatten already includes them. A screen too
+ small for tiles is too small for overlays; `float_rect` survives untouched
+ for when room returns.
+- **Shader states (D13/D20)**: a new `floating` pane state in the table, so a
+ float can be told apart by colour. Ranked ambient, below `bell`. Ships no
+ default tint (a float is live; nothing needs explaining) — the *shadow*
+ does that job instead: one cell of dimming at (+1,+1) outside the frame,
+ painted by the float pass over cells already composed. Derived every frame,
+ knob `float_shadow`, default on.
+- **Kitty graphics (D17)** — the one genuinely new cost. The cell diff gets
+ occlusion right by paint order, but placements are emitted *after* the
+ diff and would paint an image from a tiled pane over a float covering it.
+ v1 rule: a placement clipped by a float from one clean side is **cropped**
+ (the source-rectangle machinery from D17, pointed at a second clipper);
+ a float landing in the *middle* of an image is a shape one placement
+ cannot express, so that placement is **suppressed for the frame** and
+ returns when the float moves. A float's own images already work — cropping
+ at the pane edge is the same code — and are emitted with a kitty `z=` above
+ the tiled panes' placements, matching the cells.
+- **Connections/clients, D10**: untouched; this is all server-side compose.
+
+## Persistence and the control API
+
+- **Layout files (D2)**: pane nodes take `floating=true x= y= w= h=`, both
+ loaders, both schemas linted the usual way. `dump-layout` writes it, so
+ `save-workspace` (D21) records a float and a restored session floats it.
+- **Control API (D3)**: `{"cmd":"float","id":N}` toggles;
+ `{"cmd":"float","id":N,"x":..,"y":..,"w":..,"h":..}` places. `panes`
+ reports `floating` and the rect. Bare-verb alias runs the same code.
+
+## Tests (headless, per the harness)
+
+- float a pane → snapshot: its cells overwrite the tiled cells under it;
+ hit list resolves the overlap to the float.
+- two floats → focus the lower one → it is on top next frame (raise = focus).
+- drag-move and drag-resize via `send` of SGR mouse; a keystroke mid-drag
+ ends it.
+- shrink the terminal under a float → clamped; grow it back → **exact**
+ original rect (the fork's collapse/expand lesson, asserted for floats).
+- flatten a tab containing a float → the float is a row, reachable.
+- dump → apply → the float is back, floating, placed.
+- refuse: floating the last tiled pane; splitting a float.
+
+## Milestone M10, staged
+
+1. **M10a — the model.** ✅ Flag, rect, stamp; layout skips it; draw pass after
+ the status line; clamp; focus/raise; `float` action + palette; the guard
+ rules; tests above minus the drags. Three deltas found by building it:
+ - A bare `{"cmd":"float","id":N}` toggle shipped early (the harness is a
+ client; placement arguments stay M10c).
+ - The backdrop invariant needs a landing rule, not just a guard: closing
+ or moving away the last tiled pane un-floats the top float
+ (`ensure_tiled`, checked once in `layout()` like the minimised-focus
+ rule). The minimise guard tightened to match: another float is not
+ something to show.
+ - A float refuses splits through `split_fits` returning false, which
+ reaches the guide, the border click and the keyboard floor check in one
+ place; the keyboard, the border release and the API each add the words
+ ("a floating pane cannot be split").
+2. **M10b — the hands.** ✅ `DRAG_FLOAT_MOVE`/`DRAG_FLOAT_RESIZE`, keyboard
+ move, the shadow, the `floating` shader state. What building it decided:
+ - **A grabbed edge pins at the wall.** The general intent clamp preserves
+ the size by sliding the rect, which turns "drag the right edge past the
+ wall" into the whole window walking left — so `float_resize` clamps each
+ grabbed edge against the tab area itself and never moves the opposite
+ one. Which edges are held is derived from where the press landed on the
+ rect painted that frame, so a bottom corner is two edges for free.
+ - **Explicit moves write intent back.** A drag or keyboard nudge applies
+ its delta to the rect *as drawn* and, after the layout clamps, writes
+ the clamped answer back into `float_rect`: the hand is the author of the
+ intent, so "where you see it is where it stays". A terminal resize still
+ never writes back. Refused while the float is not drawn as a float
+ (flattened or zoomed), where writing the shown rect back would destroy
+ the place it returns to.
+ - **The float verbs are their own drag kinds**, not flags on
+ `DRAG_TITLE`/`DRAG_BORDER`: everything those mean on release — swap,
+ split — is exactly what a float must not do. The rename double-click on
+ a float's name still wins, and a float is excluded as a swap *target*
+ too (its tree seat is not where it is).
+ - **`floating` ranks above `unfocused`**, so a float is never dimmed by
+ `dim_unfocused`: full strength is what keeps the thing on top reading
+ as lifted — the `dragging` argument. It ships no chain; the shadow does
+ the telling apart.
+ - **The shadow is a number, not a state** (`float_shadow`, default 110,
+ 0 for off) — the `dim_unfocused` argument about discoverability. Cast
+ as the scrim's own dim pass over two non-overlapping strips
+ (`gap_aspect` columns beside, one row below, offset so the light comes
+ from the top left), before the float paints, so it falls on lower
+ floats too.
+ - `H J K L` on a focused float move it one gap-aspect-square step;
+ keyboard *resize* of a float remains open (question 2). A float's title
+ also does not target the tab strip yet — moving between tabs stays
+ `pane-to-next-tab`'s job for now.
+ - **The border says it is a handle** (added after a hand went looking):
+ hovering lights the edge a grab would move in the resize colour, with
+ the `⇔`/`⇕` arrow at the grab point; a corner lights both edges; the
+ held edges stay lit through the drag; the status hint says "drag to
+ resize (both ways)" and a float's title handle stops promising the
+ split it does not offer. All read from one derivation
+ (`float_edges_at`) shared with the press, so the promise, the paint
+ and the drag cannot disagree. Finding this also found an ordering bug:
+ the status line — which reads the hit list for its hover hint — was
+ painted *before* the floats, so a float's border hinted as the pane
+ underneath. Floats now paint before the status line (their rects
+ cannot overlap; the clamp sees to it), which also keeps a float's
+ shadow off the status row on a gap-less config.
+3. **M10c — the memory.** Layout schema, dump, control API, the graphics
+ occlusion rule.
+
+## Open questions
+
+1. ~~**The default key.**~~ **Resolved: `C-a f` floats.** Both float and the
+ finder wanted the letter; the tie went to the verb — f is what "float"
+ sounds like, and a float toggle is pressed in a flow (lift, look, put
+ back) where a reach hurts. The finder moved to `C-a s`, where it reads
+ just as naturally as *search*.
+2. ~~**Keyboard resize chord**~~ **Resolved: the =/+ key grows, `-`
+ shrinks**, about the float's own centre so it stays under your eyes.
+ Grow is bound with and without shift because a legacy terminal reports
+ `+` as a bare `=` (the `?`/`/` deal), which is also why **equalize moved
+ to `0`** rather than sharing the key: two verbs on one key survive only
+ until a terminal drops the shift — and `0` reads as "reset the shares"
+ on the digit row the tabs already own. Shrink rides the split-rows case,
+ where a float's split was a refusal anyway.
+3. ~~**Docking**~~ **Resolved: unfloat is the dock.** The float kept its
+ seat all along, so `C-a f` on a focused float already lands it — no drop
+ gesture needed.
+4. ~~**Creating panes floating**~~ **Resolved: `C-a F` / `new-float`** — the
+ throwaway terminal. A real leaf beside the focused pane (so un-floating
+ lands it there and `exit` closes it under D14's shell rule), born
+ floating at the centred default size, in the focused pane's directory.
+ `f`/`F` is a shifted pair: float this pane / float a fresh one. (It
+ spent a day on F12, the IDE key for the same idea, and came back — a
+ leader-and-letter chord is what every other verb costs, and the function
+ row is a reach the home row is not. The detour still paid: `f1`..`f12`
+ joined NAMED_KEYS, so the function row is bindable by any config — the
+ decoder always understood it, the config just had no names for it.) The
+ layout-file case stays M10c.
+5. **Graphics crop vs suppress** — start with the simple rule and let a real
+ program complain.
+
+## A later decision: the pop to the centre
+
+Floating a pane now **always centres it** (M10b originally lifted it in
+place, inset a cell). The jump is what says the float *happened* — a pane
+lifted in place looks like a pane whose neighbours flinched. The size is
+still the one remembered from the last time a hand shaped it (or seeded
+from the pane's tiled rect); only the position is opinionated, because the
+position is the announcement. This traded away "refloat returns to where
+you parked it" deliberately: position is re-announced, size is remembered.
diff --git a/config/config.kdl b/config/config.kdl
index 27f842d..a39df7e 100644
--- a/config/config.kdl
+++ b/config/config.kdl
@@ -238,6 +238,14 @@ keep_dead "commands"
// replaces it, and `states { unfocused { } }` means nothing at all.
dim_unfocused 60
+// How dark the cell of shade a floating pane casts on what it covers,
+// 0..255, and 0 for no shadow. A float wears the same frame as a tile, so
+// the shadow is what tells the two apart at a glance -- which is why it
+// ships on, and why it is a number rather than a `states { floating { } }`
+// chain: turning it off should not require knowing the states block exists.
+// (The floating *state* exists too, empty by default, for colour on top.)
+float_shadow 110
+
bell_indicator true
// What to draw. A short string rather than a fixed glyph, because the obvious
@@ -452,6 +460,7 @@ modal_scrim 120
// suspended laid out, never started (its label is its contents)
// bell a BEL arrived and nobody has looked since; ends when you do
// scrolled looking at scrollback rather than at the present
+// floating lifted out of the layout, drawn on top (see float_shadow)
// unfocused the weakest thing that can be true of a pane
//
// The order is fixed rather than read from this block, because it is a ranking
@@ -483,6 +492,10 @@ modal_scrim 120
// taste was never the taste, it was that turning it off should not require
// knowing this block exists. `dragging` still has no default: the pane in
// your hand keeping its colour is what makes it lift off the page.
+// `floating` has none for the same reason -- full strength is what keeps
+// the thing on top reading as lifted -- and because it ranks above
+// unfocused, a float is never dimmed as merely-unfocused. Its telling-apart
+// is the shadow, which is `float_shadow` above, one number.
//
// All the defaults are gentle: a dead pane, a suspended one and scrollback are
// all things you still want to read. The scrolled default is *derived*, not
@@ -508,6 +521,7 @@ states {
// dim amount="(below > 0) * max(0, (y - rows + min(3, rows / 4) + 1) * 45)"
// }
// dragging { }
+ // floating { }
// bell { // derived: a body shimmer, and a frame blink-then-breathe, in theme's bell colour
// tint color="#efd7b3" amount="(since < 900) * max(0, 95 - abs(x + 2 * y - since / 4) * (6 + sin(since / 2) / 96) - since / 64)"
// tint where="chrome" channel="fg" color="#e0af68" amount="since < 900 ? (since / 150 % 2 == 0) * 255 : 120 + sin(since / 8 + 158) / 3"
@@ -828,7 +842,7 @@ theme {
// Everything is `prefix` then a key. Pressing the prefix twice sends the
// prefix itself to the pane. Modifiers are ctrl+ alt+ shift+ super+, and named
// keys are left/right/up/down/enter/tab/escape/space/backspace/home/end/
-// pageup/pagedown/delete/insert/backslash/minus/slash/comma/period.
+// pageup/pagedown/delete/insert/backslash/minus/slash/comma/period/f1..f12.
//
// The cheatsheet's shorthand also parses -- C- M- S-, and ← → ↑ ↓ -- so a chord
// copied off C-a ? is a chord you can paste in here. Anything else is the
@@ -840,8 +854,9 @@ theme {
// focus-down focus-next resize-left resize-right resize-up resize-down equalize
// rotate-layout clear-shaders pane-to-next-tab pane-to-prev-tab pane-to-new-tab
// scroll-up scroll-down scroll-page-up scroll-page-down scroll-top
-// scroll-bottom zoom minimize rerun new-tab next-tab prev-tab select-tab-1..9 finder
-// palette help detach quit
+// scroll-bottom zoom minimize float new-float float-grow float-shrink rerun
+// set-purpose rename-pane rename-tab new-tab next-tab prev-tab
+// select-tab-1..9 finder palette help detach quit
// literal-prefix none
// What C-a e opens this file with. Empty means $EDITOR, and then vi -- the
@@ -875,6 +890,20 @@ keys {
bind "r" "rerun"
bind "z" "zoom"
bind "m" "minimize"
+ // Lift the pane out of the layout (it pops to the centre) and drop it
+ // back in: f is what "float" sounds like. The finder lives on `s`,
+ // where it reads as *search*. `F` is the same verb on a fresh thing --
+ // a new floating shell, the throwaway terminal.
+ bind "f" "float"
+ bind "F" "new-float"
+ // The =/+ key grows a focused float about its centre and `-` shrinks it
+ // (through split-rows, whose split is a refusal on a float anyway).
+ // Bound both ways because whether `+` arrives shifted depends on the
+ // outer terminal -- the same deal `?` has with `/` -- which is also why
+ // equalize moved to `0` rather than sharing the key: two verbs on one
+ // key survive only until a terminal drops the shift.
+ bind "=" "float-grow"
+ bind "+" "float-grow"
bind "h" "focus-left"
bind "l" "focus-right"
@@ -895,8 +924,9 @@ keys {
// Undo every drag and nudge at once: each split divided between the panes
// behind it, so every visible pane gets an even share of the rows and
- // columns it competes for.
- bind "=" "equalize"
+ // columns it competes for. On the digit row it already lives on: 1..9
+ // pick a tab, 0 resets the shares.
+ bind "0" "equalize"
// A quarter turn of the whole tab, clockwise: every split changes axis and
// a row split's children reverse, because the pane on top of a stack is the
@@ -925,7 +955,7 @@ keys {
bind "2" "select-tab-2"
bind "3" "select-tab-3"
- bind "f" "finder"
+ bind "s" "finder"
// Every action by name, including the ones nothing is bound to -- which
// is the whole point of it, since an unbound action has no other way in.
diff --git a/docs/config.md b/docs/config.md
index 686ef0e..bbff38d 100644
--- a/docs/config.md
+++ b/docs/config.md
@@ -111,7 +111,7 @@ theme { frame_focus "#00ff88" } // ...but that one colour is mine
|---|---|
| geometry | `gap` `gap_aspect` `padding` `rounded` `title_align` `title_inset` `min_pane` `min_split` |
| chrome | `status_bar` `status_line` `status_pad` `hints` `version_banner` `pane_buttons` `bell_indicator` and the marks (`zoom_mark` `zoom_on_mark` `close_mark` `min_mark` `newtab_mark` `bell_mark`) |
-| behaviour | `focus_follows_mouse` `scroll_lines` `scrollback` `scrollback_bytes` `toast_ms` `hover_delay_ms` `double_click_ms` `word_separators` `anim_ms` `modal_scrim` `dim_unfocused` `keep_dead` `in_band_shaders` `shell` `editor` `shader_dir` |
+| behaviour | `focus_follows_mouse` `scroll_lines` `scrollback` `scrollback_bytes` `toast_ms` `hover_delay_ms` `double_click_ms` `word_separators` `anim_ms` `modal_scrim` `dim_unfocused` `float_shadow` `keep_dead` `in_band_shaders` `shell` `editor` `shader_dir` |
| colour | `theme { }` |
| effects | `shaders { }`, `states { }` — see [shaders](shaders.md) and [chrome](chrome.md) |
| keys | `keys { }` — see [keys](keys.md) |
@@ -178,6 +178,7 @@ by looking:
```kdl
dim_unfocused 60 // the one knob most people want
+float_shadow 110 // the shade a floating pane casts; 0 for none
states { // ...and the whole table underneath it
dead { grayscale amount=200; dim amount=90 }
@@ -187,6 +188,7 @@ states { // ...and the whole table underneath it
// while there is more content past it (`above` / `below` in expressions),
// and turn solid at the end of the buffer -- nothing more to scroll to
unfocused { dim amount=60 } // writing this replaces dim_unfocused
+ // floating { } // a float; above unfocused, so floats are never dimmed
// bell: a shimmer across the body (a soft sheen, gone within a second)
// and a chrome pass on the frame -- blinks, then a breathe -- in theme's
// bell colour, until the pane is looked at. See the chrome shaders page;
diff --git a/docs/keys.md b/docs/keys.md
index fb58532..88a3318 100644
--- a/docs/keys.md
+++ b/docs/keys.md
@@ -18,11 +18,14 @@ it did not — `+7 more, in docs/keys.md`, which is the table below.
| `\` `-` | split into columns / rows, when you mean one |
| `h` `j` `k` `l`, or arrows | move focus |
| `o` | the next pane |
-| `H` `J` `K` `L`, or shift+arrows | move the boundary between panes |
-| `=` | give every visible pane an even share |
+| `H` `J` `K` `L`, or shift+arrows | move the boundary between panes — or the pane itself, when it is [floating](panes.md#floating-a-pane) |
+| `0` | give every visible pane an even share |
| `Space` | turn the layout a quarter turn (four brings it back) |
| `z` | zoom this pane to fill the tab, and back |
| `m` | minimise it into the strip along the bottom |
+| `f` | [float](panes.md#floating-a-pane) it above the layout, and back |
+| `F` | a new floating shell — the throwaway terminal |
+| `=` (`+`) `-` | grow / shrink a focused float about its centre (`-` splits, when the pane is tiled) |
| `x` | close this pane |
| `X` | close this tab, and everything in it |
| `r` | run a finished pane's command again |
@@ -32,7 +35,7 @@ it did not — `+7 more, in docs/keys.md`, which is the table below.
| `c` | new tab |
| `Tab` `shift+Tab` | next / previous tab |
| `1`…`9` | go to that tab |
-| `f` | find a pane by name |
+| `s` | search: find a pane by name |
| `w` | the projects picker — every project under your roots, open or not ([workspaces](workspaces.md)) |
| `W` | write this tab out as this project's layout ([workspaces](workspaces.md)) |
| `p` | the command palette |
@@ -43,11 +46,12 @@ it did not — `+7 more, in docs/keys.md`, which is the table below.
| `q` | quit the session |
| `C-a` | send a literal `C-a` |
-**`x`/`X`, `p`/`P` and `w`/`W` are deliberately shifted pairs on one letter.**
-Close this pane / close this tab; run a command / tag this pane; go to a project /
-write this project down. The shifted half is the same verb on a bigger thing, or
-the thing you reach for within a moment of the other, so the shift is less to
-remember than a second letter would have been.
+**`x`/`X`, `p`/`P`, `w`/`W` and `f`/`F` are deliberately shifted pairs on one
+letter.** Close this pane / close this tab; run a command / tag this pane; go
+to a project / write this project down; float this pane / float a fresh one.
+The shifted half is the same verb on a bigger thing, or the thing you reach
+for within a moment of the other, so the shift is less to remember than a
+second letter would have been.
`X` says how many panes went with the tab, since most of them were not the one you
were looking at. On the **last** tab it refuses and points at `q` instead: a key
@@ -79,8 +83,8 @@ keys {
Modifiers are `ctrl+ alt+ shift+ super+`, or the shorthand the cheatsheet
prints: `C-` `M-` `S-`. Named keys are `left right up down enter tab escape
space backspace home end pageup pagedown delete insert backslash minus slash
-comma period`, and the arrows can be written as `←` `→` `↑` `↓` too — so a chord
-copied off the cheatsheet is a chord you can paste into a config.
+comma period f1`…`f12`, and the arrows can be written as `←` `→` `↑` `↓` too —
+so a chord copied off the cheatsheet is a chord you can paste into a config.
Anything else is the character you press. That includes the shifted ones, which
carry their own shift: `"?"` is `"shift+slash"`, `"H"` is `"shift+h"`, `"|"` is
@@ -116,10 +120,10 @@ being bound at all.
| group | actions |
|---|---|
-| panes | `split` `split-cols` `split-rows` `close-pane` `rerun` `zoom` `minimize` `rotate-layout` `clear-shaders` `pane-to-next-tab` `pane-to-prev-tab` `pane-to-new-tab` `set-purpose` |
+| panes | `split` `split-cols` `split-rows` `close-pane` `rerun` `zoom` `minimize` `float` `new-float` `rotate-layout` `clear-shaders` `pane-to-next-tab` `pane-to-prev-tab` `pane-to-new-tab` `set-purpose` `rename-pane` |
| focus | `focus-left` `focus-right` `focus-up` `focus-down` `focus-next` `finder` |
-| size | `resize-left` `resize-right` `resize-up` `resize-down` `equalize` |
-| tabs | `new-tab` `close-tab` `next-tab` `prev-tab` `select-tab-1` … `select-tab-9` |
+| size | `resize-left` `resize-right` `resize-up` `resize-down` `equalize` `float-grow` `float-shrink` |
+| tabs | `new-tab` `rename-tab` `close-tab` `next-tab` `prev-tab` `select-tab-1` … `select-tab-9` |
| projects | `workspaces` `save-workspace` |
| scroll | `scroll-up` `scroll-down` `scroll-page-up` `scroll-page-down` `scroll-top` `scroll-bottom` |
| session | `palette` `help` `edit-config` `detach` `quit` `literal-prefix` |
diff --git a/docs/panes.md b/docs/panes.md
index e400479..d0a6eb3 100644
--- a/docs/panes.md
+++ b/docs/panes.md
@@ -20,7 +20,7 @@ looking at is drawn.
- **Resize** with `C-a H J K L`: the boundary moves the way you press, whichever
side of it you are on. Sizes are *weights*, so a resized layout survives the
window changing size.
-- **`C-a =`** gives every visible pane an even share again — each split divided
+- **`C-a 0`** gives every visible pane an even share again — each split divided
between the panes behind it, so one pane beside a stack of three is a quarter
of the width rather than half of it.
- **`C-a Space`** turns the whole tab a quarter turn clockwise. Every split
@@ -52,8 +52,16 @@ nothing you click can disagree with what is on screen.
dead pane's epitaph legible — the heavy rule goes round them.
- **Drag the gap between panes** to move that boundary. Where two gaps cross,
drag the crossing to move both at once.
-- **Drag a pane by its title** onto another to swap them. Everything it could
- land on greys out; the pane in your hand keeps its colour.
+- **Drag a pane by its title** onto another to swap them — or to *place* it:
+ the pane under the pointer subdivides into a centre and four edge bands,
+ and where you let go is what the drop means. The centre trades places, as
+ it always has; a band means "insert me beside this pane, on this side",
+ and fills the half of the pane the drop would hand over, so what you see
+ is where it lands. A side there is no room for offers no band — the same
+ floor a border click's split honours — except between neighbours along the
+ same row or column, where a drop is a reorder and always fits. Everything it could land on greys
+ out; the pane in your hand keeps its colour. Between the bands and the
+ strip, the whole layout can be rebuilt without touching the keyboard.
- **...or onto a tab in the strip** to move it into that tab, or onto the `+` for a
tab of its own. The tabs light up as destinations while you carry a pane and the
one under the pointer fills; the tab it already lives in does not offer itself. A
@@ -65,7 +73,10 @@ nothing you click can disagree with what is on screen.
`slosh cmd '{"cmd":"set-name","target":"pane","id":3,"name":"agent: gbos"}'`.
An empty name hands the label back.
- **Double-click a name to rename it in place** — a pane's title or a tab in the
- strip. Enter keeps it, Escape abandons it, empty gives it back to the program.
+ strip. Enter keeps it, Escape abandons it, `C-u` clears the field (a rename
+ seeds with the current label, since it is usually an edit), empty gives it
+ back to the program. The same two verbs are in the palette as `rename-pane`
+ and `rename-tab`, for hands that are on the keyboard.
A name is not a [purpose](layouts.md#purposes): the purpose is a separate label
with a key of its own, `C-a P`, so a rename and a tag cannot be confused with
each other. The purpose editor draws in the same title cell but labels itself
@@ -81,9 +92,68 @@ nothing you click can disagree with what is on screen.
looking.
- Hovering anything says what it does, in a word, in the status line.
+## Floating a pane
+
+`C-a f` lifts a pane out of the layout and draws it on top of the tiled ones
+— below the modals — where it can be moved and shaped freely. It **pops to
+the centre of the tab**, because the jump is what says the float happened; a
+pane lifted in place would look like a pane whose neighbours flinched.
+Pressing `f` again puts it back in the seat it kept, with the layout exactly
+as it was. (`f` is what "float" sounds like; finding a pane is `C-a s`,
+search.) `C-a F` opens a **new floating shell** over whatever you are doing
+— the throwaway terminal — in the focused pane's directory; `exit` closes it
+like any shell, and un-floating lands it beside the pane it was opened over.
+
+- **Drag its title to move it, any other border to resize it** — a bottom
+ corner resizes on both axes. Hovering says so: the edge a grab would move
+ goes heavy in the resize colour with a double arrow at the grab point (a
+ corner lights both edges), and the held edges stay lit through the drag.
+ A float's border never splits: a split is a statement about the
+ arrangement, and a float is precisely not arranged.
+- **`C-a H J K L` move a focused float** instead of moving a boundary, since
+ a float has no boundary to move. **`=` (or `+`) grows it and `-` shrinks
+ it** about its own centre, so it stays under your eyes; on a tiled pane
+ `-` still splits. Equalize lives on `0` for this reason: `+` arrives as a
+ bare `=` on terminals that do not report shift for punctuation, and two
+ verbs on one key survive only until that happens.
+- **It casts a shadow** on what it covers, which is how you tell a float from
+ a tile at a glance. `float_shadow 0` in the config turns it off, and a
+ `states { floating { … } }` chain colours floats on top of that.
+- **Where you put it is where it stays.** Squeezed by a small window it is
+ clamped in; grow the window back and it is exactly where you left it. On a
+ window too small to lay out at all it becomes a row in the flat list like
+ every other pane.
+- Focusing a float raises it above other floats; minimising works as ever and
+ it comes back floating; zooming fills the tab and returns it on the way
+ out. The last tiled pane cannot float — an overlay needs something to be
+ over — and closing the last tiled pane lands the float in its place.
+- Its rect is not yet written by `dump-layout` (coming with the layout-file
+ support); `{"cmd":"float","id":N}` toggles one and `{"cmd":"new-float"}`
+ opens one from a [script](scripting.md).
+
+## Links
+
+**OSC 8 hyperlinks pass through.** A program that emits real hyperlinks —
+`ls --hyperlink`, gcc's diagnostics, delta — keeps them: the compositor
+carries the link beside each cell and re-emits it to your terminal, which
+offers it with its own gesture. Anything painted over a link (a frame, a
+modal, a floating pane) takes the link with it, so chrome is never clickable
+with a pane's URL. Links survive scrollback with the text that carries them.
+
+**Plain-text URLs are your terminal's own matcher**, running over what slosh
+paints — with one catch worth knowing. While any program owns the mouse (a
+multiplexer does), Ghostty only offers links when **shift** is added to the
+usual gesture: shift is its mouse-capture escape, and it is stripped before
+the link's own modifier is checked. So the gesture inside slosh — or tmux,
+or zellij — is **shift+cmd+hover** to highlight and **shift+cmd+click** to
+open (shift+ctrl on Linux). A URL that *wraps* onto a second row cannot be
+matched this way through any multiplexer — the screen is repainted row by
+row, so the terminal sees two hard lines — which is exactly what OSC 8
+links, which pass through whole, are for.
+
## Finding a pane
-Tabs stop being navigation somewhere around six projects. `C-a f` opens a picker
+Tabs stop being navigation somewhere around six projects. `C-a s` opens a picker
over the whole session — every tab, including panes a small window has collapsed
out of sight. Type to narrow it by pane title, tab name or
[purpose](layouts.md#purposes); arrows, `C-n`/`C-p` or tab to move, `C-u` to clear
diff --git a/include/app.h b/include/app.h
index d422995..e3cbc73 100644
--- a/include/app.h
+++ b/include/app.h
@@ -223,6 +223,17 @@ uint32_t app_move_pane_to_new_tab(app_t *a, uint32_t pane_id, const char *name);
bool app_toggle_zoom(app_t *a, uint32_t id);
bool app_pane_zoomed(app_t *a, uint32_t id);
+/* Lift a pane out of the layout and draw it on top, or put it back in the
+ * seat it kept. 0 means the focused one. False when it would leave the tab
+ * with no tiled pane to float over. */
+bool app_toggle_float(app_t *a, uint32_t id);
+bool app_pane_floating(app_t *a, uint32_t id);
+
+/* A new floating shell over the current tab, centred: the throwaway
+ * terminal. It holds a real seat in the tree beside the focused pane, so
+ * un-floating lands it there. Returns its id, or 0. */
+uint32_t app_new_float(app_t *a);
+
/* Give every visible pane in the current tab an even share of the rows and
* columns it competes for: each split's children weighted by how many visible
* panes are behind them, so "even" means the same thing at every depth. False
diff --git a/include/app_internal.h b/include/app_internal.h
index b3a64cb..68d6817 100644
--- a/include/app_internal.h
+++ b/include/app_internal.h
@@ -36,6 +36,25 @@ struct node {
* every pane on every frame. */
bool minimized;
+ /* Floating: out of the layout, drawn on top of the tiled panes and below
+ * the modals. Same pattern as `minimized` — the leaf keeps its seat in the
+ * tree, layout skips it, the siblings absorb its share, and un-floating
+ * returns it home: same id, same place, nothing that referred to it has to
+ * be told anything.
+ *
+ * `float_rect` is where it *wants* to be, in cells. Intent: what is drawn
+ * each frame is this clamped to the tab's area, and the clamp never writes
+ * back — shrink the terminal and the float is squeezed in, grow it again
+ * and it is exactly where you put it. Zero-width means "never placed",
+ * which the placement answers with a centred default.
+ *
+ * `raised` orders overlapping floats: highest paints last, so it is on
+ * top. Stamped when a float is made and when a focused float is not the
+ * top one — in layout(), once, rather than at each place focus can move. */
+ bool floating;
+ rect_t float_rect;
+ uint32_t raised;
+
/* leaf */
pane_t *pane;
uint32_t id;
@@ -112,6 +131,10 @@ struct app {
size_t ntabs, tabcap, cur;
uint32_t next_tab_id;
uint32_t next_id;
+ /* The last raise stamp handed out. A float whose `raised` equals this is
+ * the top one, which is what lets the focus rule stamp without spinning
+ * the counter every frame. */
+ uint32_t raise_seq;
uint16_t cols, rows;
/* The client's cell size in pixels, as reported when it attached. Panes are
* told so that a program can size an image, and so lib-vt can work out how
@@ -134,6 +157,12 @@ struct app {
DRAG_SELECT,
DRAG_BORDER,
DRAG_TAB,
+ /* The float verbs: the title row moves it, any other border cell
+ * resizes from the edges under the grab. Their own kinds rather than
+ * flags on DRAG_TITLE/DRAG_BORDER, because everything those two mean
+ * on release — swap, split — is exactly what a float must not do. */
+ DRAG_FLOAT_MOVE,
+ DRAG_FLOAT_RESIZE,
} kind;
uint32_t src; /* pane being dragged, or the split being resized */
uint32_t target; /* pane under the pointer, for the drop highlight */
@@ -146,6 +175,15 @@ struct app {
uint16_t x, y; /* where the pointer was at the last event */
bool moved; /* a press that never moves is a click */
char side; /* border press: 'l' 'r' 't' 'b' */
+ /* Which of the target's drop zones the pointer is in, or 0 for the
+ * centre. The centre is the swap; a side means "insert me beside the
+ * target, on this side" — the drop grammar that turns dragging into
+ * re-layout. */
+ char drop_side;
+ /* Which edges a float resize is holding (FEDGE_*): derived from where
+ * the press landed on the frame, so a bottom corner is two edges and
+ * follows the pointer on both axes. */
+ uint8_t fedges;
/* A corner drag carries the boundaries it is moving, not an index into
* the corner list: that list is rebuilt from the layout every frame, and
* the layout is the thing being changed. Halfway through a drag the
@@ -281,6 +319,12 @@ extern config_t CFG;
#define WEIGHT_MIN 150 /* a pane can be squeezed, not squeezed out */
#define WEIGHT_STEP 120 /* one keyboard nudge */
+/* Which edges of a float a resize is moving. */
+#define FEDGE_L 1u
+#define FEDGE_R 2u
+#define FEDGE_T 4u
+#define FEDGE_B 8u
+
#define MIN_PANE_COLS (CFG.min_pane_cols)
#define MIN_PANE_ROWS (CFG.min_pane_rows)
#define FRAME_FOCUS (CFG.frame_focus)
@@ -368,6 +412,11 @@ void split_focus(app_t *a, split_dir_t dir);
void split_focus_auto(app_t *a);
void split_focus_ui(app_t *a, split_dir_t dir);
void transfer_weight(node_t *from, node_t *to, int amount);
+void float_move(app_t *a, node_t *n, int dx, int dy);
+void float_resize(app_t *a, node_t *n, unsigned edges, int dx, int dy);
+bool focus_float_move(app_t *a, int dx, int dy);
+bool focus_float_grow(app_t *a, int sign);
+uint8_t float_edges_at(const node_t *n, uint16_t mx, uint16_t my);
/* src/app_ui.c */
void picker_accept(app_t *a, const char *action);
@@ -393,6 +442,7 @@ tab_t *tab_by_id(app_t *a, uint32_t id);
size_t tab_index(app_t *a, uint32_t id);
bool swap_panes(app_t *a, uint32_t id_a, uint32_t id_b);
+bool insert_pane_beside(app_t *a, uint32_t src_id, uint32_t dst_id, char side);
struct byid {
uint32_t id;
node_t *found;
@@ -410,9 +460,11 @@ void draw_status_line(app_t *a, screen_t *s);
void draw_node(app_t *a, screen_t *s, node_t *n);
void draw_min_bar(app_t *a, screen_t *s);
void draw_corners(app_t *a, screen_t *s);
+void draw_floats(app_t *a, screen_t *s);
size_t count_leaves(node_t *n);
size_t collect_minimized(node_t *n, node_t **out, size_t cap, size_t k);
+size_t collect_floating(node_t *n, node_t **out, size_t cap, size_t k);
size_t collect_leaves(node_t *n, node_t **out, size_t cap, size_t k);
#endif /* SLOSH_APP_INTERNAL_H */
diff --git a/include/config.h b/include/config.h
index 969136a..b251aed 100644
--- a/include/config.h
+++ b/include/config.h
@@ -33,6 +33,12 @@ typedef enum {
* which is what answering a bell is. */
PSTATE_BELL,
PSTATE_SCROLLED, /* looking at scrollback rather than the present */
+ /* Lifted out of the layout, drawn on top. Above `unfocused`, so a float is
+ * never dimmed by dim_unfocused: full strength is what keeps the thing on
+ * top reading as lifted off the page — the same reason `dragging` keeps no
+ * default. Ships no chain of its own either; the shadow does the telling
+ * apart, and a config that wants colour writes `states { floating { } }`. */
+ PSTATE_FLOATING,
PSTATE_UNFOCUSED,
PSTATE_COUNT,
} pane_state_t;
@@ -49,7 +55,13 @@ typedef enum {
ACT_RERUN,
ACT_ZOOM,
ACT_MINIMIZE,
+ ACT_FLOAT,
+ ACT_NEW_FLOAT,
+ ACT_FLOAT_GROW,
+ ACT_FLOAT_SHRINK,
ACT_SET_PURPOSE,
+ ACT_RENAME_PANE,
+ ACT_RENAME_TAB,
ACT_ROTATE_LAYOUT,
ACT_FOCUS_LEFT,
ACT_FOCUS_RIGHT,
@@ -219,6 +231,12 @@ typedef struct {
* Writing the state chain by hand still wins: the knob only fills it in
* when the config has not. */
uint8_t dim_unfocused;
+ /* How dark the cell of shade a floating pane casts on what it covers,
+ * 0..255 and 0 for no shadow. The shadow is what tells a float from a tile
+ * at a glance — the frame is the same frame — which is why it ships on and
+ * why it is a number rather than a state chain: turning it off should not
+ * require knowing the states block exists (the dim_unfocused argument). */
+ uint8_t float_shadow;
/* Colour passes every pane gets, in the order they were written. Ordinary
* shaders rather than policy: the session has no opinion about these, you
diff --git a/src/app.c b/src/app.c
index a9cb37c..91e4a31 100644
--- a/src/app.c
+++ b/src/app.c
@@ -327,14 +327,31 @@ static const char *hint_for(app_t *a, const char *action) {
if (strncmp(action, "scrollbottom:", 13) == 0) return "back to the bottom";
if (strncmp(action, "panetitle:", 10) == 0)
return "double-click to rename \u00b7 drag to move";
- if (strncmp(action, "title:", 6) == 0)
+ if (strncmp(action, "title:", 6) == 0) {
+ /* A float's top row only moves it; the click-to-split half of the
+ * handle's promise is exactly what a float does not offer. */
+ node_t *n = pane_by_id(a, id);
+ if (n && n->floating) return "drag to move";
/* The handle says both things it can do; the rest of the row only drags. */
return strchr(action + 6, ':') ? "drag to move \u00b7 click to split up"
: "drag to move";
- /* The rim gets no caption on purpose: it is the part of the edge that does
- * nothing, and the guide it arms is already pointing at the part that does. */
- if (strncmp(action, "brim:", 5) == 0) return NULL;
- if (strncmp(action, "border:", 7) == 0) {
+ }
+ if (strncmp(action, "brim:", 5) == 0 || strncmp(action, "border:", 7) == 0) {
+ /* On a float the whole border is the resize grab, corners both ways —
+ * read from the same derivation the press uses, so the caption and the
+ * drag cannot disagree. */
+ node_t *n = pane_by_id(a, id);
+ if (n && n->floating) {
+ if (!a->ptr_valid) return "drag to resize";
+ uint8_t e = float_edges_at(n, a->ptr_x, a->ptr_y);
+ return (e & (FEDGE_L | FEDGE_R)) && (e & (FEDGE_T | FEDGE_B))
+ ? "drag to resize both ways"
+ : "drag to resize";
+ }
+ /* The rim gets no caption on purpose: it is the part of the edge that
+ * does nothing, and the guide it arms is already pointing at the part
+ * that does. */
+ if (action[1] == 'r') return NULL; /* brim: */
const char *side = strrchr(action, ':');
switch (side && side[1] ? side[1] : 0) {
case 'l': return "click to split left";
@@ -902,6 +919,34 @@ static bool hover_focus_allowed(const app_t *a) {
a->drag.kind == DRAG_NONE;
}
+/* Grab a float's edge: which edges follow the pointer is derived from where
+ * the press landed on the rect painted this frame, so a bottom corner is two
+ * edges and resizes on both axes — no corner target to register, because the
+ * geometry that was just painted already says which corner it is. The same
+ * derivation the hint and the guide read (float_edges_at), so the promise
+ * and the grab cannot disagree. */
+static void float_resize_press(app_t *a, node_t *n, const input_event_t *ev) {
+ uint8_t e = float_edges_at(n, ev->mx, ev->my);
+ if (!e) return;
+ a->drag.kind = DRAG_FLOAT_RESIZE;
+ a->drag.src = n->id;
+ a->drag.fedges = e;
+ a->drag.moved = false;
+ a->drag.x = ev->mx;
+ a->drag.y = ev->my;
+ /* Grabbing a float raises it, like every window you have ever held. */
+ app_focus_pane(a, n->id);
+}
+
+/* During a title drag, is this pane somewhere the drop could land? A float
+ * is not: swapping tree seats with a pane that is not *in* its seat would
+ * rearrange something invisible. 0 clears the highlight as well as the drop,
+ * so hovering a float mid-drag promises nothing. */
+static uint32_t swap_target(app_t *a, uint32_t id) {
+ node_t *n = pane_by_id(a, id);
+ return n && n->floating ? 0 : id;
+}
+
static void do_action(app_t *a, const char *action, const input_event_t *ev) {
bool on_name = strncmp(action, "panetitle:", 10) == 0;
if (on_name || strncmp(action, "title:", 6) == 0) {
@@ -925,6 +970,20 @@ static void do_action(app_t *a, const char *action, const input_event_t *ev) {
a->name_click_id = id;
a->name_click_kind = RENAME_PANE;
}
+ /* A float's top row moves it — its own verb, because everything a
+ * title drag means on release (swap, split up) is what a float must
+ * not do. The rename double-click above still wins, so a float's name
+ * is still a name. */
+ node_t *fn = pane_by_id(a, id);
+ if (fn && fn->floating) {
+ a->drag.kind = DRAG_FLOAT_MOVE;
+ a->drag.src = id;
+ a->drag.moved = false;
+ a->drag.x = ev->mx;
+ a->drag.y = ev->my;
+ app_focus_pane(a, id);
+ return;
+ }
/* The top border is both a drag handle and an edge, and whether this is a
* move or a split-upward is decided by whether the pointer moves. Which
* side it takes is decided by *where* on the row it was pressed: only the
@@ -934,6 +993,7 @@ static void do_action(app_t *a, const char *action, const input_event_t *ev) {
a->drag.kind = DRAG_TITLE;
a->drag.src = id;
a->drag.target = id;
+ a->drag.drop_side = 0;
a->drag.x = ev->mx;
a->drag.y = ev->my;
a->drag.moved = false;
@@ -947,13 +1007,29 @@ static void do_action(app_t *a, const char *action, const input_event_t *ev) {
* Deliberately inert and deliberately explicit -- this is the whole point of
* the handle, and a silent fall-through to whatever comes next would be a
* thin place for it to live. Starting no drag also means the release path
- * finds nothing to split, which is what stops the accidents. */
- if (strncmp(action, "brim:", 5) == 0) return;
+ * finds nothing to split, which is what stops the accidents.
+ *
+ * On a float the whole border is a resize grab instead: a float has no
+ * guide to arm and no split to guard, and the rim being inert was about
+ * accidents that rearrange a layout — resizing the thing you grabbed is
+ * the accident-free reading of the same gesture. */
+ if (strncmp(action, "brim:", 5) == 0) {
+ uint32_t id = (uint32_t)strtoul(action + 5, NULL, 10);
+ node_t *fn = pane_by_id(a, id);
+ if (fn && fn->floating && ev->maction == MOUSE_PRESS)
+ float_resize_press(a, fn, ev);
+ return;
+ }
if (strncmp(action, "border:", 7) == 0) {
uint32_t id = (uint32_t)strtoul(action + 7, NULL, 10);
const char *colon = strchr(action + 7, ':');
char side = colon && colon[1] ? colon[1] : 'r';
if (ev->maction == MOUSE_PRESS) {
+ node_t *fn = pane_by_id(a, id);
+ if (fn && fn->floating) {
+ float_resize_press(a, fn, ev);
+ return;
+ }
a->drag.kind = DRAG_BORDER;
a->drag.src = id;
a->drag.side = side;
@@ -1168,11 +1244,31 @@ bool run_action(app_t *a, action_t act) {
switch (act) {
case ACT_SPLIT: split_focus_auto(a); return true;
case ACT_SPLIT_COLS: split_focus_ui(a, SPLIT_COLS); return true;
- case ACT_SPLIT_ROWS: split_focus_ui(a, SPLIT_ROWS); return true;
+ case ACT_SPLIT_ROWS:
+ /* On a focused float, `-` shrinks it: the split it usually means is a
+ * refusal there, and minus meaning smaller is the reading the hand
+ * expects. The pair's other half, grow, has the =/+ key to itself. */
+ if (focus_float_grow(a, -1)) return true;
+ split_focus_ui(a, SPLIT_ROWS);
+ return true;
case ACT_ZOOM: app_toggle_zoom(a, 0); return true;
case ACT_MINIMIZE:
if (!app_minimize(a, 0)) app_toast(a, "nothing else to show");
return true;
+ case ACT_FLOAT:
+ /* The refusal is the guard: floating the last tiled pane would leave
+ * the overlay nothing to be over. */
+ if (!app_toggle_float(a, 0)) app_toast(a, "nothing to float over");
+ return true;
+ case ACT_NEW_FLOAT:
+ if (!app_new_float(a)) app_toast(a, "cannot open a pane");
+ return true;
+ case ACT_FLOAT_GROW:
+ if (!focus_float_grow(a, 1)) app_toast(a, "nothing floating to grow");
+ return true;
+ case ACT_FLOAT_SHRINK:
+ if (!focus_float_grow(a, -1)) app_toast(a, "nothing floating to shrink");
+ return true;
case ACT_CLOSE_PANE:
if (cur(a)->focus) close_leaf(a, cur(a)->focus);
return true;
@@ -1181,6 +1277,19 @@ bool run_action(app_t *a, action_t act) {
* than to the space one would occupy. */
if (cur(a)->focus) purpose_begin(a, cur(a)->focus->id);
return true;
+ case ACT_RENAME_PANE:
+ if (cur(a)->focus) rename_begin(a, cur(a)->focus->id);
+ return true;
+ case ACT_RENAME_TAB:
+ /* The editor draws in the tab's own cell in the strip; with the strip
+ * off there is nowhere to type, and an invisible editor holding the
+ * keyboard is the wedged state this program does not do. */
+ if (!CFG.status_bar) {
+ app_toast(a, "no tab strip to rename in");
+ return true;
+ }
+ rename_tab_begin(a, cur(a)->id);
+ return true;
case ACT_RERUN:
/* The keyboard's half of a dead pane's [re-run] button. Refused on a
* live pane rather than restarting it: "run it again" would mean
@@ -1220,10 +1329,21 @@ bool run_action(app_t *a, action_t act) {
}
case ACT_NEXT_TAB: app_cycle_tab(a, 1); return true;
case ACT_PREV_TAB: app_cycle_tab(a, -1); return true;
- case ACT_RESIZE_LEFT: resize_focus(a, -1, 0); return true;
- case ACT_RESIZE_RIGHT: resize_focus(a, 1, 0); return true;
- case ACT_RESIZE_UP: resize_focus(a, 0, -1); return true;
- case ACT_RESIZE_DOWN: resize_focus(a, 0, 1); return true;
+ /* On a focused float the same keys move the float: it has no boundary to
+ * move, and moving the thing itself is the same verb aimed at what is
+ * there. */
+ case ACT_RESIZE_LEFT:
+ if (!focus_float_move(a, -1, 0)) resize_focus(a, -1, 0);
+ return true;
+ case ACT_RESIZE_RIGHT:
+ if (!focus_float_move(a, 1, 0)) resize_focus(a, 1, 0);
+ return true;
+ case ACT_RESIZE_UP:
+ if (!focus_float_move(a, 0, -1)) resize_focus(a, 0, -1);
+ return true;
+ case ACT_RESIZE_DOWN:
+ if (!focus_float_move(a, 0, 1)) resize_focus(a, 0, 1);
+ return true;
case ACT_CLEAR_SHADERS:
/* Says which of the two happened. "Nothing to undo" and "undone" look
* identical on a pane that was never painted, and a key that might have
@@ -1366,6 +1486,7 @@ void app_event(app_t *a, const input_event_t *ev) {
}
a->drag.kind = DRAG_NONE;
a->drag.src = a->drag.target = 0;
+ a->drag.drop_side = 0;
}
if (ev->kind == EV_KEY && ev->action != KEY_RELEASE) {
@@ -1465,6 +1586,16 @@ void app_event(app_t *a, const input_event_t *ev) {
? (int)ev->mx - (int)a->drag.x
: (int)ev->my - (int)a->drag.y;
drag_edge(a, sp, a->drag.edge, cells);
+ } else if (a->drag.kind == DRAG_FLOAT_MOVE) {
+ node_t *n = pane_by_id(a, a->drag.src);
+ if (n)
+ float_move(a, n, (int)ev->mx - (int)a->drag.x,
+ (int)ev->my - (int)a->drag.y);
+ } else if (a->drag.kind == DRAG_FLOAT_RESIZE) {
+ node_t *n = pane_by_id(a, a->drag.src);
+ if (n)
+ float_resize(a, n, a->drag.fedges, (int)ev->mx - (int)a->drag.x,
+ (int)ev->my - (int)a->drag.y);
} else if (a->drag.kind == DRAG_TAB) {
/* Reordered as you drag, rather than dropped at the end: the
* strip is the only thing that could show an insertion point, and
@@ -1486,25 +1617,43 @@ void app_event(app_t *a, const input_event_t *ev) {
a->drag.tab_target = (uint32_t)strtoul(action + 4, NULL, 10);
a->drag.new_tab_target = false;
a->drag.target = 0;
+ a->drag.drop_side = 0;
} else if (a->drag.kind == DRAG_TITLE && action &&
strcmp(action, "newtab") == 0) {
/* The button that makes a tab, used as somewhere to put one pane. */
a->drag.new_tab_target = true;
a->drag.tab_target = 0;
a->drag.target = 0;
+ a->drag.drop_side = 0;
+ } else if (a->drag.kind == DRAG_TITLE && action &&
+ strncmp(action, "drop:", 5) == 0) {
+ /* A drop zone: insert beside that pane, on that side. The zone
+ * was registered by the paint the pointer is over, so it exists
+ * exactly where the fill said it would. */
+ a->drag.target = (uint32_t)strtoul(action + 5, NULL, 10);
+ const char *sep = strrchr(action, ':');
+ a->drag.drop_side = sep && sep[1] ? sep[1] : 0;
+ a->drag.tab_target = 0;
+ a->drag.new_tab_target = false;
} else if (action && strncmp(action, "title:", 6) == 0) {
- a->drag.target = (uint32_t)strtoul(action + 6, NULL, 10);
+ a->drag.target =
+ swap_target(a, (uint32_t)strtoul(action + 6, NULL, 10));
a->drag.tab_target = 0;
a->drag.new_tab_target = false;
+ a->drag.drop_side = 0;
} else if (action && strncmp(action, "panetitle:", 10) == 0) {
/* Dropping onto a pane's name is dropping onto that pane. */
- a->drag.target = (uint32_t)strtoul(action + 10, NULL, 10);
+ a->drag.target =
+ swap_target(a, (uint32_t)strtoul(action + 10, NULL, 10));
a->drag.tab_target = 0;
a->drag.new_tab_target = false;
+ a->drag.drop_side = 0;
} else if (action && strncmp(action, "pane:", 5) == 0) {
- a->drag.target = (uint32_t)strtoul(action + 5, NULL, 10);
+ a->drag.target =
+ swap_target(a, (uint32_t)strtoul(action + 5, NULL, 10));
a->drag.tab_target = 0;
a->drag.new_tab_target = false;
+ a->drag.drop_side = 0;
}
a->drag.x = ev->mx;
a->drag.y = ev->my;
@@ -1521,7 +1670,9 @@ void app_event(app_t *a, const input_event_t *ev) {
/* The guide already declined to offer this, so the click that
* the guide would have explained must decline too — otherwise
* the border silently does something it just said it would not. */
- if (!split_fits(n, dir)) {
+ if (n->floating) { /* its own words: the room is not the problem */
+ app_toast(a, "a floating pane cannot be split");
+ } else if (!split_fits(n, dir)) {
app_toast(a, dir == SPLIT_COLS ? "no room to split across"
: "no room to split down");
} else {
@@ -1549,13 +1700,27 @@ void app_event(app_t *a, const input_event_t *ev) {
(a->drag.tab_target || a->drag.new_tab_target)) {
/* Dropped on the strip: the pane changes tab rather than places. */
drop_pane_on_strip(a);
+ } else if (a->drag.kind == DRAG_TITLE && a->drag.moved && action &&
+ strncmp(action, "drop:", 5) == 0) {
+ /* A zone inserts beside its pane, on its side: the drop grammar.
+ * Resolved from the painted frame's hits at the moment of
+ * release, not from a side remembered off the last motion —
+ * the zones are painted one frame behind the pointer, and a
+ * remembered answer could disagree with the fill on screen.
+ * Derive from what the user saw; same lesson as the guide. */
+ uint32_t dst = (uint32_t)strtoul(action + 5, NULL, 10);
+ const char *sep = strrchr(action, ':');
+ if (dst != a->drag.src && sep && sep[1])
+ insert_pane_beside(a, a->drag.src, dst, sep[1]);
} else if (a->drag.kind == DRAG_TITLE &&
a->drag.target != a->drag.src) {
+ /* The centre is the swap it always was. */
swap_panes(a, a->drag.src, a->drag.target);
}
a->drag.kind = DRAG_NONE;
a->drag.src = a->drag.target = a->drag.tab_target = 0;
a->drag.new_tab_target = false;
+ a->drag.drop_side = 0;
}
break; /* a drag owns the mouse until the button comes up */
}
@@ -1618,6 +1783,7 @@ static void panes_cb(node_t *n, void *ud) {
json_int(j, "content_h", n->content.h);
json_bool(j, "focused", n == cur(pj->a)->focus);
json_bool(j, "hidden", n->hidden);
+ json_bool(j, "floating", n->floating);
json_bool(j, "suspended", pane_suspended(n->pane));
/* A dead pane is still a pane, so tooling has to be able to tell: `alive`
* false with a status is one whose program is over and which is waiting to
diff --git a/src/app_draw.c b/src/app_draw.c
index 7c2a93c..d96ceab 100644
--- a/src/app_draw.c
+++ b/src/app_draw.c
@@ -362,6 +362,179 @@ static void draw_split_guide(app_t *a, screen_t *s, node_t *leaf) {
}
}
+/* The drop zones: while a pane is being carried, the pane under the pointer
+ * subdivides into a centre — today's swap, keeping the biggest target — and
+ * four edge bands that mean "insert me beside this pane, on this side",
+ * which is what turns dragging into re-layout. Bands are rects registered in
+ * the hit list as they are painted, so the promise on screen and the drop on
+ * release are the same rects; the corners go to the upright bands because
+ * they are registered last, and the centre needs no entry at all — a
+ * pointer that lands in no band falls through to the pane's own hit, which
+ * is the swap it always was.
+ *
+ * Zones materialise only under the pointer: four bands of chrome on every
+ * candidate during every drag would be noise, and the drop highlight has
+ * always been a fact about the pane you are on. A side whose insert would
+ * not fit (the same floor split_fits holds a border click to) simply never
+ * registers, so the preview cannot promise what the drop cannot deliver.
+ * The hovered band fills the half of the pane the drop would hand over —
+ * where the pane will *be*, not where the button is. */
+static void draw_drop_zones(app_t *a, screen_t *s, node_t *n) {
+ if (a->drag.kind != DRAG_TITLE || !a->drag.moved) return;
+ if (n->id == a->drag.src || n->floating) return;
+ if (!a->ptr_valid) return;
+ rect_t r = n->rect;
+ if (r.w < 3 || r.h < 3) return;
+ if (a->ptr_x < r.x || a->ptr_x >= r.x + r.w || a->ptr_y < r.y ||
+ a->ptr_y >= r.y + r.h)
+ return;
+
+ /* A third of each dimension per band, so the centre is the remaining
+ * third: big enough to hit without aiming, small enough that the bands —
+ * the half of this feature you steer by — own most of the pane. It began
+ * as quarters, and the first map of the result showed the swap owning
+ * half of each axis: a target that big reads as dead space when you are
+ * mid-drag hunting for a band. */
+ uint16_t bw = r.w / 3 ? (uint16_t)(r.w / 3) : 1;
+ uint16_t bh = r.h / 3 ? (uint16_t)(r.h / 3) : 1;
+
+ /* Whether a side is offered. A drop beside a *sibling along that axis* is
+ * a reorder — the dragged pane leaves the split it would rejoin, so the
+ * child count never changes and there is nothing to fit; refusing it was
+ * refusing the most ordinary drag there is, rearranging a row. Everything
+ * else is a real insert and answers to the same floor a border click
+ * does. (Still conservative across splits: a drop whose *source* leaving
+ * would have made the room is not offered, because the offer would need
+ * the speculative layout to prove.) */
+ node_t *src = pane_by_id(a, a->drag.src);
+ bool sib = src && src->parent && src->parent == n->parent;
+ bool rows_ok =
+ (sib && n->parent->dir == SPLIT_ROWS) || split_fits(n, SPLIT_ROWS);
+ bool cols_ok =
+ (sib && n->parent->dir == SPLIT_COLS) || split_fits(n, SPLIT_COLS);
+
+ char action[48];
+ /* Horizontal bands first, upright bands after: the corners resolve to a
+ * column split, which is the axis a cell's shape makes roomier. */
+ if (rows_ok) {
+ snprintf(action, sizeof action, "drop:%u:t", n->id);
+ hit_add(&s->hits, r.x, r.y, r.w, bh, action);
+ snprintf(action, sizeof action, "drop:%u:b", n->id);
+ hit_add(&s->hits, r.x, (uint16_t)(r.y + r.h - bh), r.w, bh, action);
+ }
+ if (cols_ok) {
+ snprintf(action, sizeof action, "drop:%u:l", n->id);
+ hit_add(&s->hits, r.x, r.y, bw, r.h, action);
+ snprintf(action, sizeof action, "drop:%u:r", n->id);
+ hit_add(&s->hits, (uint16_t)(r.x + r.w - bw), r.y, bw, r.h, action);
+ }
+
+ /* The fill, read back off the hits just registered so it cannot disagree
+ * with what a release here would do. */
+ const char *over = hit_test(&s->hits, a->ptr_x, a->ptr_y);
+ char want[16];
+ snprintf(want, sizeof want, "drop:%u:", n->id);
+ if (!over || strncmp(over, want, strlen(want)) != 0) return;
+ char side = over[strlen(want)];
+ rect_t half = r;
+ switch (side) {
+ case 'l': half.w = (uint16_t)(r.w / 2); break;
+ case 'r':
+ half.w = (uint16_t)(r.w / 2);
+ half.x = (uint16_t)(r.x + r.w - half.w);
+ break;
+ case 't': half.h = (uint16_t)(r.h / 2); break;
+ default:
+ half.h = (uint16_t)(r.h / 2);
+ half.y = (uint16_t)(r.y + r.h - half.h);
+ break;
+ }
+ shader_t fill;
+ if (!shader_make(&fill, "tint", DROP_C, 110)) return;
+ shade_ctx_t base = {
+ .now_ms = now_ms_(),
+ .default_fg = CFG.default_fg,
+ .default_bg = CFG.default_bg,
+ };
+ shade_apply(s, &fill, 1, half, NULL, &base);
+}
+
+/* The float's answer to the split guide: hovering its border shows, in the
+ * resize colour, which edges a grab there would move — one lit edge for a
+ * side, two meeting at the pointer for a corner — and the pair stays lit
+ * while the drag holds them. Without this the border was a working handle
+ * that never said so, which is a control you have to already know about.
+ *
+ * The edges are read from float_edges_at, the same derivation the press and
+ * the hint use, and each cell is painted only if the hit list says the
+ * border still owns it — so the buttons and the epitaph on the bottom row
+ * stay legible, the same question the split guide asks. Armed on the same
+ * dwell, for the same reason: a pointer crossing a float on its way
+ * somewhere else should not make the frame flash. */
+static void draw_float_guide(app_t *a, screen_t *s, node_t *leaf) {
+ if (!leaf->floating || leaf->collapsed || leaf->hidden) return;
+ rect_t r = leaf->rect;
+ if (r.w < 3 || r.h < 3) return;
+
+ uint8_t e = 0;
+ bool active = a->drag.kind == DRAG_FLOAT_RESIZE && a->drag.src == leaf->id;
+ if (active) {
+ e = a->drag.fedges;
+ } else if (a->drag.kind == DRAG_NONE && a->ptr_valid) {
+ if (now_ms_() - a->ptr_still_since < CFG.hover_delay_ms) return;
+ const char *action = hit_test(&s->hits, a->ptr_x, a->ptr_y);
+ if (!action) return;
+ char want[24];
+ snprintf(want, sizeof want, "border:%u:", leaf->id);
+ bool mine = strncmp(action, want, strlen(want)) == 0;
+ if (!mine) {
+ snprintf(want, sizeof want, "brim:%u:", leaf->id);
+ mine = strncmp(action, want, strlen(want)) == 0;
+ }
+ if (!mine) return;
+ e = float_edges_at(leaf, a->ptr_x, a->ptr_y);
+ }
+ if (!e) return;
+
+ uint16_t x1 = (uint16_t)(r.x + r.w - 1), y1 = (uint16_t)(r.y + r.h - 1);
+ uint16_t attrs = ATTR_BOLD;
+
+ /* A cell is painted only if the border owns it: brim or handle, either
+ * name is the edge. */
+ char rim[24], handle[24];
+ for (int side = 0; side < 3; side++) {
+ char c = "lrb"[side];
+ uint8_t bit = c == 'l' ? FEDGE_L : c == 'r' ? FEDGE_R : FEDGE_B;
+ if (!(e & bit)) continue;
+ snprintf(rim, sizeof rim, "brim:%u:%c", leaf->id, c);
+ snprintf(handle, sizeof handle, "border:%u:%c", leaf->id, c);
+ if (c == 'b') {
+ for (uint16_t x = r.x; x <= x1; x++) {
+ const char *own = hit_test(&s->hits, x, y1);
+ if (own && (strcmp(own, rim) == 0 || strcmp(own, handle) == 0))
+ screen_text(s, x, y1, "\u2501", RESIZE_C, NO_COLOR, attrs);
+ }
+ } else {
+ uint16_t bx = c == 'l' ? r.x : x1;
+ for (uint16_t y = (uint16_t)(r.y + 1); y < y1; y++) {
+ const char *own = hit_test(&s->hits, bx, y);
+ if (own && (strcmp(own, rim) == 0 || strcmp(own, handle) == 0))
+ screen_text(s, bx, y, "\u2503", RESIZE_C, NO_COLOR, attrs);
+ }
+ }
+ }
+
+ /* The double arrow at the grab point, hover only — mid-drag the pointer
+ * has left the edge, and the lit edges following the hand are the
+ * feedback. A corner gets none: two edges meeting at the pointer already
+ * say "both ways", and no single glyph does. */
+ bool corner = (e & (FEDGE_L | FEDGE_R)) && (e & (FEDGE_T | FEDGE_B));
+ if (!active && !corner)
+ screen_text(s, a->ptr_x, a->ptr_y,
+ (e & (FEDGE_L | FEDGE_R)) ? "\u21d4" : "\u21d5", RESIZE_C,
+ NO_COLOR, ATTR_BOLD);
+}
+
static void draw_frame(app_t *a, screen_t *s, node_t *leaf) {
rect_t r = leaf->rect;
if (r.w < 3 || r.h < 3) return;
@@ -378,8 +551,8 @@ static void draw_frame(app_t *a, screen_t *s, node_t *leaf) {
* the greying subtle. The pane actually under the pointer still takes the
* drop_target highlight on top of the dashes: these are all targets, that is
* the one you are on. */
- bool drag_target =
- a->drag.kind == DRAG_TITLE && a->drag.moved && a->drag.src != leaf->id;
+ bool drag_target = a->drag.kind == DRAG_TITLE && a->drag.moved &&
+ a->drag.src != leaf->id && !leaf->floating;
const char *hbar = drag_target ? "\u2504" : "\u2500";
const char *vbar = drag_target ? "\u2506" : "\u2502";
@@ -967,7 +1140,11 @@ static pane_state_t pane_state_of(app_t *a, node_t *n) {
* click would otherwise flash the whole session on its way to nothing. */
if (a->drag.kind == DRAG_TITLE && a->drag.moved) {
if (n->id == a->drag.src) return PSTATE_DRAGGING;
- if (n->id == a->drag.target) return PSTATE_DROP_HOVER;
+ /* drop_hover is the swap's promise — the whole pane trades places. A
+ * pointer in one of its drop *zones* promises something narrower, and
+ * the zone's own fill says it; the pane stays an ordinary candidate so
+ * the two promises cannot show at once. */
+ if (n->id == a->drag.target && !a->drag.drop_side) return PSTATE_DROP_HOVER;
return PSTATE_DROP_TARGET;
}
if (!pane_alive(n->pane) && !pane_suspended(n->pane)) return PSTATE_DEAD;
@@ -979,6 +1156,12 @@ static pane_state_t pane_state_of(app_t *a, node_t *n) {
* at it answers the bell before anything is drawn. */
if (pane_bell(n->pane)) return PSTATE_BELL;
if (pane_scrolled(n->pane)) return PSTATE_SCROLLED;
+ /* Above `unfocused`, deliberately: a float is never dimmed by
+ * dim_unfocused. The thing on top of the stack reading at full strength is
+ * what keeps it lifted off the page — the same reason `dragging` keeps no
+ * default — and the frame colour still tells focus. Ships no chain; the
+ * shadow does the telling apart. */
+ if (n->floating) return PSTATE_FLOATING;
if (n != cur(a)->focus) return PSTATE_UNFOCUSED;
return PSTATE_COUNT;
}
@@ -1216,6 +1399,8 @@ static void draw_cb(node_t *n, void *ud) {
shade_leaf(d->a, d->s, n);
shade_chrome(d->a, d->s, n, n->rect, &n->content);
draw_split_guide(d->a, d->s, n);
+ draw_float_guide(d->a, d->s, n);
+ draw_drop_zones(d->a, d->s, n);
}
struct bellsearch {
@@ -1592,6 +1777,10 @@ void draw_node(app_t *a, screen_t *s, node_t *n) {
return;
}
if (n->kind == NODE_LEAF) {
+ /* A float is painted by draw_floats, after everything tiled, so its
+ * cells and its hits go on top. A *collapsed* float took the branch
+ * above: in a flattened tab it is a row like everyone else. */
+ if (n->floating) return;
struct draw d = {a, s};
draw_cb(n, &d);
return;
@@ -1612,6 +1801,73 @@ void draw_node(app_t *a, screen_t *s, node_t *n) {
for (size_t i = 0; i < n->nkids; i++) draw_node(a, s, n->kids[i]);
}
+/* The cell of shade a float casts on whatever it covers: `gap_aspect`
+ * columns beside and one row below, offset so the light reads as coming from
+ * the top left. The same dim pass the scrim is — a shadow is a colour pass
+ * with everything in its rects — run before the float itself is painted, so
+ * it falls on the composited cells underneath, lower floats included. The
+ * two rects meet without overlapping, because a corner dimmed twice reads as
+ * a stain rather than a shadow. */
+static void cast_shadow(app_t *a, screen_t *s, rect_t r) {
+ if (!CFG.float_shadow) return;
+ shader_t dim;
+ if (!shader_make(&dim, "dim", (color_t){0}, CFG.float_shadow)) return;
+ shade_ctx_t base = {
+ .now_ms = now_ms_(),
+ .default_fg = CFG.default_fg,
+ .default_bg = CFG.default_bg,
+ };
+ uint16_t off = CFG.gap_aspect ? CFG.gap_aspect : 2;
+ shade_apply(s, &dim, 1,
+ (rect_t){(uint16_t)(r.x + r.w), (uint16_t)(r.y + 1), off,
+ (uint16_t)(r.h - 1)},
+ NULL, &base);
+ shade_apply(s, &dim, 1,
+ (rect_t){(uint16_t)(r.x + off), (uint16_t)(r.y + r.h), r.w, 1},
+ NULL, &base);
+}
+
+/* The floating panes, over everything tiled and under the modals.
+ *
+ * Painted in ascending raise order, so the hit list — searched backwards —
+ * resolves an overlap to the float you can see. The z-order and the click
+ * order are the same list, which is the "one geometry" rule doing the work:
+ * there is no routing code to disagree with the paint.
+ *
+ * Each float is drawn by the same draw_cb as a tiled pane: same frame, same
+ * buttons, same shader passes. What differs is when, which is the whole
+ * feature. */
+void draw_floats(app_t *a, screen_t *s) {
+ node_t *fl[64];
+ size_t nf = collect_floating(cur(a)->root, fl, 64, 0);
+ /* Insertion sort by raise stamp: n is small and the order is nearly
+ * stable frame to frame. */
+ for (size_t i = 1; i < nf; i++) {
+ node_t *k = fl[i];
+ size_t j = i;
+ while (j > 0 && fl[j - 1]->raised > k->raised) {
+ fl[j] = fl[j - 1];
+ j--;
+ }
+ fl[j] = k;
+ }
+ for (size_t i = 0; i < nf; i++) {
+ node_t *n = fl[i];
+ /* A flattened tab listed it as a row, and a zoomed tab hid it: either
+ * way this pass has nothing to add. */
+ if (n->collapsed || n->hidden) continue;
+ /* The cursor a tiled pane parked under this float must not glow through
+ * it. Its own cursor is set by its own compose below, when focused. */
+ if (s->cursor_visible && s->cursor_x >= n->rect.x &&
+ s->cursor_x < n->rect.x + n->rect.w && s->cursor_y >= n->rect.y &&
+ s->cursor_y < n->rect.y + n->rect.h)
+ s->cursor_visible = false;
+ cast_shadow(a, s, n->rect);
+ struct draw d = {a, s};
+ draw_cb(n, &d);
+ }
+}
+
/* ---- kitty graphics ------------------------------------------------------ */
struct gfx_ctx {
diff --git a/src/app_layout.c b/src/app_layout.c
index 5af2dec..d2fc0b1 100644
--- a/src/app_layout.c
+++ b/src/app_layout.c
@@ -33,10 +33,11 @@ size_t collect_leaves(node_t *n, node_t **out, size_t cap, size_t k) {
return k;
}
-/* Does anything under here still want space in the layout? */
+/* Does anything under here still want space in the layout? A floating pane
+ * does not: it is placed over the layout, not by it. */
static bool subtree_live(node_t *n) {
if (!n) return false;
- if (n->kind == NODE_LEAF) return !n->minimized;
+ if (n->kind == NODE_LEAF) return !n->minimized && !n->floating;
for (size_t i = 0; i < n->nkids; i++)
if (subtree_live(n->kids[i])) return true;
return false;
@@ -53,6 +54,19 @@ size_t collect_minimized(node_t *n, node_t **out, size_t cap, size_t k) {
return k;
}
+/* The floats, in tree order; the draw pass sorts them by raise stamp. A
+ * minimised float is in the strip, not on screen, so it is not collected. */
+size_t collect_floating(node_t *n, node_t **out, size_t cap, size_t k) {
+ if (!n || k >= cap) return k;
+ if (n->kind == NODE_LEAF) {
+ if (n->floating && !n->minimized) out[k++] = n;
+ return k;
+ }
+ for (size_t i = 0; i < n->nkids; i++)
+ k = collect_floating(n->kids[i], out, cap, k);
+ return k;
+}
+
size_t count_leaves(node_t *n) {
if (!n) return 0;
if (n->kind == NODE_LEAF) return 1;
@@ -98,6 +112,11 @@ typedef struct {
node_t *focus;
bool apply; /* false during the probe */
bool overflow; /* some node could not give its children their floor */
+ /* Lay out leaves the tree normally skips. The flatten paths set it because
+ * down there every pane is a row and being minimised or floating is not a
+ * different thing to be; the float placement sets it because placing a
+ * float *is* laying out a leaf the tree skipped. */
+ bool place_hidden;
} layout_ctx_t;
static void layout_node(node_t *n, rect_t r, layout_ctx_t *ctx);
@@ -126,6 +145,7 @@ static void layout_stack(node_t *n, rect_t r, layout_ctx_t *ctx) {
node_t *leaves[64];
size_t nl = collect_leaves(n, leaves, 64, 0);
if (!nl) return;
+ ctx->place_hidden = true; /* a list holds every pane, floats included */
size_t expanded = 0;
for (size_t i = 0; i < nl; i++)
@@ -152,6 +172,7 @@ static void layout_solo(node_t *n, rect_t r, layout_ctx_t *ctx) {
node_t *leaves[64];
size_t nl = collect_leaves(n, leaves, 64, 0);
if (!nl) return;
+ ctx->place_hidden = true; /* the shown pane may be a float (zoomed, say) */
size_t expanded = 0;
for (size_t i = 0; i < nl; i++)
@@ -164,11 +185,13 @@ static void layout_solo(node_t *n, rect_t r, layout_ctx_t *ctx) {
}
static void layout_node(node_t *n, rect_t r, layout_ctx_t *ctx) {
- /* Minimised panes are placed by the strip, not by the tree. Returning before
- * touching the rect leaves the strip's placement standing, and returning
- * before the floor check is right too: a pane that is one row by request is
- * not a pane that failed to fit. */
- if (n->kind == NODE_LEAF && n->minimized) return;
+ /* Minimised panes are placed by the strip and floating ones by the float
+ * pass, not by the tree. Returning before touching the rect leaves that
+ * placement standing, and returning before the floor check is right too: a
+ * pane that is one row by request is not a pane that failed to fit. */
+ if (n->kind == NODE_LEAF && (n->minimized || n->floating) &&
+ !ctx->place_hidden)
+ return;
n->rect = r;
n->collapsed = false;
@@ -281,6 +304,76 @@ static void layout_node(node_t *n, rect_t r, layout_ctx_t *ctx) {
node_t *pane_by_id(app_t *a, uint32_t id); /* defined below */
size_t tab_of(app_t *a, node_t *n);
+/* Clamp a wanted rect into the tab's area. Derived every frame from the
+ * intent and the area, never written back: a float squeezed by a small
+ * terminal is exactly where you put it when the room comes back — the same
+ * property the weights promise across a collapse/expand cycle. */
+static rect_t float_clamp(rect_t want, rect_t area) {
+ rect_t r = want;
+ if (!r.w || !r.h) { /* never placed: centred, two thirds of the area */
+ r.w = (uint16_t)(area.w * 2 / 3);
+ r.h = (uint16_t)(area.h * 2 / 3);
+ r.x = (uint16_t)(area.x + (area.w - r.w) / 2);
+ r.y = (uint16_t)(area.y + (area.h - r.h) / 2);
+ }
+ /* The same floor the tree holds a leaf's whole rect to. */
+ if (r.w < MIN_PANE_COLS) r.w = MIN_PANE_COLS;
+ if (r.h < MIN_PANE_ROWS) r.h = MIN_PANE_ROWS;
+ if (r.w > area.w) r.w = area.w;
+ if (r.h > area.h) r.h = area.h;
+ if (r.x < area.x) r.x = area.x;
+ if (r.y < area.y) r.y = area.y;
+ if (r.x + r.w > area.x + area.w) r.x = (uint16_t)(area.x + area.w - r.w);
+ if (r.y + r.h > area.y + area.h) r.y = (uint16_t)(area.y + area.h - r.h);
+ return r;
+}
+
+/* Give every float its rect for this frame. Reuses the leaf branch of
+ * layout_node — frame, padding, content, resize — rather than a second copy
+ * of that arithmetic, with a ctx of its own so a float below the floor
+ * cannot flatten the tab: the clamp is the answer down here, not the list. */
+static void place_floats(app_t *a, rect_t area) {
+ node_t *fl[64];
+ size_t nf = collect_floating(cur(a)->root, fl, 64, 0);
+ layout_ctx_t ctx = {
+ .focus = cur(a)->focus, .apply = true, .place_hidden = true};
+ for (size_t i = 0; i < nf; i++)
+ layout_node(fl[i], float_clamp(fl[i]->float_rect, area), &ctx);
+}
+
+/* No live tiled pane under this root? The float guard refuses to *make* that
+ * state, but closing the last tiled pane, or moving it to another tab, can
+ * still reach it — and a tab that is only overlays is overlays over nothing.
+ * So the top float lands. Same shape as the minimised-focus rule below:
+ * checked once, here, rather than at each place the tree can lose a leaf. */
+static void ensure_tiled(app_t *a) {
+ node_t *leaves[64];
+ size_t nl = collect_leaves(cur(a)->root, leaves, 64, 0);
+ node_t *top = NULL;
+ for (size_t i = 0; i < nl; i++) {
+ if (!leaves[i]->minimized && !leaves[i]->floating) return; /* all is well */
+ if (leaves[i]->floating && !leaves[i]->minimized &&
+ (!top || leaves[i]->raised > top->raised))
+ top = leaves[i];
+ }
+ if (top) top->floating = false;
+}
+
+/* The rect a tab lays out into: the screen minus the strip, the status line
+ * and the outer gap. Factored so the float edits can ask the same question
+ * the layout answers, rather than a second copy of this arithmetic. */
+static rect_t tab_area(app_t *a) {
+ uint16_t gx = (uint16_t)(CFG.gap * CFG.gap_aspect), gy = CFG.gap;
+ uint16_t top = (uint16_t)(gy + STRIP_ROWS);
+ return (rect_t){.x = gx,
+ .y = top,
+ .w =
+ (uint16_t)(a->cols > 2 * gx ? a->cols - 2 * gx : a->cols),
+ .h = (uint16_t)(a->rows > top + gy + LINE_ROWS
+ ? a->rows - top - gy - LINE_ROWS
+ : 1)};
+}
+
void layout(app_t *a) {
if (!a->ntabs) return;
/* Re-derived below, like everything else about a layout pass: whether this
@@ -288,23 +381,25 @@ void layout(app_t *a) {
* able to ask what its edit did — a turn that flattens the tab is a turn
* nobody asked for. */
a->flattened = false;
- uint16_t gx = (uint16_t)(CFG.gap * CFG.gap_aspect), gy = CFG.gap;
- uint16_t top = (uint16_t)(gy + STRIP_ROWS);
- rect_t r = {.x = gx,
- .y = top,
- .w = (uint16_t)(a->cols > 2 * gx ? a->cols - 2 * gx : a->cols),
- .h = (uint16_t)(a->rows > top + gy + LINE_ROWS
- ? a->rows - top - gy - LINE_ROWS
- : 1)};
+ rect_t r = tab_area(a);
node_t *root = cur(a)->root;
if (!root) return;
+ ensure_tiled(a);
+
/* Focusing a minimised pane is how you get it back, and it is checked here
* rather than in the several places focus can move: any route that ends with
* this pane focused restores it, including ones that do not exist yet. */
if (cur(a)->focus && cur(a)->focus->minimized)
cur(a)->focus->minimized = false;
+ /* Focusing a float raises it. Stamped here for the same reason the rule
+ * above lives here: every route to "this float is focused" raises it, and
+ * the guard keeps the counter still while it is already on top. */
+ if (cur(a)->focus && cur(a)->focus->floating &&
+ cur(a)->focus->raised != a->raise_seq)
+ cur(a)->focus->raised = ++a->raise_seq;
+
/* A zoomed pane is the whole tab. Reuses the solo path rather than adding a
* third arrangement: "show this one and nothing else" is a thing the layout
* already knew how to do, it just used to be a last resort rather than a
@@ -362,6 +457,9 @@ void layout(app_t *a) {
/* They are drawn by the bar, not as panes: no rect of their own. */
for (size_t i = 0; i < nmin; i++)
collapse_leaf(mins[i], (rect_t){r.x, r.y, 0, 0});
+ /* Floats go over the whole tab area, min bar included: the bar owns no
+ * global verbs, and a float can be moved off it. */
+ place_floats(a, r);
return;
}
a->flattened = true;
@@ -473,7 +571,7 @@ static void place_beside(app_t *a, tab_t *t, node_t *leaf, node_t *node,
static node_t *split_node_with(app_t *a, node_t *leaf, split_dir_t dir,
bool before, const char *const *argv,
const char *label) {
- if (!leaf) return NULL;
+ if (!leaf || leaf->floating) return NULL; /* nowhere beside a float to be */
/* A new pane opens where the pane it came out of *is*, not where the session
* was started. Splitting inside a project and landing in whatever directory
* the server happened to be launched from is wrong on its own, and it made a
@@ -499,6 +597,11 @@ static node_t *split_node_with(app_t *a, node_t *leaf, split_dir_t dir,
* that immediately collapsed, and the guide's whole claim is that drawing and
* the layout cannot disagree. */
bool split_fits(node_t *leaf, split_dir_t dir) {
+ /* A split is a statement about the tree's arrangement, and a float is
+ * precisely not arranged. False here reaches everything that offers a
+ * split — the guide, the handle's click, the keyboard's floor check — so
+ * a float's border never promises what the tree cannot deliver. */
+ if (leaf->floating) return false;
/* Two different questions, and the larger answer wins: min_pane is where the
* layout gives up, min_split is where splitting stopped being worth
* offering. A pane can clear the first and still be too small to be worth
@@ -552,6 +655,10 @@ void split_focus(app_t *a, split_dir_t dir) {
void split_focus_ui(app_t *a, split_dir_t dir) {
node_t *n = cur(a)->focus;
if (!n) return;
+ if (n->floating) { /* its own words: the room is not the problem */
+ app_toast(a, "a floating pane cannot be split");
+ return;
+ }
if (!split_fits(n, dir)) {
app_toast(a, dir == SPLIT_COLS ? "no room to split across"
: "no room to split down");
@@ -585,6 +692,10 @@ static split_dir_t preferred_dir(const node_t *leaf) {
void split_focus_auto(app_t *a) {
node_t *n = cur(a)->focus;
if (!n) return;
+ if (n->floating) {
+ app_toast(a, "a floating pane cannot be split");
+ return;
+ }
split_dir_t want = preferred_dir(n);
split_dir_t other = want == SPLIT_COLS ? SPLIT_ROWS : SPLIT_COLS;
split_dir_t dir;
@@ -872,12 +983,14 @@ bool app_minimize(app_t *a, uint32_t id) {
/* Refused if it would leave nothing on screen. A tab showing no panes at all
* has no way back that is discoverable from the tab, and "everything is put
- * away" is not a state worth being able to reach by accident. */
+ * away" is not a state worth being able to reach by accident. A float does
+ * not count as something to show: it needs a tiled backdrop under it, the
+ * same invariant app_toggle_float holds. */
node_t *next = NULL;
node_t *leaves[64];
size_t nl = collect_leaves(a->tabs[ti].root, leaves, 64, 0);
for (size_t i = 0; i < nl; i++)
- if (leaves[i] != n && !leaves[i]->minimized) {
+ if (leaves[i] != n && !leaves[i]->minimized && !leaves[i]->floating) {
next = leaves[i];
break;
}
@@ -911,6 +1024,209 @@ bool app_pane_zoomed(app_t *a, uint32_t id) {
return a->ntabs && cur(a)->zoom == id && id != 0;
}
+/* Lift a pane out of the layout, or put it back in the seat it kept.
+ *
+ * Floating on puts it in the centre of the tab, always: the pop to the
+ * middle is what says the float *happened* — a pane lifted in place looks
+ * like a pane whose neighbours flinched. The size is the one remembered
+ * from the last time a hand shaped it, or the pane's own, a cell in from
+ * where it stood; only the position is opinionated, because the position is
+ * the announcement. Floating off keeps the size for next time. */
+bool app_toggle_float(app_t *a, uint32_t id) {
+ node_t *n = id ? pane_by_id(a, id) : (a->ntabs ? cur(a)->focus : NULL);
+ if (!n || n->kind != NODE_LEAF) return false;
+ size_t ti = tab_of(a, n);
+ if (ti == (size_t)-1) return false;
+
+ if (n->floating) {
+ n->floating = false;
+ } else {
+ /* The backdrop must keep a pane: a tab that is only an overlay is an
+ * overlay over nothing. Same guard as minimising, tightened to tiled
+ * panes — another float is not something to float over. */
+ node_t *leaves[64];
+ size_t nl = collect_leaves(a->tabs[ti].root, leaves, 64, 0);
+ bool backed = false;
+ for (size_t i = 0; i < nl; i++)
+ if (leaves[i] != n && !leaves[i]->minimized && !leaves[i]->floating)
+ backed = true;
+ if (!backed) return false;
+
+ n->minimized = false; /* a minimised pane floated is a pane brought back */
+ n->floating = true;
+ n->raised = ++a->raise_seq;
+ uint16_t w = n->float_rect.w, h = n->float_rect.h;
+ if (!w && n->rect.w > 4 && n->rect.h > 4) {
+ w = (uint16_t)(n->rect.w - 2);
+ h = (uint16_t)(n->rect.h - 2);
+ }
+ if (w) {
+ rect_t area = tab_area(a);
+ if (w > area.w) w = area.w;
+ if (h > area.h) h = area.h;
+ n->float_rect = (rect_t){(uint16_t)(area.x + (area.w - w) / 2),
+ (uint16_t)(area.y + (area.h - h) / 2), w, h};
+ }
+ /* w == 0 (a pane too small to measure): float_clamp's own centred
+ * default answers the same question at two thirds of the area. */
+ }
+ /* Either way you are now looking at it, and it holds the keyboard: the
+ * pane that just changed planes is the one the verb was about. */
+ a->cur = ti;
+ a->tabs[ti].focus = n;
+ layout(a);
+ return true;
+}
+
+bool app_pane_floating(app_t *a, uint32_t id) {
+ node_t *n = id ? pane_by_id(a, id) : (a->ntabs ? cur(a)->focus : NULL);
+ return n && n->floating;
+}
+
+/* An explicit move or resize is the user authoring the intent, so unlike a
+ * terminal resize it *does* write back: the delta is applied to the rect as
+ * drawn — what you grabbed is what moves, even when an older intent is
+ * currently clamped — and after the layout has clamped the result, the
+ * clamped answer becomes the intent. Where you see it is where it stays.
+ *
+ * Refused while the float is not being drawn as a float: a flattened tab
+ * shows it as a row and a zoomed tab hides it, and writing either of those
+ * rects back as intent would destroy the place it returns to. */
+static bool float_editable(app_t *a, node_t *n) {
+ return n && n->floating && !n->minimized && !n->hidden && !n->collapsed &&
+ !a->flattened && !cur(a)->zoom;
+}
+
+void float_move(app_t *a, node_t *n, int dx, int dy) {
+ if (!float_editable(a, n) || (!dx && !dy)) return;
+ int x = (int)n->rect.x + dx, y = (int)n->rect.y + dy;
+ n->float_rect = (rect_t){(uint16_t)(x < 0 ? 0 : x), (uint16_t)(y < 0 ? 0 : y),
+ n->rect.w, n->rect.h};
+ layout(a);
+ if (float_editable(a, n)) n->float_rect = n->rect;
+}
+
+/* Only the grabbed edges move. Each one stops at the area's wall and at the
+ * floor, and the opposite edge stays exactly where it is — the general
+ * clamp would keep the *size* by sliding the rect, which turns "drag the
+ * right edge past the wall" into the whole window walking left. An edge you
+ * are holding pinning at the wall is what a hand expects. */
+void float_resize(app_t *a, node_t *n, unsigned edges, int dx, int dy) {
+ if (!float_editable(a, n) || !edges || (!dx && !dy)) return;
+ rect_t area = tab_area(a);
+ int left = n->rect.x, top = n->rect.y;
+ int right = n->rect.x + n->rect.w, bottom = n->rect.y + n->rect.h;
+ int ax1 = area.x + area.w, ay1 = area.y + area.h;
+ if (edges & FEDGE_L) {
+ left += dx;
+ if (left < area.x) left = area.x;
+ if (left > right - MIN_PANE_COLS) left = right - MIN_PANE_COLS;
+ }
+ if (edges & FEDGE_R) {
+ right += dx;
+ if (right > ax1) right = ax1;
+ if (right < left + MIN_PANE_COLS) right = left + MIN_PANE_COLS;
+ }
+ if (edges & FEDGE_T) {
+ top += dy;
+ if (top < area.y) top = area.y;
+ if (top > bottom - MIN_PANE_ROWS) top = bottom - MIN_PANE_ROWS;
+ }
+ if (edges & FEDGE_B) {
+ bottom += dy;
+ if (bottom > ay1) bottom = ay1;
+ if (bottom < top + MIN_PANE_ROWS) bottom = top + MIN_PANE_ROWS;
+ }
+ if (left < 0) left = 0;
+ if (top < 0) top = 0;
+ n->float_rect = (rect_t){(uint16_t)left, (uint16_t)top,
+ (uint16_t)(right - left), (uint16_t)(bottom - top)};
+ layout(a);
+ if (float_editable(a, n)) n->float_rect = n->rect;
+}
+
+/* Which edges of a float a grab at (mx, my) would move: the cell on the
+ * left border is the left edge, a bottom corner is two edges at once. One
+ * derivation shared by the press, the hover hint and the guide, so what is
+ * promised, what is painted and what the drag does cannot disagree — the
+ * hit-list rule, applied to geometry. */
+uint8_t float_edges_at(const node_t *n, uint16_t mx, uint16_t my) {
+ rect_t r = n->rect;
+ uint8_t e = 0;
+ if (mx <= r.x)
+ e |= FEDGE_L;
+ else if (mx >= (uint16_t)(r.x + r.w - 1))
+ e |= FEDGE_R;
+ if (my <= r.y)
+ e |= FEDGE_T;
+ else if (my >= (uint16_t)(r.y + r.h - 1))
+ e |= FEDGE_B;
+ return e;
+}
+
+/* The keyboard's move, one gap-aspect-square step per press, so a nudge
+ * looks the same distance on both axes. False when the focus is not a
+ * float, which hands the keys back to the boundary they usually move. */
+bool focus_float_move(app_t *a, int dx, int dy) {
+ node_t *n = a->ntabs ? cur(a)->focus : NULL;
+ if (!n || !n->floating) return false;
+ int aspect = CFG.gap_aspect ? CFG.gap_aspect : 2;
+ float_move(a, n, dx * aspect, dy);
+ return true;
+}
+
+/* Grow or shrink a focused float about its own centre, one gap-aspect
+ * square per side per press: `+` makes it bigger in every direction, `-`
+ * smaller, and the pane stays where you were looking. Pinned by the area
+ * and floored like every other float edit; the centre only drifts when a
+ * wall or the floor stops one side and not the other. False when the focus
+ * is not a float. */
+bool focus_float_grow(app_t *a, int sign) {
+ node_t *n = a->ntabs ? cur(a)->focus : NULL;
+ if (!n || !n->floating) return false;
+ if (!float_editable(a, n)) return true; /* a float, just not editable now */
+ rect_t area = tab_area(a);
+ int aspect = CFG.gap_aspect ? CFG.gap_aspect : 2;
+ int w = (int)n->rect.w + sign * 2 * aspect;
+ int h = (int)n->rect.h + sign * 2;
+ if (w < MIN_PANE_COLS) w = MIN_PANE_COLS;
+ if (w > area.w) w = area.w;
+ if (h < MIN_PANE_ROWS) h = MIN_PANE_ROWS;
+ if (h > area.h) h = area.h;
+ int x = (int)n->rect.x + ((int)n->rect.w - w) / 2;
+ int y = (int)n->rect.y + ((int)n->rect.h - h) / 2;
+ if (x < area.x) x = area.x;
+ if (y < area.y) y = area.y;
+ if (x + w > area.x + area.w) x = area.x + area.w - w;
+ if (y + h > area.y + area.h) y = area.y + area.h - h;
+ n->float_rect = (rect_t){(uint16_t)x, (uint16_t)y, (uint16_t)w, (uint16_t)h};
+ layout(a);
+ if (float_editable(a, n)) n->float_rect = n->rect;
+ return true;
+}
+
+/* A new floating shell: the throwaway terminal. The pane is real — it has a
+ * seat in the tree beside the focused pane, so un-floating it lands
+ * somewhere sensible and closing it is `close_leaf` like anyone — it simply
+ * starts life lifted, centred at the never-placed default size. A shell
+ * closes when you type `exit` (D14), which is what makes it throwaway
+ * without any new machinery saying so. */
+uint32_t app_new_float(app_t *a) {
+ node_t *beside = a->ntabs ? cur(a)->focus : NULL;
+ if (!beside || beside->kind != NODE_LEAF) return 0;
+ char cwdbuf[4096];
+ const char *cwd = live_cwd(beside->pane, cwdbuf, sizeof cwdbuf);
+ node_t *fresh = leaf_new_ex(a, default_argv(a), cwd, false, "");
+ if (!fresh) return 0;
+ place_beside(a, cur(a), beside, fresh, preferred_dir(beside), false);
+ fresh->floating = true;
+ fresh->raised = ++a->raise_seq;
+ /* float_rect stays zero: the centred two-thirds default. */
+ cur(a)->focus = fresh;
+ layout(a);
+ return fresh->id;
+}
+
void app_set_session(app_t *a, const char *name) {
snprintf(a->session, sizeof a->session, "%s", name ? name : "");
}
@@ -928,6 +1244,9 @@ bool app_focus_pane(app_t *a, uint32_t id) {
bool app_split_pane(app_t *a, uint32_t id, bool rows) {
if (id && !app_focus_pane(a, id)) return false;
+ /* Refused, not quietly ignored: a script declaring it wants a pane beside
+ * a float would otherwise get an ok and no pane. */
+ if (cur(a)->focus && cur(a)->focus->floating) return false;
split_focus(a, rows ? SPLIT_ROWS : SPLIT_COLS);
return true;
}
@@ -950,9 +1269,11 @@ static bool detach_leaf(app_t *a, node_t *leaf, size_t *from, bool *emptied) {
/* Nothing about this pane should still say where it used to be. A zoom is the
* tab's opinion about one of its panes, and a minimised pane is one this tab put
* away; carried across, the first would zoom a pane that has left and the second
- * would land the arrival in a strip nobody asked for. */
+ * would land the arrival in a strip nobody asked for. A float is one this tab
+ * lifted, and it would arrive hovering over a layout that never saw it. */
if (t->zoom == leaf->id) t->zoom = 0;
leaf->minimized = false;
+ leaf->floating = false;
node_t *p = leaf->parent;
if (!p) {
@@ -1258,7 +1579,10 @@ void resize_focus(app_t *a, int dx, int dy) {
static size_t equalize_node(node_t *n) {
if (n->kind == NODE_LEAF) {
n->weight = WEIGHT_UNIT;
- return n->minimized ? 0 : 1;
+ /* A float is out of the layout like a minimised pane: counting one would
+ * hand its share to a pane the tree is not showing. Its own weight is
+ * still evened, because that is the share it lands back into. */
+ return n->minimized || n->floating ? 0 : 1;
}
size_t vis = 0;
for (size_t i = 0; i < n->nkids; i++) vis += equalize_node(n->kids[i]);
@@ -1365,6 +1689,32 @@ void rotate_layout_ui(app_t *a) {
app_toast(a, one_pane ? "nothing to turn" : "no room to turn it");
}
+/* Land a dragged pane beside the target, on the side its drop zone named:
+ * the same detach-and-place surgery moving a pane to another tab does,
+ * aimed within the tab. A flat join through place_beside, exactly like a
+ * border-click split — three drops into a row are three even columns, not a
+ * bisection cascade — and `0` evens the shares as ever. Both panes must be
+ * in the same tab, which is the only place a drop zone can be painted: only
+ * the current tab is composed. */
+bool insert_pane_beside(app_t *a, uint32_t src_id, uint32_t dst_id, char side) {
+ node_t *src = pane_by_id(a, src_id), *dst = pane_by_id(a, dst_id);
+ if (!src || !dst || src == dst || dst->floating) return false;
+ size_t ti = tab_of(a, src);
+ if (ti == (size_t)-1 || tab_of(a, dst) != ti) return false;
+
+ size_t from;
+ bool emptied = false;
+ if (!detach_leaf(a, src, &from, &emptied)) return false;
+ /* Two leaves in one tree share a root split, so detaching one cannot
+ * empty the tab; the guard is here for the day that stops being true. */
+ if (emptied) return false;
+ place_beside(a, &a->tabs[ti], dst, src, side_dir(side),
+ side == 'l' || side == 't');
+ a->tabs[ti].focus = src; /* your hand was on it; it keeps the keyboard */
+ layout(a);
+ return true;
+}
+
/* Reordering is a swap of two leaves, in place: their positions, weights and
* parents trade, and everything else about them is untouched. */
bool swap_panes(app_t *a, uint32_t id_a, uint32_t id_b) {
diff --git a/src/app_ui.c b/src/app_ui.c
index ec4a6ef..d9e231c 100644
--- a/src/app_ui.c
+++ b/src/app_ui.c
@@ -950,6 +950,17 @@ bool rename_key(app_t *a, const input_event_t *ev) {
if (!a->renaming) return false;
if (ev->kind != EV_KEY || ev->action == KEY_RELEASE) return true;
+ /* The same C-u the picker's query field has: these are the program's two
+ * places you type into, and a key that clears one and is swallowed by the
+ * other is a key you cannot trust. It earns its place here twice over,
+ * because a rename seeds with the current label — an edit, usually — and
+ * "replace it outright" is otherwise a backspace per character. */
+ if ((ev->mods & MOD_CTRL) &&
+ (ev->key == GHOSTTY_KEY_U || ev->unshifted == 'u')) {
+ a->rename_buf[0] = 0;
+ return true;
+ }
+
switch (ev->key) {
case GHOSTTY_KEY_ESCAPE: rename_end(a, false); return true;
case GHOSTTY_KEY_ENTER: rename_end(a, true); return true;
@@ -996,6 +1007,15 @@ void app_compose(app_t *a, screen_t *s) {
draw_node(a, s, cur(a)->root);
draw_corners(a, s);
draw_min_bar(a, s);
+ /* The floats, over everything tiled and under the modals below: painted
+ * later is on top, and the hit list resolves clicks the same way, so the
+ * z-order and the click order cannot disagree. Before the status line,
+ * which cannot overlap them (the clamp keeps floats off it) but reads the
+ * hit list for its hover hint — drawn first, it would ask about a frame
+ * whose hits did not exist yet, and a float's border would hint as the
+ * pane underneath. The order also keeps a float's shadow off the status
+ * row on a gap-less config, since the row is painted after the shade. */
+ draw_floats(a, s);
draw_status_line(a, s);
/* The modals, on top of everything: each is the only thing that can be
* interacted with while it is up, so it owns the topmost hits. The scrim
diff --git a/src/cmd.c b/src/cmd.c
index d3415ae..24baec9 100644
--- a/src/cmd.c
+++ b/src/cmd.c
@@ -120,6 +120,22 @@ static char *cmd_json(app_t *a, screen_t *s, input_parser_t *in,
free(txt);
return j.buf;
}
+ /* The emitter's own output: what a client's terminal would be sent for
+ * this frame. The model and the bytes are two different things, and
+ * asserting on the model while the bytes are wrong is a lesson this
+ * session has already paid for once (the graphics cmd says the same).
+ * Rendering updates the diff state, so a second call is the delta —
+ * which is itself worth testing. */
+ if (strcmp(fmt, "bytes") == 0) {
+ screen_render(s);
+ json_t j;
+ json_init(&j);
+ json_obj_open(&j, NULL);
+ json_bool(&j, "ok", true);
+ json_str(&j, "bytes", s->out ? s->out : "", s->out_len);
+ json_obj_close(&j);
+ return j.buf;
+ }
return jok_raw("screen", screen_dump_json(s));
}
if (strcmp(cmd, "deadline") == 0) {
@@ -156,9 +172,11 @@ static char *cmd_json(app_t *a, screen_t *s, input_parser_t *in,
return jok_int(NULL, 0);
}
if (strcmp(cmd, "split") == 0) {
+ uint32_t id = (uint32_t)jv_geti(req, "id", 0);
bool rows = strcmp(jv_gets(req, "dir", "cols"), "rows") == 0;
- if (!app_split_pane(a, (uint32_t)jv_geti(req, "id", 0), rows))
- return jerr("no such pane");
+ if (!app_split_pane(a, id, rows))
+ return jerr(app_pane_floating(a, id) ? "cannot split a floating pane"
+ : "no such pane");
return jok_int("id", app_focused_pane_id(a));
}
if (strcmp(cmd, "focus") == 0) {
@@ -166,6 +184,22 @@ static char *cmd_json(app_t *a, screen_t *s, input_parser_t *in,
return jerr("no such pane");
return jok_int("id", app_focused_pane_id(a));
}
+ /* The throwaway terminal, scripted: a fresh shell floating over the
+ * current tab. */
+ if (strcmp(cmd, "new-float") == 0) {
+ uint32_t id = app_new_float(a);
+ if (!id) return jerr("cannot open a pane");
+ return jok_int("id", id);
+ }
+ /* Toggle, like the button and the palette entry: M10c grows placement
+ * arguments here, this is the same one verb everything else runs. */
+ if (strcmp(cmd, "float") == 0) {
+ uint32_t id = (uint32_t)jv_geti(req, "id", 0);
+ /* Two refusals share the word: no such pane, and no tiled pane left to
+ * float over. */
+ if (!app_toggle_float(a, id)) return jerr("cannot float that");
+ return jok_int("floating", app_pane_floating(a, id) ? 1 : 0);
+ }
if (strcmp(cmd, "edit-config") == 0) {
return app_edit_config(a) ? jok_int("id", app_focused_pane_id(a))
: jerr("no room for another pane");
diff --git a/src/config.c b/src/config.c
index 6060479..f1361ef 100644
--- a/src/config.c
+++ b/src/config.c
@@ -22,7 +22,13 @@ static const struct {
{"rerun", ACT_RERUN},
{"zoom", ACT_ZOOM},
{"minimize", ACT_MINIMIZE},
+ {"float", ACT_FLOAT},
+ {"new-float", ACT_NEW_FLOAT},
+ {"float-grow", ACT_FLOAT_GROW},
+ {"float-shrink", ACT_FLOAT_SHRINK},
{"set-purpose", ACT_SET_PURPOSE},
+ {"rename-pane", ACT_RENAME_PANE},
+ {"rename-tab", ACT_RENAME_TAB},
{"focus-left", ACT_FOCUS_LEFT},
{"focus-right", ACT_FOCUS_RIGHT},
{"focus-up", ACT_FOCUS_UP},
@@ -83,6 +89,21 @@ static const struct {
{"slash", GHOSTTY_KEY_SLASH},
{"comma", GHOSTTY_KEY_COMMA},
{"period", GHOSTTY_KEY_PERIOD},
+ /* The function row. The decoder has always understood these (legacy
+ * CSI ~-forms, SS3 and kitty alike); the config simply had no name for
+ * them, so nothing could be bound there. */
+ {"f1", GHOSTTY_KEY_F1},
+ {"f2", GHOSTTY_KEY_F2},
+ {"f3", GHOSTTY_KEY_F3},
+ {"f4", GHOSTTY_KEY_F4},
+ {"f5", GHOSTTY_KEY_F5},
+ {"f6", GHOSTTY_KEY_F6},
+ {"f7", GHOSTTY_KEY_F7},
+ {"f8", GHOSTTY_KEY_F8},
+ {"f9", GHOSTTY_KEY_F9},
+ {"f10", GHOSTTY_KEY_F10},
+ {"f11", GHOSTTY_KEY_F11},
+ {"f12", GHOSTTY_KEY_F12},
};
/* A character to a key, and whether typing it needs shift.
@@ -277,8 +298,8 @@ size_t config_messages(const config_t *c, const char **out, size_t max) {
}
static const char *const PSTATE_NAMES[PSTATE_COUNT] = {
- "dragging", "drop_hover", "drop_target", "dead",
- "suspended", "bell", "scrolled", "unfocused",
+ "dragging", "drop_hover", "drop_target", "dead", "suspended",
+ "bell", "scrolled", "floating", "unfocused",
};
const char *pane_state_name(pane_state_t s) {
@@ -846,6 +867,7 @@ void config_defaults(config_t *c) {
* At 60 white text lands on #c3c3c3, which reads as "not this one" without
* reading as "not available". */
c->dim_unfocused = 60;
+ c->float_shadow = 110;
c->min_pane_cols = 24;
c->min_pane_rows = 6;
c->min_split_cols = 32;
@@ -1033,6 +1055,11 @@ void config_defaults(config_t *c) {
bind_add(c, GHOSTTY_KEY_Z, 0, ACT_ZOOM, false);
bind_add(c, GHOSTTY_KEY_E, 0, ACT_EDIT_CONFIG, false);
bind_add(c, GHOSTTY_KEY_M, 0, ACT_MINIMIZE, false);
+ /* `f` is float, not finder. Both wanted the letter; the tie went to the
+ * verb -- f is what "float" sounds like, a float toggle is pressed in a
+ * flow (lift, look, put back) where a reach hurts, and the finder reads
+ * just as naturally as *search* on `s`. */
+ bind_add(c, GHOSTTY_KEY_F, 0, ACT_FLOAT, false);
bind_add(c, GHOSTTY_KEY_H, 0, ACT_FOCUS_LEFT, false);
bind_add(c, GHOSTTY_KEY_L, 0, ACT_FOCUS_RIGHT, false);
bind_add(c, GHOSTTY_KEY_K, 0, ACT_FOCUS_UP, false);
@@ -1050,7 +1077,23 @@ void config_defaults(config_t *c) {
bind_add(c, GHOSTTY_KEY_ARROW_RIGHT, MOD_SHIFT, ACT_RESIZE_RIGHT, false);
bind_add(c, GHOSTTY_KEY_ARROW_UP, MOD_SHIFT, ACT_RESIZE_UP, false);
bind_add(c, GHOSTTY_KEY_ARROW_DOWN, MOD_SHIFT, ACT_RESIZE_DOWN, false);
- bind_add(c, GHOSTTY_KEY_EQUAL, 0, ACT_EQUALIZE, false);
+ /* The =/+ key grows a focused float and `-` shrinks one (through the
+ * split-rows case, where a float's split is a refusal anyway). Grow is
+ * bound with and without shift, because whether `+` arrives shifted
+ * depends on the outer terminal -- the same deal `?` has with `/` -- and
+ * that ambiguity is also why equalize moved instead of sharing the key:
+ * two verbs on one key survive only until a terminal drops the shift.
+ * Equalize lands on `0`, the digit row it already lives on: 1..9 pick a
+ * tab, 0 resets the shares. */
+ bind_add(c, GHOSTTY_KEY_EQUAL, 0, ACT_FLOAT_GROW, false);
+ bind_add(c, GHOSTTY_KEY_EQUAL, MOD_SHIFT, ACT_FLOAT_GROW, false);
+ bind_add(c, GHOSTTY_KEY_DIGIT_0, 0, ACT_EQUALIZE, false);
+ /* Shifted beside `f`, the same verb on a fresh thing: `f` floats this
+ * pane, `F` opens a new floating shell -- the throwaway terminal. (It
+ * spent a day on F12, the IDE key for the same idea, and came back: a
+ * chord of the leader and a letter is what every other verb here costs,
+ * and the function row is a reach the home row is not.) */
+ bind_add(c, GHOSTTY_KEY_F, MOD_SHIFT, ACT_NEW_FLOAT, false);
/* The leader and the space bar: the biggest key on the keyboard, no modifier,
* and the one tmux already spends on cycling layouts — so the hand that knows
* that reaches for the right thing here. Four presses come back round, which
@@ -1088,7 +1131,7 @@ void config_defaults(config_t *c) {
* same shifted-pair shape as `p`/`P`, on the letter people reach for. */
bind_add(c, GHOSTTY_KEY_W, 0, ACT_WORKSPACES, false);
bind_add(c, GHOSTTY_KEY_W, MOD_SHIFT, ACT_SAVE_WORKSPACE, false);
- bind_add(c, GHOSTTY_KEY_F, 0, ACT_FINDER, false);
+ bind_add(c, GHOSTTY_KEY_S, 0, ACT_FINDER, false);
bind_add(c, GHOSTTY_KEY_P, 0, ACT_PALETTE, false);
/* Shifted, beside the palette on the same letter: `p` runs a command, `P`
* tags this pane. A purpose was reachable only from a layout or the socket,
@@ -1137,10 +1180,17 @@ static const struct {
{ACT_RERUN, "panes", "run a finished pane again"},
{ACT_ZOOM, "panes", "fill the tab with it"},
{ACT_MINIMIZE, "panes", "put it away in the strip"},
+ {ACT_FLOAT, "panes", "float it above the layout"},
+ {ACT_NEW_FLOAT, "panes", "a new floating shell"},
/* Under `panes` because a purpose is a fact about this pane, and the label
* says "tag" rather than "set purpose": the word people arrive with is the
* one for what it is *for*, which is finding the pane again later. */
{ACT_SET_PURPOSE, "panes", "tag it with a purpose"},
+ /* Renaming was a double-click and nothing else, which made it the one
+ * verb the palette could not reach — exactly what the palette exists to
+ * prevent. Unbound by default: the double-click is the fast path, and an
+ * action with no key is the palette's whole reason to list it. */
+ {ACT_RENAME_PANE, "panes", "rename this pane"},
{ACT_FOCUS_LEFT, "focus", "go left"},
{ACT_FOCUS_RIGHT, "focus", "go right"},
@@ -1156,6 +1206,8 @@ static const struct {
/* Short enough to fit the palette's label column, which truncates at 26 —
* "give every pane an even share" read as "give every pane an even sh". */
{ACT_EQUALIZE, "size", "even out every split"},
+ {ACT_FLOAT_GROW, "size", "grow a floating pane"},
+ {ACT_FLOAT_SHRINK, "size", "shrink a floating pane"},
/* Under `panes`, not `size`: it undoes what the program in the pane did to
* the pane, which is a fact about that pane and not about the layout. */
{ACT_CLEAR_SHADERS, "panes", "clear this pane's shaders"},
@@ -1168,6 +1220,7 @@ static const struct {
{ACT_ROTATE_LAYOUT, "size", "turn the layout a quarter"},
{ACT_NEW_TAB, "tabs", "new tab"},
+ {ACT_RENAME_TAB, "tabs", "rename this tab"},
{ACT_CLOSE_TAB, "tabs", "close this tab"},
{ACT_NEXT_TAB, "tabs", "next tab"},
{ACT_PREV_TAB, "tabs", "previous tab"},
@@ -1562,6 +1615,10 @@ char *config_render(const config_t *c) {
"dim_unfocused %u // ...and how far the panes you are not in\n",
c->dim_unfocused);
cb_add(&b,
+ "float_shadow %u // the shade a floating pane casts; 0 for "
+ "none\n",
+ c->float_shadow);
+ cb_add(&b,
"keep_dead \"%s\" // which dead panes stay: commands, all, none\n",
c->keep_dead == KEEP_DEAD_ALL ? "all"
: c->keep_dead == KEEP_DEAD_NONE ? "none"
@@ -1772,6 +1829,7 @@ static const char *const KNOWN_TOP[] = {
"double_click_ms",
"word_separators",
"editor",
+ "float_shadow",
"focus_follows_mouse",
"gap",
"gap_aspect",
@@ -2114,6 +2172,10 @@ static bool load_into(config_t *c, const char *path, int depth, char *err,
long v = kdl_arg_int(kdl_child(root, "modal_scrim"), 0, c->modal_scrim);
c->modal_scrim = (uint8_t)(v < 0 ? 0 : v > 255 ? 255 : v);
}
+ {
+ long v = kdl_arg_int(kdl_child(root, "float_shadow"), 0, c->float_shadow);
+ c->float_shadow = (uint8_t)(v < 0 ? 0 : v > 255 ? 255 : v);
+ }
const char *sh = kdl_arg(kdl_child(root, "shell"), 0, NULL);
if (sh) {
diff --git a/tests/test_drop_zones.py b/tests/test_drop_zones.py
new file mode 100644
index 0000000..91ea59a
--- /dev/null
+++ b/tests/test_drop_zones.py
@@ -0,0 +1,246 @@
+#!/usr/bin/env python3
+"""The drop grammar: where on the target you let go is what the drop means.
+
+While a pane is carried, the pane under the pointer subdivides into a centre
+-- the swap it always was, keeping the biggest target -- and four edge bands
+meaning "insert me beside this pane, on this side", which is what turns
+dragging into re-layout. Zones materialise only under the pointer, are
+registered in the hit list by the paint that shows them, and a side whose
+insert would not fit never registers at all -- so the promise on screen and
+the drop on release are the same rects.
+
+A note on flow: the drop resolves against the *painted* frame's hits, so
+routing can never consult geometry the user never saw. The live server
+composes every frame; this driver composes on snapshot/panes, so each drag
+takes a snapshot between motion and release, standing in for the frames a
+real session paints continuously.
+"""
+
+import sys
+
+from harness import Session, check, report
+
+SH = ["/bin/sh", "-c", 'printf "\\033]2;p\\007"; stty raw -echo; cat']
+
+
+def by_id(s):
+ return {p["id"]: p for p in s.panes()}
+
+
+def rect(p):
+ return (p["x"], p["y"], p["w"], p["h"])
+
+
+def drag_to(s, src, x, y, release=True):
+ """Press src's title, move to (x, y), compose, optionally release."""
+ s.send(rf"\e[<0;{src['x'] + 4};{src['y'] + 1}M")
+ s.send(rf"\e[<32;{x + 1};{y + 1}M")
+ snap = s.snapshot() # the frame the user would have seen
+ if release:
+ s.send(rf"\e[<0;{x + 1};{y + 1}m")
+ s.settle(30)
+ return snap
+
+
+def test_a_band_drop_inserts_beside_the_target():
+ """Three panes, so an insert is geometrically distinct from a swap."""
+ with Session(SH, cols=120, rows=30) as s:
+ s.settle(20)
+ s.api("split", dir="cols")
+ s.settle(20)
+ s.api("split", dir="cols")
+ s.settle(20)
+ a, b, c = sorted(by_id(s))
+ ps = by_id(s)
+
+ # Drag A into C's bottom band: A lands under C, B untouched.
+ snap = drag_to(
+ s, ps[a], ps[c]["x"] + ps[c]["w"] // 2, ps[c]["y"] + ps[c]["h"] - 2
+ )
+ ps = by_id(s)
+ check(
+ "the pane lands below the target",
+ ps[a]["y"] > ps[c]["y"] and ps[a]["x"] == ps[c]["x"],
+ f"A {rect(ps[a])} C {rect(ps[c])}",
+ )
+ check("and keeps the keyboard", ps[a]["focused"], str(ps[a]))
+ check(
+ "the bystander is untouched horizontally",
+ ps[b]["y"] == 2,
+ str(rect(ps[b])),
+ )
+
+ # And back out: drag A onto B's left band. A column split this time.
+ ps = by_id(s)
+ drag_to(s, ps[a], ps[b]["x"] + 1, ps[b]["y"] + ps[b]["h"] // 2)
+ ps = by_id(s)
+ check(
+ "a left-band drop lands it beside, before",
+ ps[a]["x"] < ps[b]["x"] and ps[a]["y"] == ps[b]["y"],
+ f"A {rect(ps[a])} B {rect(ps[b])}",
+ )
+
+
+def test_the_centre_is_still_the_swap():
+ with Session(SH, cols=120, rows=30) as s:
+ s.settle(20)
+ s.api("split", dir="cols")
+ s.settle(20)
+ s.api("split", dir="rows")
+ s.settle(20)
+ a, b, c = sorted(by_id(s))
+ ps = by_id(s)
+ ra, rc = rect(ps[a]), rect(ps[c])
+ drag_to(s, ps[a], ps[c]["x"] + ps[c]["w"] // 2, ps[c]["y"] + ps[c]["h"] // 2)
+ ps = by_id(s)
+ check(
+ "dropping on the centre trades places",
+ rect(ps[a]) == rc and rect(ps[c]) == ra,
+ f"A {rect(ps[a])} C {rect(ps[c])}",
+ )
+
+
+def test_zones_materialise_only_under_the_pointer():
+ with Session(SH, cols=120, rows=30) as s:
+ s.settle(20)
+ s.api("split", dir="cols")
+ s.settle(20)
+ s.api("split", dir="cols")
+ s.settle(20)
+ a, b, c = sorted(by_id(s))
+ ps = by_id(s)
+ snap = drag_to(
+ s,
+ ps[a],
+ ps[b]["x"] + ps[b]["w"] // 2,
+ ps[b]["y"] + ps[b]["h"] - 2,
+ release=False,
+ )
+ zones = {h["action"] for h in snap.hits if h["action"].startswith("drop:")}
+ check(
+ "the hovered pane offers its zones",
+ zones and all(z.startswith(f"drop:{b}:") for z in zones),
+ str(zones),
+ )
+ check(
+ "and no other pane offers any",
+ not any(z.startswith(f"drop:{c}:") for z in zones),
+ str(zones),
+ )
+ # The hovered band fills the half the drop would hand over.
+ B = ps[b]
+ low = snap.style_at(B["x"] + 4, B["y"] + B["h"] - 3)
+ high = snap.style_at(B["x"] + 4, B["y"] + 2)
+ check("the claimed half is filled", low != high, f"{low} vs {high}")
+ s.send(r"\e") # any key ends the drag
+
+
+def test_a_zone_that_cannot_fit_is_not_offered():
+ """The refusal is the absence of the band, the same floor a border
+ click's split honours -- so the preview cannot promise what the drop
+ cannot deliver. The dragged pane must not be the target's sibling along
+ the refused axis, because a drop beside a sibling is a reorder and a
+ reorder always fits."""
+ with Session(SH, cols=60, rows=44) as s:
+ s.settle(20)
+ s.api("split", dir="cols")
+ s.settle(20)
+ s.api("split", dir="rows")
+ s.settle(20)
+ a, b, c = sorted(by_id(s))
+ ps = by_id(s)
+ # C sits under B in a rows split; A is across in the root columns
+ # split, so it is nobody's sibling along cols. C is ~28 cols: laid
+ # out fine, too narrow to host another column, so no upright bands;
+ # its rows still fit.
+ snap = drag_to(
+ s,
+ ps[a],
+ ps[c]["x"] + 1,
+ ps[c]["y"] + ps[c]["h"] // 2,
+ release=False,
+ )
+ zones = {h["action"] for h in snap.hits if h["action"].startswith("drop:")}
+ check(
+ "no column bands on a pane too narrow to split",
+ not any(z.endswith(":l") or z.endswith(":r") for z in zones),
+ str(zones),
+ )
+ check(
+ "while the row bands still offer",
+ any(z.endswith(":t") for z in zones),
+ str(zones),
+ )
+ s.send(r"\e")
+
+
+def test_a_reorder_among_siblings_is_always_offered():
+ """Dragging a pane onto its neighbour's far band rearranges the row: the
+ pane leaves the split it rejoins, so the child count never changes and
+ there is nothing to fit — refusing it was refusing the most ordinary
+ drag there is."""
+ with Session(SH, cols=100, rows=28) as s:
+ s.settle(20)
+ s.api("split", dir="cols")
+ s.settle(20)
+ a, b = sorted(by_id(s))
+ ps = by_id(s)
+ # ~47 cols each: far too narrow for a third column, but these two
+ # are siblings along cols, so the bands offer the reorder.
+ snap = drag_to(
+ s,
+ ps[a],
+ ps[b]["x"] + ps[b]["w"] - 2,
+ ps[b]["y"] + ps[b]["h"] // 2,
+ release=False,
+ )
+ zones = {h["action"] for h in snap.hits if h["action"].startswith("drop:")}
+ check(
+ "the sibling's column bands are offered",
+ any(z.endswith(":r") for z in zones),
+ str(zones),
+ )
+ s.send(
+ rf"\e[<0;{ps[b]['x'] + ps[b]['w'] - 1};{ps[b]['y'] + ps[b]['h'] // 2 + 1}m"
+ )
+ s.settle(30)
+ ps = by_id(s)
+ check(
+ "and the drop rearranges the row",
+ ps[a]["x"] > ps[b]["x"] and ps[a]["y"] == ps[b]["y"],
+ f"A {rect(ps[a])} B {rect(ps[b])}",
+ )
+
+
+def test_a_float_offers_no_zones():
+ with Session(SH, cols=120, rows=30) as s:
+ s.settle(20)
+ s.api("split", dir="cols")
+ s.settle(20)
+ s.api("split", dir="cols")
+ s.settle(20)
+ ids = sorted(by_id(s))
+ s.api("float", id=ids[2])
+ s.settle(20)
+ ps = by_id(s)
+ fl = ps[ids[2]]
+ snap = drag_to(
+ s,
+ ps[ids[0]],
+ fl["x"] + fl["w"] // 2,
+ fl["y"] + fl["h"] - 2,
+ release=False,
+ )
+ zones = {h["action"] for h in snap.hits if h["action"].startswith("drop:")}
+ check("a float subdivides for nobody", zones == set(), str(zones))
+ s.send(r"\e")
+
+
+if __name__ == "__main__":
+ test_a_band_drop_inserts_beside_the_target()
+ test_the_centre_is_still_the_swap()
+ test_zones_materialise_only_under_the_pointer()
+ test_a_zone_that_cannot_fit_is_not_offered()
+ test_a_reorder_among_siblings_is_always_offered()
+ test_a_float_offers_no_zones()
+ sys.exit(report())
diff --git a/tests/test_equalize.py b/tests/test_equalize.py
index dca3aa8..d224389 100755
--- a/tests/test_equalize.py
+++ b/tests/test_equalize.py
@@ -45,7 +45,7 @@ def test_a_nudged_row_of_columns_goes_back_to_even():
str(nudged),
)
- s.key("=")
+ s.key("0")
s.settle()
back = widths(s)
check("equalize puts them back to even", max(back) - min(back) <= 1, str(back))
@@ -66,7 +66,7 @@ def test_it_divides_a_split_between_the_panes_behind_it():
f"{left['w']} vs {top['w']}",
)
- s.key("=")
+ s.key("0")
s.settle()
left, top, bottom = by_pos(s)
check(
@@ -105,7 +105,7 @@ def test_a_minimised_pane_is_not_counted():
s.settle()
check("one pane is away in the strip", len(widths(s)) == 2, str(widths(s)))
- s.key("=")
+ s.key("0")
s.settle()
seen = widths(s)
check(
@@ -119,7 +119,7 @@ def test_one_pane_has_nothing_to_even_out():
with Session(SH, cols=80, rows=24) as s:
s.settle()
before = s.pane()
- s.key("=")
+ s.key("0")
s.settle()
snap = s.snapshot()
check(
@@ -146,7 +146,7 @@ def test_it_is_in_the_palette_and_the_cheatsheet():
sheet = s.snapshot().screen()
check(
"the cheatsheet lists it under size, with its key",
- "= even out every split" in sheet,
+ "0 even out every split" in sheet,
repr(sheet[:400]),
)
s.send("q") # any key closes the sheet
@@ -162,7 +162,7 @@ def test_it_is_in_the_palette_and_the_cheatsheet():
)
check(
"with the key it would have skipped",
- "C-a =" in pal.screen(),
+ "C-a 0" in pal.screen(),
repr(pal.screen()[:400]),
)
check(
diff --git a/tests/test_finder.py b/tests/test_finder.py
index 6d3e80e..b5c75df 100644
--- a/tests/test_finder.py
+++ b/tests/test_finder.py
@@ -102,7 +102,7 @@ def test_finder_keyboard():
with Session(SH, cols=70, rows=16) as s:
a, b = setup_panes(s)
- s.key("f")
+ s.key("s")
s.settle(80)
snap = s.snapshot()
check("the finder opens", is_open(s), repr(snap.screen()[:200]))
@@ -135,7 +135,7 @@ def test_finder_keyboard():
with Session(SH, cols=70, rows=16) as s:
setup_panes(s)
- s.key("f")
+ s.key("s")
s.send("zzzz")
s.settle(80)
check("a query matching nothing is not fatal", is_open(s))
@@ -162,7 +162,7 @@ def test_finder_navigation():
reached for by hands that already know one."""
with Session(SH, cols=70, rows=16) as s:
setup_panes(s)
- s.key("f")
+ s.key("s")
s.settle(80)
check("it starts on the first row", counter(s) == (1, 2), str(counter(s)))
@@ -205,7 +205,7 @@ def test_finder_navigation():
def test_finder_query_editing():
with Session(SH, cols=70, rows=16) as s:
setup_panes(s)
- s.key("f")
+ s.key("s")
s.send("web")
s.settle(80)
check("the query narrows to one", counter(s) == (1, 1), str(counter(s)))
@@ -238,7 +238,7 @@ def test_finder_mouse():
a, b = setup_panes(s)
s.api("select-tab", index=1)
s.settle()
- s.key("f")
+ s.key("s")
s.settle(80)
snap = s.snapshot()
pos = snap.find("service:web")
@@ -268,7 +268,7 @@ def test_finder_dismissal_by_mouse():
s.api("focus", id=b)
s.settle()
- s.key("f")
+ s.key("s")
s.settle(80)
close = [h for h in s.snapshot().hits if h["action"] == "closefind"]
check(
@@ -284,7 +284,7 @@ def test_finder_dismissal_by_mouse():
"without changing the focus", s.focused()["id"] == b, str(s.focused())
)
- s.key("f")
+ s.key("s")
s.settle(80)
check("it is open again", is_open(s))
s.click(1, 15) # the corner of the screen, well outside the box
@@ -302,7 +302,7 @@ def test_finder_marks_where_you_are():
a, b = setup_panes(s)
s.api("focus", id=b)
s.settle()
- s.key("f")
+ s.key("s")
s.settle(80)
marked = [r for r in rows(s) if "\u2022" in r[2]]
check(
@@ -340,7 +340,7 @@ def test_finder_over_a_collapsed_layout():
[p for p in s.panes() if p["id"] == target][0]["hidden"],
)
- s.key("f")
+ s.key("s")
s.send("findme")
s.settle(80)
s.send(r"\r")
diff --git a/tests/test_float.py b/tests/test_float.py
new file mode 100644
index 0000000..6ace740
--- /dev/null
+++ b/tests/test_float.py
@@ -0,0 +1,872 @@
+#!/usr/bin/env python3
+"""Floating panes (M10a).
+
+A floating pane is a leaf still in the tree, carrying intent the way
+`minimized` does: the layout skips it, the siblings absorb its share, and
+un-floating returns it home. What is drawn is the wanted rect clamped to the
+tab's area, derived every frame and never written back — so what is worth
+checking is that the overlap resolves to what you can see, that the intent
+survives a resize exactly, that a flattened tab lists floats like everyone
+else, and that the two guards hold: a tab keeps a tiled backdrop, and a
+float's border never promises a split.
+"""
+
+import sys
+import tempfile
+
+from harness import Session, check, report
+
+
+def cfg(text):
+ f = tempfile.NamedTemporaryFile("w", suffix=".kdl", delete=False)
+ f.write(text)
+ f.close()
+ return f.name
+
+
+SH = ["/bin/sh", "-c", 'printf "\\033]2;p\\007"; stty raw -echo; cat']
+
+
+def here(s):
+ active = [t["index"] for t in s.tabs() if t["active"]][0]
+ return [p for p in s.panes() if p["tab"] == active]
+
+
+def floats(s):
+ """Floats on screen. A minimised float keeps `floating` — that intent is
+ what brings it back floating — but it has no rect while it is away."""
+ return [p for p in here(s) if p["floating"] and p["w"] > 1]
+
+
+def tiled(s):
+ return [p for p in here(s) if not p["floating"] and p["w"] > 1]
+
+
+def rect(p):
+ return (p["x"], p["y"], p["w"], p["h"])
+
+
+def overlap(a, b):
+ """The intersection of two panes' content rects, or None."""
+ x0 = max(a["content_x"], b["content_x"])
+ y0 = max(a["content_y"], b["content_y"])
+ x1 = min(a["content_x"] + a["content_w"], b["content_x"] + b["content_w"])
+ y1 = min(a["content_y"] + a["content_h"], b["content_y"] + b["content_h"])
+ if x0 >= x1 or y0 >= y1:
+ return None
+ return ((x0 + x1) // 2, (y0 + y1) // 2)
+
+
+def test_floating_lifts_a_pane_out_of_the_layout():
+ with Session(SH, cols=90, rows=24) as s:
+ s.settle(20)
+ s.api("split", dir="cols")
+ s.settle(20)
+ before = max(p["w"] for p in tiled(s))
+ r = s.api("float")
+ check("the toggle answers", r.get("ok") and r.get("floating") == 1, str(r))
+ s.settle(20)
+
+ fl, ti = floats(s), tiled(s)
+ check("one pane floats", len(fl) == 1, str(here(s)))
+ check(
+ "and the tiled one took the space back",
+ len(ti) == 1 and ti[0]["w"] > before,
+ str(here(s)),
+ )
+ check("the float keeps focus", fl[0]["focused"], str(fl))
+ check(
+ "seeded from its tiled rect, lifted a cell off the page",
+ fl[0]["w"] < before and fl[0]["w"] > 1,
+ str(fl),
+ )
+
+
+def test_the_overlap_belongs_to_the_float():
+ """Painted last is on top, and the hit list is searched backwards, so the
+ z-order and the click order are the same list — no routing code to
+ disagree with the paint."""
+ with Session(SH, cols=90, rows=24) as s:
+ s.settle(20)
+ s.api("split", dir="cols")
+ s.settle(20)
+ s.api("float")
+ s.settle(20)
+ fl, ti = floats(s)[0], tiled(s)[0]
+
+ # The float sits inside the tiled pane's rect now that it spans the tab.
+ check(
+ "the float is over the tiled pane",
+ fl["x"] > ti["x"] and fl["x"] + fl["w"] <= ti["x"] + ti["w"],
+ f"{rect(fl)} vs {rect(ti)}",
+ )
+ snap = s.snapshot()
+ corner = snap.line(fl["y"])[fl["x"]]
+ check(
+ "its frame is drawn over the tiled cells",
+ corner in "╭┌",
+ repr(snap.line(fl["y"])),
+ )
+ cx, cy = fl["x"] + fl["w"] // 2, fl["y"] + fl["h"] // 2
+ hit = snap.hit_at(cx, cy)
+ check(
+ "a click in the overlap lands on the float",
+ hit == f"pane:{fl['id']}",
+ str(hit),
+ )
+
+ # What the float's program prints lands at the float's cells.
+ s.raw("FLOATED")
+ s.until_text("FLOATED")
+ snap = s.snapshot()
+ row = snap.line(fl["content_y"])
+ check(
+ "and its content is composed at the float's position",
+ row[fl["content_x"] : fl["content_x"] + 7] == "FLOATED",
+ repr(row),
+ )
+
+
+def test_focusing_a_float_raises_it():
+ """Two floats overlapping: the focused one paints last, so it is on top —
+ stamped in layout(), once, rather than at each place focus can move."""
+ with Session(SH, cols=90, rows=24) as s:
+ s.settle(20)
+ s.api("split", dir="cols")
+ s.settle(20)
+ s.api("split", dir="cols")
+ s.settle(20)
+ ids = [p["id"] for p in here(s)]
+ # Float the middle pane, then the pane that expanded over its ground:
+ # the second seed overlaps the first, which is the arrangement z-order
+ # is about.
+ s.api("float", id=ids[1])
+ s.settle(20)
+ s.api("float", id=ids[2])
+ s.settle(20)
+ fl = {p["id"]: p for p in floats(s)}
+ check("two floats", len(fl) == 2, str(here(s)))
+ spot = overlap(fl[ids[1]], fl[ids[2]])
+ check(
+ "their rects overlap", spot is not None, str(list(map(rect, fl.values())))
+ )
+ if not spot:
+ return
+ hit = s.snapshot().hit_at(*spot)
+ check(
+ "the newest float owns the overlap",
+ hit == f"pane:{ids[2]}",
+ str(hit),
+ )
+ s.api("focus", id=ids[1])
+ s.settle(20)
+ hit = s.snapshot().hit_at(*spot)
+ check(
+ "focusing the one underneath raises it",
+ hit == f"pane:{ids[1]}",
+ str(hit),
+ )
+
+
+def test_the_wanted_rect_survives_a_resize_exactly():
+ """The drawn rect is the intent clamped to the area, derived every frame:
+ the clamp never writes back, which is the collapse/expand lesson the
+ weights already learned, asserted for floats."""
+ with Session(SH, cols=90, rows=24) as s:
+ s.settle(20)
+ s.api("split", dir="cols")
+ s.settle(20)
+ s.api("float")
+ s.settle(20)
+ want = rect(floats(s)[0])
+
+ s.resize(40, 14)
+ s.settle(20)
+ fl = floats(s)
+ check("still floating on a small screen", len(fl) == 1, str(here(s)))
+ check(
+ "and squeezed inside it",
+ fl[0]["x"] + fl[0]["w"] <= 40 and fl[0]["y"] + fl[0]["h"] <= 14,
+ str(rect(fl[0])),
+ )
+
+ s.resize(90, 24)
+ s.settle(20)
+ check(
+ "the room comes back and the float is exactly where you put it",
+ rect(floats(s)[0]) == want,
+ f"{want} -> {rect(floats(s)[0])}",
+ )
+
+
+def test_unfloating_returns_it_to_its_seat():
+ with Session(SH, cols=90, rows=24) as s:
+ s.settle(20)
+ s.api("split", dir="cols")
+ s.settle(20)
+ layout_before = sorted(map(rect, tiled(s)))
+ s.api("float")
+ s.settle(20)
+ s.api("float")
+ s.settle(20)
+ check("nothing floats", floats(s) == [], str(here(s)))
+ check(
+ "and the layout is the one it left",
+ sorted(map(rect, tiled(s))) == layout_before,
+ str(sorted(map(rect, tiled(s)))),
+ )
+
+
+def test_the_backdrop_keeps_a_pane():
+ with Session(SH, cols=90, rows=24) as s:
+ s.settle(20)
+ r = s.api("float")
+ check("the only pane cannot float", not r.get("ok"), str(r))
+
+ s.api("split", dir="cols")
+ s.settle(20)
+ s.api("float")
+ s.settle(20)
+ check("with two, one can", len(floats(s)) == 1, str(here(s)))
+ last = tiled(s)[0]["id"]
+ r = s.api("float", id=last)
+ check("but the last tiled pane cannot", not r.get("ok"), str(r))
+
+ # Minimising the last tiled pane is the same hole by another door.
+ s.api("focus", id=last)
+ s.settle(20)
+ s.send(r"\x01m")
+ s.settle(20)
+ check(
+ "and neither can it be minimised",
+ len(tiled(s)) == 1,
+ str(here(s)),
+ )
+
+
+def test_closing_the_last_tiled_pane_lands_the_float():
+ """The guard refuses to make the overlay-over-nothing state; closing can
+ still reach it, so the top float lands — checked once, in layout()."""
+ with Session(SH, cols=90, rows=24) as s:
+ s.settle(20)
+ s.api("split", dir="cols")
+ s.settle(20)
+ fid = s.api("float")
+ s.settle(20)
+ fid = floats(s)[0]["id"]
+ other = tiled(s)[0]["id"]
+ s.api("close", id=other)
+ s.settle(20)
+ ps = here(s)
+ check("one pane left", len(ps) == 1 and ps[0]["id"] == fid, str(ps))
+ check("and it landed", not ps[0]["floating"], str(ps))
+ check("filling the tab", ps[0]["w"] > 80, str(rect(ps[0])))
+
+
+def test_a_float_cannot_be_split():
+ with Session(SH, cols=90, rows=24) as s:
+ s.settle(20)
+ s.api("split", dir="cols")
+ s.settle(20)
+ s.api("float")
+ s.settle(20)
+ n = len(here(s))
+ fid = floats(s)[0]["id"]
+
+ s.send(r"\x01\\")
+ s.settle(20)
+ check("the keyboard is refused", len(here(s)) == n, str(here(s)))
+ check(
+ "with the float's own words",
+ "a floating pane cannot be split" in s.snapshot().screen(),
+ repr(s.snapshot().screen()[-300:]),
+ )
+ r = s.api("split", id=fid)
+ check("the API is refused too", not r.get("ok"), str(r))
+ check(
+ "and says why",
+ "floating" in r.get("error", ""),
+ str(r),
+ )
+ # The guide never offers what split_fits declines: no handle glyph is
+ # promised here; the border's handle hits still exist but their click
+ # path declines. That path is exercised in M10b with the drags.
+
+
+def test_a_flattened_tab_lists_floats_like_everyone_else():
+ """A screen too small for tiles is too small for overlays; the float is a
+ reachable row, and its wanted rect is untouched for when room returns."""
+ with Session(SH, cols=90, rows=24) as s:
+ s.settle(20)
+ s.api("split", dir="cols")
+ s.settle(20)
+ s.api("float")
+ s.settle(20)
+ want = rect(floats(s)[0])
+ fid = floats(s)[0]["id"]
+
+ s.resize(26, 20)
+ s.settle(20)
+ ps = here(s)
+ check("the tab is a list", any(p["hidden"] for p in ps), str(ps))
+ check(
+ "and the float is in it, a row like everyone else",
+ all(p["w"] > 1 for p in ps),
+ str(ps),
+ )
+
+ s.resize(90, 24)
+ s.settle(20)
+ check(
+ "room back: floating again, exactly where it was",
+ len(floats(s)) == 1 and rect(floats(s)[0]) == want,
+ str(here(s)),
+ )
+ check("same pane", floats(s)[0]["id"] == fid, str(floats(s)))
+
+
+def test_a_minimised_float_comes_back_floating():
+ with Session(SH, cols=90, rows=24) as s:
+ s.settle(20)
+ s.api("split", dir="cols")
+ s.settle(20)
+ s.api("float")
+ s.settle(20)
+ fid = floats(s)[0]["id"]
+ s.send(r"\x01m")
+ s.settle(20)
+ check("a float can be put away", floats(s) == [], str(here(s)))
+ s.api("focus", id=fid)
+ s.settle(20)
+ check(
+ "and focusing it restores it floating",
+ len(floats(s)) == 1 and floats(s)[0]["id"] == fid,
+ str(here(s)),
+ )
+
+
+def test_zoom_fills_the_tab_whether_it_floats_or_not():
+ with Session(SH, cols=90, rows=24) as s:
+ s.settle(20)
+ s.api("split", dir="cols")
+ s.settle(20)
+ s.api("float")
+ s.settle(20)
+ want = rect(floats(s)[0])
+ s.send(r"\x01z")
+ s.settle(20)
+ fl = floats(s)
+ check("zoomed, the float fills the tab", fl and fl[0]["w"] > 80, str(fl))
+ s.send(r"\x01z")
+ s.settle(20)
+ check(
+ "and unzooming returns it to its rect",
+ rect(floats(s)[0]) == want,
+ f"{want} -> {rect(floats(s)[0])}",
+ )
+
+
+def drag(s, x0, y0, x1, y1):
+ s.send(rf"\e[<0;{x0 + 1};{y0 + 1}M")
+ s.send(rf"\e[<32;{x1 + 1};{y1 + 1}M")
+ s.send(rf"\e[<0;{x1 + 1};{y1 + 1}m")
+ s.settle(20)
+
+
+def floated(s):
+ """One pane split off and floating, ready to be handled."""
+ s.settle(20)
+ s.api("split", dir="cols")
+ s.settle(20)
+ s.api("float")
+ s.settle(20)
+ return floats(s)[0]
+
+
+def test_dragging_the_title_moves_it():
+ """A float's top row carries its own verb: everything a title drag means
+ on release -- swap, split up -- is what a float must not do."""
+ with Session(SH, cols=90, rows=24) as s:
+ fl = floated(s)
+ before = rect(fl)
+ tiled_before = sorted(map(rect, tiled(s)))
+ # Left and up, where the area has room; down would hit the clamp,
+ # which is the resize test's subject rather than this one's.
+ drag(s, fl["x"] + 3, fl["y"], fl["x"] - 7, fl["y"] - 1)
+ after = rect(floats(s)[0])
+ check(
+ "the float follows the pointer",
+ after == (before[0] - 10, before[1] - 1, before[2], before[3]),
+ f"{before} -> {after}",
+ )
+ check(
+ "and nothing tiled moved",
+ sorted(map(rect, tiled(s))) == tiled_before,
+ str(sorted(map(rect, tiled(s)))),
+ )
+ check("no pane swapped anywhere", len(floats(s)) == 1, str(here(s)))
+
+
+def test_dragging_an_edge_resizes_it():
+ with Session(SH, cols=90, rows=24) as s:
+ fl = floated(s)
+ # Away from the wall first: the seed sits a cell inside the tiled
+ # pane, and an edge dragged into the wall pins there — that clamp
+ # has its own test below.
+ drag(s, fl["x"] + 3, fl["y"], fl["x"] - 7, fl["y"])
+ fl = floats(s)[0]
+ before = rect(fl)
+ # The right edge, mid-height: rim or handle, either is a grab.
+ ex, ey = fl["x"] + fl["w"] - 1, fl["y"] + fl["h"] // 2
+ drag(s, ex, ey, ex + 6, ey)
+ after = rect(floats(s)[0])
+ check(
+ "the right edge follows the pointer",
+ after == (before[0], before[1], before[2] + 6, before[3]),
+ f"{before} -> {after}",
+ )
+
+ # A bottom corner is two edges, derived from where the press landed.
+ fl = floats(s)[0]
+ before = rect(fl)
+ cx, cy = fl["x"], fl["y"] + fl["h"] - 1
+ drag(s, cx, cy, cx - 4, cy + 1)
+ after = rect(floats(s)[0])
+ check(
+ "a corner resizes on both axes",
+ after == (before[0] - 4, before[1], before[2] + 4, before[3] + 1),
+ f"{before} -> {after}",
+ )
+
+
+def test_the_floor_stops_the_grabbed_edge():
+ with Session(SH, cols=90, rows=24) as s:
+ fl = floated(s)
+ ex, ey = fl["x"] + fl["w"] - 1, fl["y"] + fl["h"] // 2
+ drag(s, ex, ey, fl["x"] - 30, ey) # far past the left edge
+ after = rect(floats(s)[0])
+ check(
+ "a resize stops at the floor rather than inverting",
+ after[2] >= 4 and after[0] == fl["x"],
+ f"{rect(fl)} -> {after}",
+ )
+ # And the wall pins the grabbed edge: the opposite one never moves.
+ fl = floats(s)[0]
+ ex = fl["x"] + fl["w"] - 1
+ drag(s, ex, ey, 200, ey) # far past the right wall
+ after = rect(floats(s)[0])
+ check(
+ "the wall pins the edge you hold, not the window",
+ after[0] == fl["x"] and after[0] + after[2] <= 90,
+ f"{rect(fl)} -> {after}",
+ )
+
+
+def test_a_keystroke_ends_a_float_drag():
+ """A release can go missing; any key ends a drag, floats included."""
+ with Session(SH, cols=90, rows=24) as s:
+ fl = floated(s)
+ before = rect(fl)
+ s.send(rf"\e[<0;{fl['x'] + 4};{fl['y'] + 1}M") # press, no release
+ s.send(rf"\e[<32;{fl['x'] - 6};{fl['y'] + 1}M")
+ s.settle(20)
+ s.send(r"\x01l") # any chord: the drag must not survive it
+ s.settle(20)
+ moved = rect(floats(s)[0])
+ s.send(rf"\e[<32;{fl['x'] + 20};{fl['y'] + 5}M") # stray motion after
+ s.settle(20)
+ check(
+ "motion after the keystroke moves nothing",
+ rect(floats(s)[0]) == moved,
+ f"{moved} -> {rect(floats(s)[0])}",
+ )
+ check("the earlier drag did move it", moved != before, str(moved))
+
+
+def test_the_keyboard_moves_a_focused_float():
+ """H J K L: a float has no boundary to move, so the same keys move the
+ thing itself -- one gap-aspect-square step per press."""
+ with Session(SH, cols=90, rows=24) as s:
+ fl = floated(s)
+ before = rect(fl)
+ s.send(r"\x01H")
+ s.settle(20)
+ s.send(r"\x01J")
+ s.settle(20)
+ after = rect(floats(s)[0])
+ check(
+ "H moves it left and J moves it down",
+ after[0] < before[0] and after[1] == before[1] + 1,
+ f"{before} -> {after}",
+ )
+ # And the tiled boundary keys still work when focus is tiled.
+ s.api("focus", id=tiled(s)[0]["id"])
+ s.settle(20)
+ w0 = tiled(s)[0]["w"]
+ s.send(r"\x01L")
+ s.settle(20)
+ check(
+ "a tiled pane's keys still move its boundary",
+ tiled(s)[0]["w"] == w0, # one tiled pane: nothing to resize against
+ str(tiled(s)[0]["w"]),
+ )
+
+
+def shadow_cells(s):
+ """A shadow cell and an out-of-shade cell on the same backdrop row.
+
+ The freshly seeded float sits one cell inside the tiled pane, so its
+ shadow falls on that pane's frame — styled with or without a shadow.
+ Moved off it first, both probe cells are blank tiled content, and the
+ only thing that can differ between them is the shade."""
+ fl = floats(s)[0]
+ drag(s, fl["x"] + 3, fl["y"], fl["x"] - 7, fl["y"] - 1)
+ fl = floats(s)[0]
+ return (fl["x"] + fl["w"], fl["y"] + 2), (fl["x"] + fl["w"] + 5, fl["y"] + 2)
+
+
+def test_a_float_casts_a_shadow():
+ """A cell of shade beside and below, on whatever is covered: the frame
+ is the same frame, so the shadow is what tells a float from a tile."""
+ with Session(SH, cols=90, rows=24) as s:
+ floated(s)
+ (sx, sy), (ox, oy) = shadow_cells(s)
+ snap = s.snapshot()
+ shadow, clear = snap.style_at(sx, sy), snap.style_at(ox, oy)
+ check("the shadow cell is shaded", shadow is not None, str(shadow))
+ check(
+ "and the same backdrop out of the shade is not",
+ shadow != clear,
+ f"{shadow} vs {clear}",
+ )
+
+
+def test_float_shadow_0_turns_it_off():
+ with Session(SH, cols=90, rows=24, config=cfg("float_shadow 0\n")) as s:
+ floated(s)
+ (sx, sy), (ox, oy) = shadow_cells(s)
+ snap = s.snapshot()
+ check(
+ "no shade is cast",
+ snap.style_at(sx, sy) == snap.style_at(ox, oy),
+ f"{snap.style_at(sx, sy)} vs {snap.style_at(ox, oy)}",
+ )
+
+
+def test_the_floating_state_takes_a_chain():
+ """`states { floating { } }` is a state like any other -- and it ranks
+ above unfocused, so a float is never dimmed as merely-unfocused."""
+ conf = cfg("states { floating { dim amount=200 } }\n")
+ with Session(SH, cols=90, rows=24, config=conf) as s:
+ fl = floated(s)
+ snap = s.snapshot()
+ inside = snap.style_at(fl["content_x"] + 1, fl["content_y"] + 1)
+ check("the chain colours the float", inside is not None, str(inside))
+ s.api("float") # put it back: the chain should leave with the state
+ s.settle(20)
+ p = [q for q in here(s) if q["id"] == fl["id"]][0]
+ snap = s.snapshot()
+ check(
+ "and leaves with the state",
+ snap.style_at(p["content_x"] + 1, p["content_y"] + 1) != inside,
+ str(snap.style_at(p["content_x"] + 1, p["content_y"] + 1)),
+ )
+
+
+def test_floating_pops_to_the_centre():
+ """The pop to the middle is what says the float happened: a pane lifted
+ in place looks like a pane whose neighbours flinched."""
+ with Session(SH, cols=90, rows=24) as s:
+ fl = floated(s)
+ cx = fl["x"] + fl["w"] / 2
+ check("the float is centred", abs(cx - 45) <= 1, f"centre {cx}")
+
+
+def test_refloating_recentres_but_keeps_the_size():
+ """The size is the hand's; the position is the announcement."""
+ with Session(SH, cols=90, rows=24) as s:
+ fl = floated(s)
+ # Shape it and park it in a corner.
+ ex, ey = fl["x"] + fl["w"] - 1, fl["y"] + fl["h"] // 2
+ drag(s, ex, ey, ex - 8, ey)
+ fl = floats(s)[0]
+ drag(s, fl["x"] + 3, fl["y"], 4, 2)
+ shaped = rect(floats(s)[0])
+ s.send(r"\x01f") # dock
+ s.settle(20)
+ s.send(r"\x01f") # and float again
+ s.settle(20)
+ fl = floats(s)[0]
+ check(
+ "the size comes back",
+ (fl["w"], fl["h"]) == (shaped[2], shaped[3]),
+ f"{shaped} -> {rect(fl)}",
+ )
+ cx = fl["x"] + fl["w"] / 2
+ check("but it pops to the centre again", abs(cx - 45) <= 1, f"centre {cx}")
+
+
+def test_plus_grows_and_minus_shrinks_a_float():
+ """About its own centre, so the pane stays where you were looking. On a
+ tiled pane `-` still splits: the keys aim at what is there."""
+ with Session(SH, cols=90, rows=24) as s:
+ fl = floated(s)
+ before = rect(fl)
+ s.send(r"\x01=") # the =/+ key, however shift arrives
+ s.settle(20)
+ grown = rect(floats(s)[0])
+ check(
+ "= grows it in every direction",
+ grown[2] > before[2] and grown[3] > before[3] and grown[0] < before[0],
+ f"{before} -> {grown}",
+ )
+ s.send(r"\x01-")
+ s.settle(20)
+ check(
+ "- shrinks it back about the same centre",
+ rect(floats(s)[0]) == before,
+ f"{grown} -> {rect(floats(s)[0])}",
+ )
+
+ n = len(here(s))
+ s.api("focus", id=tiled(s)[0]["id"])
+ s.settle(20)
+ s.send(r"\x01-")
+ s.settle(20)
+ check("on a tiled pane - still splits", len(here(s)) == n + 1, str(here(s)))
+
+
+def test_zero_equalizes():
+ """Equalize moved off `=` to make room for the float verbs: 1..9 pick a
+ tab, 0 resets the shares."""
+ with Session(SH, cols=90, rows=24) as s:
+ s.settle(20)
+ s.api("split", dir="cols")
+ s.settle(20)
+ # Skew the boundary, then put it back.
+ s.send(r"\x01L")
+ s.settle(20)
+ skewed = [p["w"] for p in tiled(s)]
+ s.send(r"\x010")
+ s.settle(20)
+ even = [p["w"] for p in tiled(s)]
+ check(
+ "0 gives every pane an even share",
+ abs(even[0] - even[1]) <= 1 and skewed[0] != skewed[1],
+ f"{skewed} -> {even}",
+ )
+
+
+def test_new_float_opens_a_floating_shell():
+ """`C-a F`: the throwaway terminal -- f floats this pane, F floats a
+ fresh one. It holds a real seat in the tree, so un-floating lands it
+ beside the pane it was opened over."""
+ with Session(SH, cols=90, rows=24) as s:
+ s.settle(20)
+ n = len(here(s))
+ s.send(r"\x01F")
+ s.settle(40)
+ fl = floats(s)
+ check(
+ "a new pane opens floating",
+ len(fl) == 1 and len(here(s)) == n + 1,
+ str(here(s)),
+ )
+ if not fl:
+ return
+ check("focused", fl[0]["focused"], str(fl))
+ cx = fl[0]["x"] + fl[0]["w"] / 2
+ check("and centred", abs(cx - 45) <= 1, f"centre {cx}")
+
+ s.send(r"\x01f") # dock it: it has a seat to land in
+ s.settle(20)
+ check(
+ "un-floating lands it in the layout",
+ floats(s) == [] and len(tiled(s)) == 2,
+ str(here(s)),
+ )
+
+ r = s.api("new-float")
+ s.settle(20)
+ check("the API opens one too", r.get("ok") and len(floats(s)) == 1, str(r))
+
+
+def test_function_keys_are_bindable():
+ """The decoder always understood the function row; the config just had
+ no name for it. `bind \"f5\" ...` is the proof, on a different key than
+ the default so this is the parser and not the default bind."""
+ conf = cfg('keys { bind "f5" "float" }\n')
+ with Session(SH, cols=90, rows=24, config=conf) as s:
+ s.settle(20)
+ s.api("split", dir="cols")
+ s.settle(20)
+ s.send(r"\x01\e[15~") # C-a F5
+ s.settle(20)
+ check("a config can bind a function key", len(floats(s)) == 1, str(here(s)))
+
+
+FAST = cfg("hover_delay_ms 20\n")
+
+
+def hover(s, x, y):
+ """Motion with no button held: SGR button 35 = release/none + motion."""
+ s.send(rf"\e[<35;{x + 1};{y + 1}M")
+
+
+def test_hovering_a_float_edge_shows_the_resize():
+ """The border is a working handle and has to say so: the edge a grab
+ would move lights in the resize colour with the double arrow at the
+ grab point, and the status line says the verb. A corner lights both
+ edges and says "both ways" -- all read from the same derivation the
+ press uses, so the promise and the drag cannot disagree."""
+ with Session(SH, cols=90, rows=24, config=FAST) as s:
+ fl = floated(s)
+ x1, y1 = fl["x"] + fl["w"] - 1, fl["y"] + fl["h"] - 1
+ ey = fl["y"] + fl["h"] // 2
+
+ hover(s, x1, ey)
+ s.settle(60)
+ snap = s.snapshot()
+ check(
+ "the edge goes heavy with the arrow at the grab point",
+ snap.line(ey)[x1] == "\u21d4" and snap.line(ey - 1)[x1] == "\u2503",
+ repr(snap.line(ey)[x1 - 2 : x1 + 1]),
+ )
+ check(
+ "and the status line says the verb",
+ "drag to resize" in snap.screen(),
+ repr(snap.screen()[-200:]),
+ )
+
+ hover(s, fl["x"], y1)
+ s.settle(60)
+ snap = s.snapshot()
+ check(
+ "a corner lights both edges",
+ snap.line(y1)[fl["x"] + 3] == "\u2501"
+ and snap.line(y1 - 2)[fl["x"]] == "\u2503",
+ repr(snap.line(y1)[fl["x"] : fl["x"] + 5]),
+ )
+ check(
+ "and says both ways",
+ "both ways" in snap.screen(),
+ repr(snap.screen()[-200:]),
+ )
+
+
+def test_a_float_title_promises_no_split():
+ """The tiled handle says 'drag to move / click to split up'; a float's
+ top row only moves it, and the caption must not promise the half a
+ float does not offer."""
+ with Session(SH, cols=90, rows=24, config=FAST) as s:
+ fl = floated(s)
+ th = [h for h in s.snapshot().hits if h["action"] == f"title:{fl['id']}:t"]
+ check("the float's top row still carries its handle", th != [], "")
+ if not th:
+ return
+ hover(s, th[0]["x"], th[0]["y"])
+ s.settle(60)
+ scr = s.snapshot().screen()
+ check(
+ "its hint moves and does not split",
+ "drag to move" in scr and "split up" not in scr,
+ repr(scr[-200:]),
+ )
+
+
+def test_the_grabbed_edge_stays_lit_through_the_drag():
+ with Session(SH, cols=90, rows=24, config=FAST) as s:
+ fl = floated(s)
+ x1 = fl["x"] + fl["w"] - 1
+ ey = fl["y"] + fl["h"] // 2
+ s.send(rf"\e[<0;{x1 + 1};{ey + 1}M") # press the right edge
+ s.send(rf"\e[<32;{x1 - 5};{ey + 1}M") # and pull it left
+ s.settle(40)
+ fl2 = [p for p in s.panes() if p["floating"]][0]
+ nx1 = fl2["x"] + fl2["w"] - 1
+ snap = s.snapshot()
+ check(
+ "the held edge follows lit",
+ snap.line(ey)[nx1] == "\u2503",
+ repr(snap.line(ey)[nx1 - 2 : nx1 + 2]),
+ )
+ s.send(rf"\e[<0;{x1 - 5};{ey + 1}m")
+
+
+def test_a_float_is_not_a_drop_target():
+ """Swapping tree seats with a pane that is not in its seat would
+ rearrange something invisible, so a title drag promises nothing over a
+ float and swaps nothing dropped on one."""
+ with Session(SH, cols=90, rows=24) as s:
+ fl = floated(s)
+ ti = tiled(s)[0]
+ before = sorted(map(rect, here(s)))
+ # Drag the tiled pane's title onto the float and drop it there.
+ drag(s, ti["x"] + 3, ti["y"], fl["x"] + fl["w"] // 2, fl["y"] + 3)
+ check(
+ "nothing swapped",
+ sorted(map(rect, here(s))) == before,
+ str(sorted(map(rect, here(s)))),
+ )
+
+
+def test_the_key_and_the_palette_both_run_it():
+ """`C-a f` floats and unfloats; the palette runs the same action and
+ shows the chord, which is how the palette teaches the key (D19)."""
+ with Session(SH, cols=90, rows=24) as s:
+ s.settle(20)
+ s.api("split", dir="cols")
+ s.settle(20)
+ s.send(r"\x01f")
+ s.settle(20)
+ check("C-a f floats the pane", len(floats(s)) == 1, str(here(s)))
+ s.send(r"\x01f")
+ s.settle(20)
+ check("and again puts it back", floats(s) == [], str(here(s)))
+
+ s.send(r"\x01p")
+ s.settle(50)
+ check(
+ "the palette offers it",
+ "float it above the layout" in s.snapshot().screen(),
+ repr(s.snapshot().screen()[:400]),
+ )
+ s.send("float")
+ s.settle(50)
+ s.send(r"\r")
+ s.settle(50)
+ check("running it floats the pane", len(floats(s)) == 1, str(here(s)))
+
+
+if __name__ == "__main__":
+ test_floating_lifts_a_pane_out_of_the_layout()
+ test_the_overlap_belongs_to_the_float()
+ test_focusing_a_float_raises_it()
+ test_the_wanted_rect_survives_a_resize_exactly()
+ test_unfloating_returns_it_to_its_seat()
+ test_the_backdrop_keeps_a_pane()
+ test_closing_the_last_tiled_pane_lands_the_float()
+ test_a_float_cannot_be_split()
+ test_a_flattened_tab_lists_floats_like_everyone_else()
+ test_a_minimised_float_comes_back_floating()
+ test_zoom_fills_the_tab_whether_it_floats_or_not()
+ test_dragging_the_title_moves_it()
+ test_dragging_an_edge_resizes_it()
+ test_the_floor_stops_the_grabbed_edge()
+ test_a_keystroke_ends_a_float_drag()
+ test_the_keyboard_moves_a_focused_float()
+ test_a_float_casts_a_shadow()
+ test_float_shadow_0_turns_it_off()
+ test_the_floating_state_takes_a_chain()
+ test_floating_pops_to_the_centre()
+ test_refloating_recentres_but_keeps_the_size()
+ test_plus_grows_and_minus_shrinks_a_float()
+ test_zero_equalizes()
+ test_new_float_opens_a_floating_shell()
+ test_function_keys_are_bindable()
+ test_hovering_a_float_edge_shows_the_resize()
+ test_a_float_title_promises_no_split()
+ test_the_grabbed_edge_stays_lit_through_the_drag()
+ test_a_float_is_not_a_drop_target()
+ test_the_key_and_the_palette_both_run_it()
+ sys.exit(report())
diff --git a/tests/test_help.py b/tests/test_help.py
index e845530..7c81de4 100644
--- a/tests/test_help.py
+++ b/tests/test_help.py
@@ -96,7 +96,7 @@ def test_sections_are_separated_by_a_blank_line():
# Narrow, so one column and easy to read -- and tall enough for all of it,
# because this is about the spacing between groups and not about what a short
# terminal does with the overflow (which is checked below).
- with Session(SH, cols=56, rows=56) as s:
+ with Session(SH, cols=56, rows=60) as s:
s.settle()
snap = open_help(s)
text = [l for l in snap.text if "│" in l]
@@ -129,7 +129,7 @@ def test_the_box_is_not_taller_than_its_contents():
"""The fold falls on a heading, which strands the blank row above it at the
bottom of the first column -- a row that draws nothing and made the box a
line taller than it needed to be."""
- with Session(SH, cols=92, rows=36) as s:
+ with Session(SH, cols=92, rows=38) as s:
s.settle()
snap = open_help(s)
bottom = None
diff --git a/tests/test_hover.py b/tests/test_hover.py
index 2dae5c2..6a71093 100644
--- a/tests/test_hover.py
+++ b/tests/test_hover.py
@@ -76,7 +76,7 @@ def test_guards():
s.send("g") # release the prefix harmlessly
# with the finder open, the pane underneath must not take focus
- s.key("f")
+ s.key("s")
s.settle(60)
hover(s, left["content_x"] + 2, left["content_y"] + 1)
s.settle(60)
diff --git a/tests/test_palette.py b/tests/test_palette.py
index 9741227..fbd2e47 100644
--- a/tests/test_palette.py
+++ b/tests/test_palette.py
@@ -151,6 +151,47 @@ def test_enter_runs_the_command():
check("and the palette closed behind it", not is_open(s))
+def test_rename_is_reachable_from_the_palette():
+ """Renaming was a double-click and nothing else, which made it the one
+ verb the palette could not reach -- exactly what the palette exists to
+ prevent. The editor it opens is the same in-place one the gesture uses,
+ seeded with the current label."""
+ with Session(SH, cols=80, rows=22) as s:
+ s.settle()
+ s.send(OPEN)
+ s.send("rename this tab")
+ s.settle(80)
+ s.send(r"\r")
+ s.settle(80)
+ s.send("deploys")
+ s.settle(40)
+ s.send(r"\r")
+ s.settle(40)
+ check(
+ "rename-tab renames the tab in place",
+ [t["name"] for t in s.tabs()] == ["deploys"],
+ str(s.tabs()),
+ )
+
+ s.send(OPEN)
+ s.send("rename this pane")
+ s.settle(80)
+ s.send(r"\r")
+ s.settle(80)
+ # The editor seeds with the current title (a rename is an edit);
+ # clear it first so the name is what was typed.
+ s.send(r"\x15") # C-u: clear the field
+ s.send("agent")
+ s.settle(40)
+ s.send(r"\r")
+ s.settle(40)
+ check(
+ "rename-pane renames the pane in place",
+ [p["title"] for p in s.panes()] == ["agent"],
+ str([p["title"] for p in s.panes()]),
+ )
+
+
def test_clicking_a_row_runs_it():
with Session(SH, cols=80, rows=22) as s:
s.settle()
@@ -234,7 +275,7 @@ def test_the_finder_is_still_its_own_thing():
"""They share a box; they must not share a list."""
with Session(SH, cols=80, rows=22) as s:
s.settle()
- s.send(r"\x01f")
+ s.send(r"\x01s")
s.settle(100)
check(
"the finder lists panes, not commands",
@@ -269,6 +310,7 @@ def test_every_label_fits_the_column():
if __name__ == "__main__":
test_every_label_fits_the_column()
+ test_rename_is_reachable_from_the_palette()
test_it_opens_and_lists_commands()
test_it_lists_actions_that_have_no_key()
test_typing_filters_it()
diff --git a/tests/test_rotate.py b/tests/test_rotate.py
index ba2d62f..308d8d7 100755
--- a/tests/test_rotate.py
+++ b/tests/test_rotate.py
@@ -164,7 +164,7 @@ def test_a_turn_that_would_not_fit_is_refused():
repr(s.snapshot().screen()[-200:]),
)
- s.key("=") # even shares fit either way round
+ s.key("0") # even shares fit either way round
s.settle()
turn(s)
cols = sorted(rects(s), key=lambda r: r[1])
diff --git a/tests/test_shaders.py b/tests/test_shaders.py
index 56001a6..2341f93 100644
--- a/tests/test_shaders.py
+++ b/tests/test_shaders.py
@@ -90,6 +90,11 @@ def press(s, x, y):
s.send(rf"\e[<0;{x + 1};{y + 1}M")
+# Drop-state hovers aim at the target's *centre*: since the drop zones (the
+# edge bands that mean "insert beside") arrived, only the centre means the
+# swap, and only there is the pane the drop_hover state -- a band hover is an
+# ordinary candidate plus the zone's own fill, which would contaminate the
+# colours these tests sample.
def motion(s, x, y):
s.send(rf"\e[<32;{x + 1};{y + 1}M")
@@ -211,7 +216,7 @@ def test_dragging_greys_the_other_panes():
left, right = split(s)
press(s, left["x"] + 4, left["y"])
- motion(s, right["x"] + 5, right["y"] + 3)
+ motion(s, right["x"] + right["w"] // 2, right["y"] + right["h"] // 2)
snap = s.snapshot()
check(
"the pane being dragged keeps its colour",
@@ -249,7 +254,7 @@ def test_full_strength_is_a_true_grey():
left, right = split(s)
press(s, left["x"] + 4, left["y"])
- motion(s, right["x"] + 5, right["y"] + 3)
+ motion(s, right["x"] + right["w"] // 2, right["y"] + right["h"] // 2)
s.settle(120)
grey = content_fg(s.snapshot(), right)
check(
@@ -282,7 +287,7 @@ def test_the_drag_greying_outranks_the_focus_dimming():
# Drag the *unfocused* pane, so focus policy would dim it if it applied.
focused, other = by_focus(s)
press(s, other["x"] + 4, other["y"])
- motion(s, focused["x"] + 5, focused["y"] + 3)
+ motion(s, focused["x"] + focused["w"] // 2, focused["y"] + focused["h"] // 2)
snap = s.snapshot()
check(
"the dragged pane lifts off the page, dimming or not",
@@ -299,7 +304,7 @@ def drag_and_sample(conf):
s.until_text("BLOCK")
left, right = split(s)
press(s, left["x"] + 4, left["y"])
- motion(s, right["x"] + 5, right["y"] + 3)
+ motion(s, right["x"] + right["w"] // 2, right["y"] + right["h"] // 2)
got = content_fg(s.snapshot(), right)
release(s, right["x"] + 5, right["y"] + 3)
s.settle(120)
@@ -364,7 +369,7 @@ def test_non_dragged_panes_get_a_dashed_border():
)
press(s, left["x"] + 4, left["y"])
- motion(s, right["x"] + 5, right["y"] + 3)
+ motion(s, right["x"] + right["w"] // 2, right["y"] + right["h"] // 2)
snap = s.snapshot()
top = snap.line(right["y"])[right["x"] : right["x"] + right["w"]]
dragged_top = snap.line(left["y"])[left["x"] : left["x"] + left["w"]]
@@ -402,7 +407,7 @@ def test_the_hovered_target_is_still_highlighted_over_the_dashes():
s.until_text("BLOCK")
left, right = split(s)
press(s, left["x"] + 4, left["y"])
- motion(s, right["x"] + 5, right["y"] + 3)
+ motion(s, right["x"] + right["w"] // 2, right["y"] + right["h"] // 2)
snap = s.snapshot()
run = snap.style_at(right["x"], right["y"])
check(
@@ -421,7 +426,7 @@ def test_a_drag_that_ends_off_a_pane_still_clears():
left, right = split(s)
press(s, left["x"] + 4, left["y"])
- motion(s, right["x"] + 5, right["y"] + 3)
+ motion(s, right["x"] + right["w"] // 2, right["y"] + right["h"] // 2)
s.settle(120)
motion(s, 0, 0) # out into the margin
release(s, 0, 0)
@@ -435,7 +440,7 @@ def test_a_keystroke_mid_drag_clears_the_greying():
left, right = split(s)
press(s, left["x"] + 4, left["y"])
- motion(s, right["x"] + 5, right["y"] + 3)
+ motion(s, right["x"] + right["w"] // 2, right["y"] + right["h"] // 2)
s.settle(120)
s.send("x")
check_at_rest(s, "a keystroke mid-drag")
@@ -524,7 +529,7 @@ def test_the_dragged_pane_can_have_a_state_of_its_own():
s.until_text("BLOCK")
left, right = split(s)
press(s, left["x"] + 4, left["y"])
- motion(s, right["x"] + 5, right["y"] + 3)
+ motion(s, right["x"] + right["w"] // 2, right["y"] + right["h"] // 2)
snap = s.snapshot()
check(
"the pane in your hand takes the dragging state",
@@ -554,7 +559,7 @@ def test_the_hovered_pane_is_a_different_state_from_the_rest():
return
a, b, c = panes
press(s, a["x"] + 4, a["y"])
- motion(s, b["x"] + 3, b["y"] + 3)
+ motion(s, b["x"] + b["w"] // 2, b["y"] + b["h"] // 2)
snap = s.snapshot()
check(
"the pane under the pointer is the hover state",