diff options
| author | user <user@clank> | 2026-08-25 14:23:33 +0200 |
|---|---|---|
| committer | user <user@clank> | 2026-08-25 14:23:33 +0200 |
| commit | b744a90859bf248bfbea97f990668301b5fa9537 (patch) | |
| tree | f1b717507bc24161b7ab41def644289f9457470e | |
| parent | placeholder webbie (diff) | |
| download | slosh-b744a90859bf248bfbea97f990668301b5fa9537.tar.gz slosh-b744a90859bf248bfbea97f990668301b5fa9537.tar.xz slosh-b744a90859bf248bfbea97f990668301b5fa9537.zip | |
compact: shared borders instead of gaps (D23)
One knob, `compact true`: panes pack flush against 1-cell divider lines
that meet in real junctions, one frame rings the tab, and a pane's title
rides the line above it -- a boundary costs one cell where the default
look spends four. The lines are strokes: each write says which edges of
its cell it needs, unions with what is there, and never clobbers text,
so ├ ┬ ┼ fall out of paint order and the focus ring recolours without
inventing arms. The divider *is* the gap (eff_gap, one answer for the
layout, split_fits and the resize clamp), so gap hits, hover hints and
corner drags work unchanged. Interior edges give up click-to-split --
one cell cannot hold two verbs -- and a pane's status row overlays its
last content row. Floats, zoom and the D6 flatten keep classic frames
through place_hidden: zero new tree cases.
Underneath it, mode or no mode: a border drag now moves the boundary on
that side, converted on the first motion so the press that never moves
keeps its old meaning -- the handle's click still splits, the rim's is
still deliberately nothing (drag.rim). Which is also the fix for gap 0,
where flush panes leave no gap cell to grab and the resize gesture had
silently vanished with it.
| -rw-r--r-- | DESIGN.md | 21 | ||||
| -rw-r--r-- | config/config.kdl | 11 | ||||
| -rw-r--r-- | docs/config.md | 40 | ||||
| -rw-r--r-- | docs/panes.md | 9 | ||||
| -rw-r--r-- | include/app_internal.h | 9 | ||||
| -rw-r--r-- | include/config.h | 7 | ||||
| -rw-r--r-- | src/app.c | 84 | ||||
| -rw-r--r-- | src/app_draw.c | 460 | ||||
| -rw-r--r-- | src/app_layout.c | 61 | ||||
| -rw-r--r-- | src/app_ui.c | 5 | ||||
| -rw-r--r-- | src/config.c | 5 | ||||
| -rw-r--r-- | tests/test_compact.py | 338 |
12 files changed, 948 insertions, 102 deletions
@@ -165,6 +165,7 @@ unreadable. | **D20** | **Chrome is a rect too, so it is a pass too.** `where="chrome"` on any entry in `shaders` or in a `states` block runs that pass over a pane's **frame** — border, padding, title, buttons — instead of its contents. Nothing else changes: same registry, same `amount`, same expressions (D16), same chain order, one new word. It is one pass over the frame's *whole* rect with the contents punched out as a hole, not four passes over four sides: an effect has to be able to travel round a border, and four passes would each count `x`/`y` from zero and restart at every corner. The hole is what keeps the two honest — a skipped cell is never visited, so a full-strength chrome tint cannot reach your text and a full-strength content tint cannot reach the border. The chains are stored separately rather than as a flag on each shader, because a pass *is* a chain and a rect, and because `shader_abi.h` has no business knowing where a session chose to run an effect. This is also the only kind of pass that can colour a pane a small window has collapsed to a single row (D6), which is the state where a colour is worth the most. **Animation needs a clock**, so `app_next_deadline_ms` reports one while a chain that reads `t` ran over the last frame — derived from what was applied, like everything else about a frame, so a pulse hung off `dead` costs `anim_ms` while a pane is dead and nothing for the rest of the session. Without it a border froze the moment you stopped typing, which is the bug that makes an animated shader look broken rather than absent. A **`bell` pane state** and a **`since`** variable came with it, because "flash the border when a pane rings" needed both and neither existed: a bell had no state to hang a chain off, and `t` says what time it is rather than how long ago something happened, so a one-shot effect was not expressible at all. `since` is milliseconds since the pane entered its current state, stamped in the one function that decides the state — a transition is the one thing a frame cannot tell you, so it is remembered, but it is remembered where it cannot disagree with the state it dates. `bell` ranks above the ambient states and below `dead`: a pane that rang while you were elsewhere is news, and a pane that rang and then exited is a pane that exited. It ends when you look at the pane, because that is what answering a bell is (the frame clock stops with it). **Shipped on by default**: the compiled-in `bell` state is two tints in theme's `bell` colour whose amounts read `since` — on the frame, three full-strength blinks over the first 900ms and then a slow breathe; on the body, a quiet shimmer — one soft diagonal sheen gliding across, peak strength 95 against the blinks' 255, its colour softened halfway towards `default_fg` (light, not paint), its width breathing on a sine as it travels, gone within those same 900ms — so what you are reading is only touched, gently, while the announcement lasts. Derived after the theme block the way the scrolled wash is, so a theme moves the mark, the flash and the shimmer together. The original objection was that an animated default would spend a frame clock on every session, and the clock's own derivation is what answers it: `since` carries `EXPR_DEP_TIME`, so the `anim_ms` clock runs only while a rung, unanswered pane is actually on screen — an idle session pays nothing. It follows `bell_indicator false` off (that knob's promise is silent *and* invisible), and `states { bell { … } }` — including an empty one — replaces it like any state, tracked across includes like `scrolled`. **`channel="fg"|"bg"|"both"`** came from the first border flash actually being looked at: `tint` mixes both of a cell's colours, and a frame's background is the terminal's own default, so the pass materialised it to our *guess* at that default and mixed towards the colour — a dark rectangle behind the glyphs, left there until the pane changed state. The mask is enforced by the pass, not by the shader: the cell's other colour is snapshotted before materialising and put back after, so it works for a built-in and a plugin alike, an unset colour goes back to *unset* rather than to our guess, and no shader grows an fg-only variant of itself. It is one byte tested once per pass, not per cell, and 0 means both so every shader built before it behaves as it did. | | **D21** | **A project is a directory; a workspace is the tab it occupies.** Two words because they are two things, and conflating them is how a session grows a second source of truth about what it is looking at. The directory is found rather than named: a `project_roots "~/dev" "~/work" depth=2` is scanned for subdirectories holding a `slosh.layout` — or a `.git`, which is the project that has not said what it needs yet, and without which `~/dev` with forty checkouts and three layout files is a picker with three rows. A directory that *is* a project is never descended into, so the walk stays off `node_modules` without anyone writing a rule about `node_modules`. **Identity is the tab's `purpose`**, `project:<name>.<hash8>` of the resolved path — the shape D3 already published — so there is no second field to disagree with it, membership is a string comparison, and a dumped session restores it for free because a dump already writes tab purposes and applying one already locks them (D8). Hashed on the *path* rather than the name, so two worktrees of one repo are two workspaces, which is what they are. A tab whose layout gave it a purpose of its own keeps it and is simply not a member: overwriting a declared purpose is the one thing D8 forbids, and the reply counts the ones it left alone rather than letting that be a surprise later. **Nothing watches the filesystem.** The list is derived when it is asked for — one `readdir` per root and two `stat`s per entry, forty checkouts for eighty-one syscalls next to the keystroke that asked — and an answer nobody remembered cannot be stale, which is a promise no watcher can make on a bind mount. The picker scans once as it opens, not per frame, because `draw_picker` asks for its rows every frame and a `readdir` per repaint is a filesystem walk at 120Hz. The one thing a scan cannot see is a project's layout file changing under a workspace that is already open, and that is deliberately not answered by re-applying it over running processes: `workspaces` reports the file's `mtime`, and drift is derivable outside without this session storing a byte. **Opening is idempotent** (`sl0ppi up`'s property, D3): the same request twice is one workspace, focused, and the answer says which branch it took so a caller never has to ask first. **Saving is the inverse, and it is the whole of onboarding.** `save-workspace` is `dump-layout` for one tab, relative to the project, written beside it — the same dumper, so there is one answer to "what does this tab look like written down". Four things made that possible and none of them were true before: a relative `cwd=` in a layout file now resolves against that file's directory (the rule `include` already followed, applied to the other half of the same syntax), a dump can be asked for one tab against a base, a pane from a split now starts where the pane it came out of stands rather than where the server was launched — which had been quietly writing an absolute path to somebody else's directory into every saved project — and **a dump now records what a pane is actually running**. That last one is what turns arranging a project and writing it down into one act instead of two: `label` only ever knew what a *layout* said, so a pane you split and typed `npm run dev` into had nothing to say for itself and came back as a bare shell. The kernel knows what the label does not — a pty has a foreground process group, and that group is by definition the job that owns the terminal — so the answer is read rather than guessed, the way `live_cwd` already reads `/proc/PID/cwd` rather than trusting where a pane was started. Three things are deliberately not commands: a shell at a prompt (its group is the pane's own, and `command="zsh"` on every idle pane restores a session where each shell runs inside a shell), a background job (it does not own the terminal, and resurrecting `&` jobs in the foreground describes a session nobody had), and an ephemeral pane (restoring one reopens a file somebody finished with). A declared label still outranks a live process, because it survives the program exiting (D14) and re-saving must not degrade `npm run dev` into whatever the process tree looks like this minute. argv is joined with shell quoting, since `command=` is run through `/bin/sh -c` and an argument with a space in it would otherwise come back as two. A saved project defaults to `suspend="commands"`: the pane running this morning's dev server is written asleep, because a *project's* layout is a statement of shape and a *session's* dump is a statement of fact, and starting a dev server on every open is exactly what `suspended` exists to prevent — which is worth much more now that there are commands to suspend. What is **not** built: no guessing commands from `package.json` or `Cargo.toml` (a table of guesses that is wrong on day one and stale by year two — recording what you actually ran makes it unnecessary), no watcher, and no auto-save on close, because a layout file is a commit-sized decision. | | **D22** | **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, a wanted rect and a raise stamp on the leaf — exactly the `minimized` pattern, not a second collection: the seat in the tree is kept, `layout()` skips it, the siblings absorb its share, and un-floating returns it home, so everything that walks leaves (the finder, `close_leaf`, the dump, the D6 flatten) works with zero new cases. **The drawn rect is derived, the wanted rect is remembered**: what is painted is the intent clamped into the tab every frame and the clamp never writes back, so a terminal squeeze round-trips exactly — the same lesson the weights taught — while an explicit move or resize *does* write the clamped answer back, because the hand is the author of the intent (and is refused while the tab is flattened or zoomed, where the shown rect is not a place to return to). A resize pins the **grabbed edge** at walls and floors and never moves the opposite one — the general clamp preserves size by sliding the rect, which turns "drag the right edge past the wall" into the window walking left — and which edges a grab holds is derived from where the press landed (`float_edges_at`), shared by the press, the hover hint and the guide, so a bottom corner is two edges with no corner target to register and the promise cannot disagree with the drag. **Floats paint after everything tiled and before the modals**, so the backwards-searched hit list makes the z-order and the click order the same list — the one-geometry commitment paying out as "no routing code to write" — and before the status line, which reads that list for its hover hint and must not hint a float's border as the pane underneath. Focusing a float raises it, stamped in `layout()` once rather than at each place focus can move. **Floating pops the pane to the centre**: the jump is what says the float happened — lifted in place it looks like a pane whose neighbours flinched — so position is the announcement and only the size is remembered. The shadow is what tells a float from a tile (the frame is the same frame): `float_shadow`, a number and not a state chain, for the `dim_unfocused` reason — turning it off must not require knowing the states block exists. A `floating` state joined the D13 table **above `unfocused`**, shipped empty: a float is never dimmed as merely-unfocused, because full strength is what keeps the thing on top reading as lifted (the `dragging` argument). Guards, all of one invariant — **an overlay needs a backdrop**: the last tiled pane cannot float or be minimised away, and closing it lands the top float (`ensure_tiled`, checked once per frame in `layout()` like the minimised-focus rule). A float's splits are refused through `split_fits`, which reaches the guide, the border click and the keyboard in one place; it is no swap target, because its tree seat is not where it is. The keys: `f` floats (what "float" sounds like; the finder moved to `s`, where it reads as *search*), `F` opens a **new floating shell** — the throwaway terminal, a real leaf beside the focused pane so un-floating lands it and D14's shell rule closes it — `H J K L` move a focused float, the `=`/`+` key grows it about its own centre and `-` shrinks it (its split was a refusal anyway); equalize moved to `0` because a legacy terminal reports `+` as a bare `=`, and two verbs on one key survive only until the shift is dropped. **The memory (M10c)**: a float survives being written down. `dump-layout` records `floating=true x= y= w= h=` — the *wanted* rect, because intent is what survives a session and the next screen's layout clamps it like any frame — layout files declare the same, and a file whose every pane floats self-heals through `ensure_tiled`, the invariant holding itself with no lint required. `float` over the API takes `x y w h` and then *places* rather than toggles — a placement is a statement about where, not about whether — with omitted fields meaning "keep", and the intent stored exactly as asked (the write-back rule is for hands, whose drags need the clamp to agree with the eye; a script means what it typed). And **kitty graphics respect the stack**: the cell compositor gets occlusion free from paint order, but placements are sent after the diff, so each pane's placements are clipped by the float rects above it — crop what one clean edge can express (the pane-edge source-rectangle arithmetic aimed at another clipper, including from the top and left, where the source origin moves), and suppress what it cannot: a float across the middle or on a corner leaves a shape one placement cannot say, and it returns when the float moves. No `z=` needed — clipping *is* the z-order, since nothing is ever allowed to overlap. | +| **D23** | **Compact mode: shared borders instead of gaps** (`compact true`). One knob, because the request is one idea: the boundary between two panes costs one cell instead of four (a border, a gap's two columns, a border) — on an 80×24 screen with four panes, the difference between 54% of the cells being content and 74%. Panes pack flush against 1-cell divider lines, one frame rings the whole tab, and a pane's title, buttons and scroll indicator ride the line *above* its rect — a line every pane has by construction, the ring for the top row and a divider for everyone else — so the tab strip and the status line give up their surrounding air too. The lines are drawn as **strokes**: a write says which of the cell's four edges it needs (up/down/left/right), unions with whatever line is already there, and never touches a cell holding anything that is not a line. So a divider running into the frame makes a ├ and two dividers crossing make a ┼ with nobody computing junctions; the focused pane's ring is *recoloured* by re-stroking — adding no arms of its own, because an arm the structure does not have is exactly what the minimised bar's rows must not grow — and a title drawn onto a line later simply wins its cells, the paint-order rule the hit list already lives by. The geometry underneath is the old geometry: the divider *is* the gap, one cell wide whichever axis (`eff_gap`, the one answer the layout, `split_fits` and the resize clamp all read), so the gap hits, the hover hints, the corner-finding and the corner drag work unchanged — the divider drags exactly as the gap did because it *is* the gap, with a line drawn through it. What compact costs, on purpose: **interior edges give up click-to-split** — a one-cell line cannot honestly hold two verbs, so the whole of it is the resize handle; the outer frame keeps its handles, the keyboard splits anything, and a pane whose title line is a shared divider is still dragged, by its name. And a pane's **status row overlays its last content row** — a dead pane's epitaph and its `[re-run] [close]`, a program's OSC 5577 row — there being no bottom border left to carry it, which is why draw_cb paints it after the pane composes rather than draw_frame before. The hover hint had to learn to ask (`edge_owns`): each dotted cell is painted only if the hit list still says the boundary owns it — the top-row furniture rule, applied to a line that now carries a neighbour's name — which is also why the hints moved to a pass after the panes, where the list is finished. Floats keep the classic frame — an overlay needs its own edge — and so do a zoomed pane and the D6 flatten, through the `place_hidden` paths the layout already had: compact adds zero new tree cases, the D22 property inherited rather than re-earned. Chrome passes (D20) run over the pane's ring of shared line, one cell out; a line between two panes belongs to both, so a chain that recolours an unfocused frame will touch the focused pane's stretch of a shared line — two owners is what sharing means, written down rather than papered over. | ### D6 — responsive layout as a pure function @@ -284,6 +285,10 @@ Frame pacing: pty reads are coalesced and painted on a timerfd at a cap - **M11** — the drop grammar: while a pane is carried, the pane under the pointer subdivides into a swap centre and four insert bands — dragging became re-layout (see the drag machine below) ✅ +- **M12** — compact mode (D23): shared 1-cell borders drawn as merging + strokes, titles riding the line above their pane — and, mode or no mode, + a border drag now moves the boundary on that side, which is what `gap 0` + had been missing ✅ ### The border is the button @@ -305,6 +310,19 @@ side carries a **handle**, three to seven cells of it, and only the handle splits. The rest of the edge — the *rim* — arms the guide and does nothing when clicked. +**All of it drags, though.** A border press that then *moves* becomes a drag +of the boundary on that side — converted on the first motion, so the press +that never moves keeps its old meaning: the handle's click still splits, the +rim's click is still deliberately nothing (`drag.rim` is what keeps it inert +on release). Which boundary is a walk up the tree (`boundary_for_side`), +because the pane's own parent may divide the other way and the boundary to +the right of a pane can belong to a split any number of levels up; a side on +the outer edge has nothing to drag against and drags nothing. This is also +the answer for `gap 0`: flush panes leave no gap cell for the resize hit to +live on — `gap_rect` truthfully reports no gap — so before this, choosing no +gap silently cost the mouse its resize. The border is all there is there, and +now it is enough. + Hovering therefore answers two questions in two stages, because they are two questions. **Anywhere on the edge**: the edge goes heavy and the handle goes solid **with the direction arrow knocked out of it**, which says *where the @@ -427,7 +445,8 @@ what is on screen: | target | painted by | drag does | |---|---|---| | a frame's top row | `draw_frame` | carry the pane: where it lands decides (the drop grammar, below) | -| the gap between two children | `draw_node`, from the rects they were just given | move that boundary | +| the gap between two children | `draw_node`, from the rects they were just given | move that boundary (compact's divider is this same gap with a line drawn through it, D23) | +| a side's border — handle or rim | `draw_frame` | move the boundary on that side; the press that never moves stays a click — the handle's splits, the rim's does nothing | | a tab in the strip | `draw_tab_strip` | move it to where you drop it | | ...and a *pane* dropped on a tab | `draw_tab_strip` again | move the pane into that tab; onto the `+`, into one of its own | | a floating pane's title / border | `draw_frame` | move / resize the float, its own verbs — everything a title or border drag means on release is what a float must not do (D22) | diff --git a/config/config.kdl b/config/config.kdl index c1e51da..ad9e2d4 100644 --- a/config/config.kdl +++ b/config/config.kdl @@ -96,6 +96,17 @@ gap_aspect 2 // have to look up is a line nobody can read. padding 0 +// Shared borders instead of gaps. Panes pack flush against 1-cell divider +// lines that meet in real junctions (├ ┬ ┼), one frame rings the whole tab, +// and each pane's title rides the line above it -- so a boundary costs one +// cell instead of four. `gap` and `gap_aspect` stop applying (the boundary +// *is* the divider); `padding` still does. The dividers carry the same drag +// the gaps did; interior edges give up click-to-split -- a one-cell line +// cannot hold two verbs -- and the outer frame keeps it. A pane's status row +// (a dead pane's epitaph, OSC 5577) overlays its last content row, there +// being no bottom border left to carry it. +compact false + // ╭╮╰╯ versus ┌┐└┘ rounded true diff --git a/docs/config.md b/docs/config.md index 463a688..25a7474 100644 --- a/docs/config.md +++ b/docs/config.md @@ -111,7 +111,7 @@ theme { frame_focus "#00ff88" } // ...but that one colour is mine | group | what is in it | |---|---| -| geometry | `gap` `gap_aspect` `padding` `rounded` `title_align` `title_inset` `min_pane` `min_split` | +| geometry | `gap` `gap_aspect` `padding` `compact` `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` `splash_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` | | projects | `project_roots` `project_layout` — see [workspaces](workspaces.md) | @@ -138,6 +138,44 @@ A number means the same thing however many of them you write, so `padding 1`, values is refused — CSS reads it as top/horizontal/bottom, and a line whose meaning you have to look up is a line nobody can read. +## Compact + +```kdl +compact true +``` + +Shared borders instead of gaps. Panes pack flush against 1-cell divider lines +that meet in real junctions — `├` `┬` `┼` — one frame rings the whole tab, and +each pane's title rides the line above it, so a boundary costs one cell where +the default look spends four (a border, a gap, a border): + +``` +╭────── nvim ───── ▬ □ ✕ ─┬─── npm run dev ── ▬ □ ✕ ─╮ +│ │ │ +│ ├────── shell ──── ▬ □ ✕ ─┤ +│ │ │ +╰──────────────────────────┴──────────────────────────╯ +``` + +What changes, and what does not: + +- `gap` and `gap_aspect` stop applying — the boundary *is* the divider. + `padding` still works, and `rounded` still picks the corners. +- **Dividers drag exactly as gaps did**, corners included, and the same hover + hints appear on them. A pane whose title line is a shared divider is still + dragged — by its name. +- **Interior edges give up click-to-split**: a one-cell line cannot honestly + hold both verbs, and the whole line is the resize handle. The outer frame + keeps its split handles, and the keyboard splits anything, as ever. +- A pane's **status row** — a dead pane's epitaph and its `[re-run] [close]`, + a program's own OSC 5577 row — overlays the pane's last content row, there + being no bottom border left to carry it. +- **Floats keep the classic frame** (an overlay needs its own edge), and so + does a zoomed pane or a flattened tab — nothing there is packed against + anything. +- Chrome shaders run over the pane's stretch of the shared lines; a line + between two panes belongs to both, which is what sharing means. + ## Scrollback ```kdl diff --git a/docs/panes.md b/docs/panes.md index 52ace45..ea03566 100644 --- a/docs/panes.md +++ b/docs/panes.md @@ -60,7 +60,14 @@ nothing you click can disagree with what is on screen. for the double-click rename. Arming an edge leaves the name, the buttons and a 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 the crossing to move both at once. With `gap 0` there is no gap to + grab, so grabbing the border itself and pulling does the same thing — a + border press that never moves is still the click that splits. +- **[`compact true`](config.md#compact)** trades the gaps for shared 1-cell + divider lines — tmux-style density, same mouse: dividers drag, crossings + drag both ways, titles ride the line above their pane. Interior edges give + up click-to-split (the line is all resize handle); the outer frame keeps + it. - **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 diff --git a/include/app_internal.h b/include/app_internal.h index 68d6817..806e1bb 100644 --- a/include/app_internal.h +++ b/include/app_internal.h @@ -175,6 +175,11 @@ 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' */ + /* The press landed on the rim rather than the handle. The rim's click + * deliberately does nothing — only the handle splits — but a *drag* from + * anywhere on a border moves the boundary, so the rim arms the same drag + * and this is what keeps its click inert on release. */ + bool rim; /* 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 @@ -401,6 +406,8 @@ size_t tab_of(app_t *a, node_t *n); void walk_all(app_t *a, leaf_fn fn, void *ud); /* src/app_layout.c */ +uint16_t eff_gap(split_dir_t dir); +rect_t app_tab_area(app_t *a); void close_leaf(app_t *a, node_t *leaf); node_t *first_leaf_of(node_t *n); void focus_dir(app_t *a, int dx, int dy); @@ -461,6 +468,8 @@ 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); +void draw_compact_lines(app_t *a, screen_t *s); +void draw_resize_hints(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); diff --git a/include/config.h b/include/config.h index b251aed..93b1081 100644 --- a/include/config.h +++ b/include/config.h @@ -135,6 +135,13 @@ typedef struct { * and so a number means the same thing wherever it appears, however many of * them were written. Written as 1, 2 or 4 values, CSS order. */ uint16_t pad_top, pad_right, pad_bottom, pad_left; + /* Shared borders instead of gaps: panes pack flush against 1-cell divider + * lines, one outer frame rings the tab, and each pane's title rides the + * line above it. `gap` and `gap_aspect` stop applying (the boundary *is* + * the divider); `padding` still does. The dividers carry the same resize + * drag the gaps did; interior edges give up their click-to-split — a + * one-cell line cannot be both verbs — and the outer frame keeps it. */ + bool compact; bool rounded; align_t title_align; /* Cells between a frame's corner and the start of its title. The title @@ -457,7 +457,9 @@ void draw_min_bar(app_t *a, screen_t *s) { void draw_status_line(app_t *a, screen_t *s) { if (!CFG.status_line || s->rows < 3) return; - uint16_t y = (uint16_t)(s->rows - CFG.gap - 1); + /* Compact rides the very bottom row: the gap it floats on is air, and + * compact has none. */ + uint16_t y = (uint16_t)(s->rows - (CFG.compact ? 0 : CFG.gap) - 1); uint16_t x = CFG.status_pad; uint16_t right = (uint16_t)(s->cols > CFG.status_pad ? s->cols - CFG.status_pad : 0); @@ -933,6 +935,37 @@ static void drag_edge(app_t *a, node_t *sp, size_t i, int cells) { layout(a); } +/* The split boundary a pane's side sits on, walking up as far as it takes: + * the pane's parent may divide the other way, and the boundary to the right + * of a pane can belong to a split any number of levels up. False for a side + * on the outer edge, where there is nothing to drag against. + * + * This is what lets a *border* drag resize. With `gap 0` the panes are flush + * and there is no gap cell to grab — gap_rect answers false, so no resize + * handle is ever registered — and the border is all there is. The border was + * already a button (a click splits toward it); motion turning the same press + * into a boundary drag gives the two gestures one target, and a press that + * never moves still splits exactly as it did. */ +static bool boundary_for_side(node_t *leaf, char side, node_t **sp_out, + size_t *edge_out) { + split_dir_t dir = side_dir(side); + bool before = side == 'l' || side == 't'; + for (node_t *n = leaf; n && n->parent; n = n->parent) { + node_t *p = n->parent; + if (p->dir != dir) continue; /* divides the other way: keep climbing */ + size_t i = 0; + while (i < p->nkids && p->kids[i] != n) i++; + if (i >= p->nkids) return false; + if (before ? i > 0 : i + 1 < p->nkids) { + *sp_out = p; + *edge_out = before ? i - 1 : i; + return true; + } + /* At this split's extreme edge: the boundary, if any, is further up. */ + } + return false; +} + /* Focus follows the mouse, but never at the cost of what you were doing. * * Hovering may not: steal focus mid-chord (the prefix is held), reach past an @@ -1024,30 +1057,31 @@ static void do_action(app_t *a, const char *action, const input_event_t *ev) { a->drag.moved = false; const char *sep = on_name ? NULL : strchr(action + 6, ':'); a->drag.side = sep && sep[1] ? sep[1] : 0; + a->drag.rim = false; /* the top row's handle really does split */ app_focus_pane(a, id); } return; } - /* The rim of an edge: hover arms the guide, and a press does nothing at all. - * 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. + /* The rim of an edge: hover arms the guide, and a *click* does nothing at + * all. 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. + * + * A press that then moves is a different gesture: the rim arms the same + * border drag the handle does, marked as the rim's, so motion can turn it + * into a boundary drag while its release stays the click that splits + * nothing. Grabbing anywhere on a border and pulling moves the boundary, + * which is the only way to move one when `gap 0` leaves no gap to grab. * * 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, ':'); + if (strncmp(action, "brim:", 5) == 0 || strncmp(action, "border:", 7) == 0) { + bool rim = action[1] == 'r'; /* brim */ + const char *rest = action + (rim ? 5 : 7); + uint32_t id = (uint32_t)strtoul(rest, NULL, 10); + const char *colon = strchr(rest, ':'); char side = colon && colon[1] ? colon[1] : 'r'; if (ev->maction == MOUSE_PRESS) { node_t *fn = pane_by_id(a, id); @@ -1058,6 +1092,7 @@ static void do_action(app_t *a, const char *action, const input_event_t *ev) { a->drag.kind = DRAG_BORDER; a->drag.src = id; a->drag.side = side; + a->drag.rim = rim; a->drag.moved = false; a->drag.x = ev->mx; a->drag.y = ev->my; @@ -1586,6 +1621,21 @@ void app_event(app_t *a, const input_event_t *ev) { if (a->drag.kind != DRAG_NONE) { if (ev->maction == MOUSE_MOTION) { a->drag.moved = true; + /* A border press that moves is a boundary drag, when the side has a + * boundary. Converted on the first motion rather than at the press, + * so a press that never moves is still the click that splits. */ + if (a->drag.kind == DRAG_BORDER) { + node_t *bn = pane_by_id(a, a->drag.src); + node_t *bsp = NULL; + size_t bedge = 0; + if (bn && !bn->floating && + boundary_for_side(bn, a->drag.side, &bsp, &bedge)) { + a->drag.kind = DRAG_EDGE; + a->drag.c_nv = a->drag.c_nh = 0; + a->drag.src = bsp->id; + a->drag.edge = bedge; + } + } if (a->drag.kind == DRAG_SELECT) { node_t *n = pane_by_id(a, a->drag.src); if (n) @@ -1685,7 +1735,7 @@ void app_event(app_t *a, const input_event_t *ev) { } else if (ev->maction == MOUSE_RELEASE) { /* A press that never moved is a click, and a click on an edge * splits toward it. */ - if (!a->drag.moved && a->drag.side && + if (!a->drag.moved && a->drag.side && !a->drag.rim && (a->drag.kind == DRAG_BORDER || a->drag.kind == DRAG_TITLE)) { node_t *n = pane_by_id(a, a->drag.src); if (n) { diff --git a/src/app_draw.c b/src/app_draw.c index 04e8264..1a46a46 100644 --- a/src/app_draw.c +++ b/src/app_draw.c @@ -21,6 +21,159 @@ /* ---- drawing ------------------------------------------------------------ */ +/* ---- compact: shared borders ------------------------------------------- + * + * Compact packs panes flush against 1-cell divider lines and rings the tab + * with one outer frame, instead of giving every pane its own border across a + * gap. The lines are drawn as *strokes*: each write says which of the cell's + * four edges it needs (up/down/left/right), reads what is already there, and + * writes the union — so a divider running into the outer frame makes a ├ and + * two dividers crossing make a ┼ without anyone computing junctions. A cell + * holding anything that is not a line (a title, a button) is left alone, so + * a re-stroke — the focused pane's ring in the frame colour — can run over a + * finished row and recolour exactly the lines. */ + +enum { BX_U = 1, BX_D = 2, BX_L = 4, BX_R = 8 }; + +/* The glyph for a set of edge bits. Corners — exactly one arm each way — + * follow `rounded`, the same choice the classic frame makes; a tee or a + * cross has no rounded form. */ +static const char *box_glyph(uint8_t bits) { + switch (bits) { + case BX_L | BX_R: return "\u2500"; /* ─ */ + case BX_U | BX_D: return "\u2502"; /* │ */ + case BX_D | BX_R: return CFG.rounded ? "\u256d" : "\u250c"; /* ┌ */ + case BX_D | BX_L: return CFG.rounded ? "\u256e" : "\u2510"; /* ┐ */ + case BX_U | BX_R: return CFG.rounded ? "\u2570" : "\u2514"; /* └ */ + case BX_U | BX_L: return CFG.rounded ? "\u256f" : "\u2518"; /* ┘ */ + case BX_U | BX_D | BX_R: return "\u251c"; /* ├ */ + case BX_U | BX_D | BX_L: return "\u2524"; /* ┤ */ + case BX_D | BX_L | BX_R: return "\u252c"; /* ┬ */ + case BX_U | BX_L | BX_R: return "\u2534"; /* ┴ */ + case BX_U | BX_D | BX_L | BX_R: return "\u253c"; /* ┼ */ + case BX_U: return "\u2575"; /* ╵ */ + case BX_D: return "\u2577"; /* ╷ */ + case BX_L: return "\u2574"; /* ╴ */ + case BX_R: return "\u2576"; /* ╶ */ + default: return " "; + } +} + +/* And back: the edge bits of what a cell already holds, or 0 for anything + * that is not one of our lines. The dashed variants count — a drop-target's + * dashes are still the boundary they replaced — and both corner styles do, + * so `rounded` cannot confuse the union. */ +static uint8_t box_bits_of(const cell_t *c) { + if (!c || c->len != 3) return 0; + static const struct { + const char *g; + uint8_t bits; + } tab[] = { + {"\u2500", BX_L | BX_R}, + {"\u2502", BX_U | BX_D}, + {"\u2504", BX_L | BX_R}, + {"\u2506", BX_U | BX_D}, + {"\u250c", BX_D | BX_R}, + {"\u256d", BX_D | BX_R}, + {"\u2510", BX_D | BX_L}, + {"\u256e", BX_D | BX_L}, + {"\u2514", BX_U | BX_R}, + {"\u2570", BX_U | BX_R}, + {"\u2518", BX_U | BX_L}, + {"\u256f", BX_U | BX_L}, + {"\u251c", BX_U | BX_D | BX_R}, + {"\u2524", BX_U | BX_D | BX_L}, + {"\u252c", BX_D | BX_L | BX_R}, + {"\u2534", BX_U | BX_L | BX_R}, + {"\u253c", BX_U | BX_D | BX_L | BX_R}, + {"\u2575", BX_U}, + {"\u2577", BX_D}, + {"\u2574", BX_L}, + {"\u2576", BX_R}, + }; + for (size_t i = 0; i < sizeof tab / sizeof *tab; i++) + if (memcmp(c->text, tab[i].g, 3) == 0) return tab[i].bits; + return 0; +} + +/* One stroke into one cell: union with whatever line is there, and never + * clobber anything that is not a line — which is what lets a focus re-stroke + * run over a row that already carries a title. `create` false additionally + * refuses blank cells: a junction extension and a focus ring only ever join + * or recolour lines this pass drew, and must not leave stray stumps where + * there is no line to meet — the rows the minimised bar owns, say. */ +static void stroke(screen_t *s, uint16_t x, uint16_t y, uint8_t bits, + color_t fg, uint16_t attrs, bool create) { + cell_t *c = screen_at(s, x, y); + if (!c) return; + uint8_t have = box_bits_of(c); + if (!have && !(create && c->len == 1 && c->text[0] == ' ')) return; + screen_text(s, x, y, box_glyph((uint8_t)(have | bits)), fg, NO_COLOR, attrs); +} + +/* The ring one cell outside `r`: the four lines a compact pane sits inside. + * Drawn (`create`) for the tab area it is the outer frame; re-stroked for + * the focused pane it *recolours* its stretch of the shared lines — junction + * glyphs included — and adds no arms of its own: any bit the ring would add + * is an arm pointing at a line that is not there, which is exactly the state + * (a pane sitting on the minimised bar's rows) where nothing should be + * drawn. */ +static void stroke_ring(screen_t *s, rect_t r, color_t fg, uint16_t attrs, + bool create) { + if (!r.x || !r.y || !r.w || !r.h) return; + uint16_t x0 = (uint16_t)(r.x - 1), x1 = (uint16_t)(r.x + r.w); + uint16_t y0 = (uint16_t)(r.y - 1), y1 = (uint16_t)(r.y + r.h); + for (uint16_t x = x0; x <= x1; x++) { + uint8_t h = + create ? (uint8_t)((x > x0 ? BX_L : 0) | (x < x1 ? BX_R : 0)) : 0; + stroke(s, x, y0, h, fg, attrs, create); + stroke(s, x, y1, h, fg, attrs, create); + } + for (uint16_t y = y0; y <= y1; y++) { + uint8_t v = + create ? (uint8_t)((y > y0 ? BX_U : 0) | (y < y1 ? BX_D : 0)) : 0; + stroke(s, x0, y, v, fg, attrs, create); + stroke(s, x1, y, v, fg, attrs, create); + } +} + +/* Is this pane drawn compact? The mode is global but the treatment is not: + * a float is an overlay and keeps the classic frame that lifts it off the + * page, and a flattened or zoomed tab is showing one pane on its own — + * nothing is packed against anything, so the pane keeps its own edges. */ +static bool compact_pane(app_t *a, const node_t *leaf) { + return CFG.compact && !leaf->floating && !leaf->collapsed && !a->flattened && + a->ntabs && !cur(a)->zoom; +} + +/* Does this side of the rect sit on the tab's outer frame? Interior sides + * are shared dividers — resize handles their whole length — and only the + * outer frame keeps the click-to-split border verbs. Geometric on purpose: + * when the minimised bar eats the bottom rows, the panes above it touch no + * line, and offering a split target on the bar's cells would be a lie. */ +static bool edge_outer(app_t *a, rect_t r, char side) { + rect_t ar = app_tab_area(a); + switch (side) { + case 'l': return r.x == ar.x; + case 'r': return (uint16_t)(r.x + r.w) == (uint16_t)(ar.x + ar.w); + case 't': return r.y == ar.y; + default: return (uint16_t)(r.y + r.h) == (uint16_t)(ar.y + ar.h); + } +} + +/* A plain rule goes dashed, and anything else — a junction, a title, a blank + * — stays: how a compact pane's stretch of the shared lines says "you could + * drop here" without redrawing glyphs its neighbours also own. */ +static void dash_rule(screen_t *s, uint16_t x, uint16_t y, color_t fg, + uint16_t attrs) { + cell_t *c = screen_at(s, x, y); + if (!c || c->len != 3) return; + if (memcmp(c->text, "\u2500", 3) == 0) + screen_text(s, x, y, "\u2504", fg, NO_COLOR, attrs); + else if (memcmp(c->text, "\u2502", 3) == 0) + screen_text(s, x, y, "\u2506", fg, NO_COLOR, attrs); +} + /* A pane's own status line and buttons, drawn in its bottom frame row. * * Two things end up here, because they are the same thing: what a *live* pane @@ -139,23 +292,32 @@ static uint16_t split_handle_len(uint16_t span) { /* One side's handle. False when the side is too short to hold one, which is a * pane `split_fits` is about to refuse anyway. The top row is not here: its * placement depends on what the title and the buttons left, so draw_frame does - * it once those are known. */ -static bool split_handle(const node_t *leaf, char side, rect_t *out) { + * it once those are known. + * + * A compact pane's edges are the shared lines one cell outside its rect, so + * `cf` moves the handle out there; the span is the pane's own stretch of the + * line, junction cells excluded by construction since they sit beyond it. */ +static bool split_handle(const node_t *leaf, char side, bool cf, rect_t *out) { rect_t r = leaf->rect; - if (r.w < 4 || r.h < 4) return false; + if (cf ? (r.w < 4 || r.h < 3 || !r.x || !r.y) : (r.w < 4 || r.h < 4)) + return false; bool vert = side == 'l' || side == 'r'; /* The span leaves the corners out: a corner is where two gaps cross and is a * resize target already. */ - uint16_t span = vert ? (uint16_t)(r.h - 2) : (uint16_t)(r.w - 2); + uint16_t span = + vert ? (uint16_t)(r.h - (cf ? 0 : 2)) : (uint16_t)(r.w - (cf ? 0 : 2)); if (span < 3) return false; uint16_t len = split_handle_len(span); uint16_t off = (uint16_t)((span - len) / 2); - if (vert) - *out = (rect_t){side == 'l' ? r.x : (uint16_t)(r.x + r.w - 1), - (uint16_t)(r.y + 1 + off), 1, len}; - else - *out = - (rect_t){(uint16_t)(r.x + 1 + off), (uint16_t)(r.y + r.h - 1), len, 1}; + if (vert) { + uint16_t bx = + side == 'l' ? (cf ? (uint16_t)(r.x - 1) : r.x) + : (cf ? (uint16_t)(r.x + r.w) : (uint16_t)(r.x + r.w - 1)); + *out = (rect_t){bx, (uint16_t)(r.y + (cf ? 0 : 1) + off), 1, len}; + } else { + uint16_t by = cf ? (uint16_t)(r.y + r.h) : (uint16_t)(r.y + r.h - 1); + *out = (rect_t){(uint16_t)(r.x + (cf ? 0 : 1) + off), by, len, 1}; + } return true; } @@ -242,7 +404,8 @@ static void draw_split_guide(app_t *a, screen_t *s, node_t *leaf) { if (!split_fits(leaf, side_dir(side))) return; rect_t r = leaf->rect; - if (r.w < 4 || r.h < 4) return; + bool cf = compact_pane(a, leaf); + if (cf ? (r.w < 4 || r.h < 3) : (r.w < 4 || r.h < 4)) return; uint16_t x1 = (uint16_t)(r.x + r.w - 1), y1 = (uint16_t)(r.y + r.h - 1); color_t hi = GUIDE; @@ -262,15 +425,21 @@ static void draw_split_guide(app_t *a, screen_t *s, node_t *leaf) { split_rim_action(leaf, side, rim, sizeof rim); split_handle_action(leaf, side, act, sizeof act); if (side == 'l' || side == 'r') { - uint16_t bx = side == 'l' ? r.x : x1; - for (uint16_t y = (uint16_t)(r.y + 1); y < y1; y++) { + uint16_t bx = side == 'l' ? (cf ? (uint16_t)(r.x - 1) : r.x) + : (cf ? (uint16_t)(r.x + r.w) : x1); + uint16_t ylo = (uint16_t)(r.y + (cf ? 0 : 1)); + uint16_t yhi = cf ? (uint16_t)(r.y + r.h) : y1; + for (uint16_t y = ylo; y < yhi; y++) { const char *own = hit_test(&s->hits, bx, y); if (own && (strcmp(own, rim) == 0 || strcmp(own, act) == 0)) screen_text(s, bx, y, "\u2503", hi, NO_COLOR, ATTR_BOLD); } } else { - uint16_t by = side == 't' ? r.y : y1; - for (uint16_t x = (uint16_t)(r.x + 1); x < x1; x++) { + uint16_t by = side == 't' ? (cf ? (uint16_t)(r.y - 1) : r.y) + : (cf ? (uint16_t)(r.y + r.h) : y1); + uint16_t xlo = (uint16_t)(r.x + (cf ? 0 : 1)); + uint16_t xhi = cf ? (uint16_t)(r.x + r.w) : x1; + for (uint16_t x = xlo; x < xhi; x++) { const char *own = hit_test(&s->hits, x, by); if (own && (strcmp(own, rim) == 0 || strcmp(own, act) == 0)) screen_text(s, x, by, "\u2501", hi, NO_COLOR, ATTR_BOLD); @@ -537,7 +706,14 @@ static void draw_float_guide(app_t *a, screen_t *s, node_t *leaf) { 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; + /* Compact wears no frame of its own: the lines were drawn by + * draw_compact_lines and this function only dresses them — the title, the + * buttons and the hits go on the shared line *above* the rect, which is + * why a compact pane needs a row and a column to its left to exist. */ + bool cf = compact_pane(a, leaf); + if (cf ? (r.w < 3 || !r.h || !r.x || !r.y) : (r.w < 3 || r.h < 3)) return; + uint16_t ty = cf ? (uint16_t)(r.y - 1) : r.y; /* the row the title rides */ + bool top_outer = !cf || edge_outer(a, r, 't'); bool focused = leaf == cur(a)->focus; bool drop_target = a->drag.kind == DRAG_TITLE && a->drag.target == leaf->id && a->drag.src != leaf->id; @@ -560,27 +736,27 @@ static void draw_frame(app_t *a, screen_t *s, node_t *leaf) { const char *bl = CFG.rounded ? "╰" : "└", *br = CFG.rounded ? "╯" : "┘"; uint16_t x1 = (uint16_t)(r.x + r.w - 1), y1 = (uint16_t)(r.y + r.h - 1); - screen_text(s, r.x, r.y, tl, fg, NO_COLOR, attrs); - screen_text(s, x1, r.y, tr, fg, NO_COLOR, attrs); - screen_text(s, r.x, y1, bl, fg, NO_COLOR, attrs); - screen_text(s, x1, y1, br, fg, NO_COLOR, attrs); - for (uint16_t x = (uint16_t)(r.x + 1); x < x1; x++) { - screen_text(s, x, r.y, hbar, fg, NO_COLOR, attrs); - screen_text(s, x, y1, hbar, fg, NO_COLOR, attrs); - } - for (uint16_t y = (uint16_t)(r.y + 1); y < y1; y++) { - screen_text(s, r.x, y, vbar, fg, NO_COLOR, attrs); - screen_text(s, x1, y, vbar, fg, NO_COLOR, attrs); - } + if (!cf) { + screen_text(s, r.x, r.y, tl, fg, NO_COLOR, attrs); + screen_text(s, x1, r.y, tr, fg, NO_COLOR, attrs); + screen_text(s, r.x, y1, bl, fg, NO_COLOR, attrs); + screen_text(s, x1, y1, br, fg, NO_COLOR, attrs); + for (uint16_t x = (uint16_t)(r.x + 1); x < x1; x++) { + screen_text(s, x, r.y, hbar, fg, NO_COLOR, attrs); + screen_text(s, x, y1, hbar, fg, NO_COLOR, attrs); + } + for (uint16_t y = (uint16_t)(r.y + 1); y < y1; y++) { + screen_text(s, r.x, y, vbar, fg, NO_COLOR, attrs); + screen_text(s, x1, y, vbar, fg, NO_COLOR, attrs); + } - /* The padding ring between the border and the contents, painted blank - * rather than left alone. A tiled pane sits on a cleared screen, so leaving - * it unpainted used to look the same by accident — but a float sits on - * whatever was composited under it, and every unpainted cell lets that show - * through. Blank-with-no-colour is exactly what screen_clear leaves, so a - * tiled pane looks as it always did, and with no padding configured the - * ring is empty and this writes nothing. */ - { + /* The padding ring between the border and the contents, painted blank + * rather than left alone. A tiled pane sits on a cleared screen, so + * leaving it unpainted used to look the same by accident — but a float + * sits on whatever was composited under it, and every unpainted cell lets + * that show through. Blank-with-no-colour is exactly what screen_clear + * leaves, so a tiled pane looks as it always did, and with no padding + * configured the ring is empty and this writes nothing. */ rect_t c = leaf->content; for (uint16_t y = (uint16_t)(r.y + 1); y < y1; y++) for (uint16_t x = (uint16_t)(r.x + 1); x < x1; x++) { @@ -590,12 +766,27 @@ static void draw_frame(app_t *a, screen_t *s, node_t *leaf) { } screen_text(s, x, y, " ", NO_COLOR, NO_COLOR, 0); } + } else if (drag_target || drop_target) { + /* The dashed drop-candidate border, said on shared lines: each cell of + * this pane's ring that is still a plain rule goes dashed (in DROP_C for + * the pane under the pointer). Junctions are left alone — they belong to + * the neighbours too, and a dashed tee is not a glyph. */ + uint16_t rx0 = (uint16_t)(r.x - 1), rx1 = (uint16_t)(r.x + r.w); + uint16_t ry0 = ty, ry1 = (uint16_t)(r.y + r.h); + for (uint16_t x = rx0; x <= rx1; x++) { + dash_rule(s, x, ry0, fg, attrs); + dash_rule(s, x, ry1, fg, attrs); + } + for (uint16_t y = ry0; y <= ry1; y++) { + dash_rule(s, rx0, y, fg, attrs); + dash_rule(s, rx1, y, fg, attrs); + } } /* A pane that rang, marked just inside its corner: the same place on every * pane, whatever its title is doing. */ if (CFG.bell_indicator && pane_bell(leaf->pane) && r.w > 4) - screen_text(s, (uint16_t)(r.x + 1), r.y, CFG.bell_mark, BELL_C, NO_COLOR, + screen_text(s, (uint16_t)(r.x + 1), ty, CFG.bell_mark, BELL_C, NO_COLOR, ATTR_BOLD); /* The frame's top row is the drag handle, all of it. Its *split* is a handle @@ -603,11 +794,16 @@ static void draw_frame(app_t *a, screen_t *s, node_t *leaf) { * than here: the title, the buttons and the scroll indicator all anchor * wherever the config puts them, a centred title lands exactly where a * centred handle would want to be, and this row's rule is that the title - * wins. So the handle is put where they are not, once they are all placed. */ - { + * wins. So the handle is put where they are not, once they are all placed. + * + * Not when the row is a shared divider (compact, interior top edge): that + * line is the boundary between this pane and the one above, and its free + * cells stay the resize handle they are. The pane is still dragged — by + * its name, which registers its own hit below. */ + if (top_outer) { char action[48]; snprintf(action, sizeof action, "title:%u", leaf->id); - hit_add(&s->hits, r.x, r.y, r.w, 1, action); + hit_add(&s->hits, r.x, ty, r.w, 1, action); } /* No split button. The border *is* the button -- or the middle of it is: @@ -657,12 +853,12 @@ static void draw_frame(app_t *a, screen_t *s, node_t *leaf) { uint16_t bw = (uint16_t)(mw + 1); if (bx < r.x + 4 + bw) break; uint16_t px = (uint16_t)(bx - bw + 1); - bool hot = ptr_on(a, px, r.y, bw, 1); - screen_text(s, px, r.y, cell, hot ? PANE_BTN_HOVER : PANE_BTN, NO_COLOR, + bool hot = ptr_on(a, px, ty, bw, 1); + screen_text(s, px, ty, cell, hot ? PANE_BTN_HOVER : PANE_BTN, NO_COLOR, hot ? ATTR_BOLD : 0); char action[48]; snprintf(action, sizeof action, "%s:%u", btns[i].verb, leaf->id); - hit_add(&s->hits, px, r.y, bw, 1, action); + hit_add(&s->hits, px, ty, bw, 1, action); bx = (uint16_t)(px - 1); has_btn = true; btn_x = px; @@ -671,7 +867,7 @@ static void draw_frame(app_t *a, screen_t *s, node_t *leaf) { /* One blank between the rule and the first button, so the group is not * welded to the frame the way a title without its space would be. */ if (has_btn && btn_x > (uint16_t)(r.x + 1)) { - screen_text(s, (uint16_t)(btn_x - 1), r.y, " ", PANE_BTN, NO_COLOR, 0); + screen_text(s, (uint16_t)(btn_x - 1), ty, " ", PANE_BTN, NO_COLOR, 0); btn_x = (uint16_t)(btn_x - 1); avail = (uint16_t)(avail > 1 ? avail - 1 : 0); } @@ -685,7 +881,7 @@ static void draw_frame(app_t *a, screen_t *s, node_t *leaf) { * The rim is a target rather than nothing so that hovering anywhere on an * edge can still show you where the button is. Take it away and the handle * becomes a thing you have to already know about. */ - { + if (!cf) { char action[48]; snprintf(action, sizeof action, "brim:%u:l", leaf->id); hit_add(&s->hits, r.x, (uint16_t)(r.y + 1), 1, (uint16_t)(r.h - 2), action); @@ -695,13 +891,42 @@ static void draw_frame(app_t *a, screen_t *s, node_t *leaf) { hit_add(&s->hits, r.x, y1, r.w, 1, action); for (const char *side = "lrb"; *side; side++) { rect_t h; - if (!split_handle(leaf, *side, &h)) continue; + if (!split_handle(leaf, *side, false, &h)) continue; + split_handle_action(leaf, *side, action, sizeof action); + hit_add(&s->hits, h.x, h.y, h.w, h.h, action); + } + } else { + /* Only the outer frame keeps the border verbs. An interior side is a + * shared divider: its whole length is already the resize handle the gap + * used to be, and a one-cell line cannot honestly hold two verbs — the + * keyboard still splits anything. */ + char action[48]; + uint16_t xl = (uint16_t)(r.x - 1), xr = (uint16_t)(r.x + r.w); + uint16_t yb = (uint16_t)(r.y + r.h); + if (edge_outer(a, r, 'l')) { + snprintf(action, sizeof action, "brim:%u:l", leaf->id); + hit_add(&s->hits, xl, r.y, 1, r.h, action); + } + if (edge_outer(a, r, 'r')) { + snprintf(action, sizeof action, "brim:%u:r", leaf->id); + hit_add(&s->hits, xr, r.y, 1, r.h, action); + } + if (edge_outer(a, r, 'b')) { + snprintf(action, sizeof action, "brim:%u:b", leaf->id); + hit_add(&s->hits, r.x, yb, r.w, 1, action); + } + for (const char *side = "lrb"; *side; side++) { + rect_t h; + if (!edge_outer(a, r, *side)) continue; + if (!split_handle(leaf, *side, true, &h)) continue; split_handle_action(leaf, *side, action, sizeof action); hit_add(&s->hits, h.x, h.y, h.w, h.h, action); } } - draw_pane_status(a, s, leaf, fg, focused); + /* A compact pane's status rides its bottom content row and must go over + * the composed contents, so draw_cb calls it after the pane does. */ + if (!cf) draw_pane_status(a, s, leaf, fg, focused); /* Scroll position, when there is one: compact, right-aligned, and clickable * to get back to the bottom. Budgeted after the button and before the title, @@ -723,10 +948,10 @@ static void draw_frame(app_t *a, screen_t *s, node_t *leaf) { if (iw + 2 < avail) { uint16_t ix = (uint16_t)((has_btn ? btn_x : x1) - iw); uint16_t drawn = - screen_text(s, ix, r.y, ind, SCROLL_FG, SCROLL_BG, ATTR_BOLD); + screen_text(s, ix, ty, ind, SCROLL_FG, SCROLL_BG, ATTR_BOLD); char action[48]; snprintf(action, sizeof action, "scrollbottom:%u", leaf->id); - hit_add(&s->hits, ix, r.y, drawn, 1, action); + hit_add(&s->hits, ix, ty, drawn, 1, action); right_lo = ix; avail = (uint16_t)(avail - iw); } @@ -775,7 +1000,7 @@ static void draw_frame(app_t *a, screen_t *s, node_t *leaf) { * is being typed, so a half-finished rename can never be mistaken for what * the pane is actually called. */ uint16_t drawn = screen_text( - s, tx, r.y, buf, + s, tx, ty, buf, editing ? RENAME_FG : (focused ? TITLE_FOCUS : TITLE_IDLE), editing ? RENAME_BG : NO_COLOR, editing || focused ? ATTR_BOLD : 0); @@ -792,7 +1017,7 @@ static void draw_frame(app_t *a, screen_t *s, node_t *leaf) { if (drawn) { char action[48]; snprintf(action, sizeof action, "panetitle:%u", leaf->id); - hit_add(&s->hits, tx, r.y, drawn, 1, action); + hit_add(&s->hits, tx, ty, drawn, 1, action); has_title = true; title_lo = tx; title_hi = (uint16_t)(tx + drawn - 1); @@ -809,7 +1034,7 @@ static void draw_frame(app_t *a, screen_t *s, node_t *leaf) { * the widest run the row still owns and, between equals, the one nearest the * middle. A row with nothing left offers no upward split, and its other three * edges are unaffected. */ - { + if (top_outer) { uint16_t lo = (uint16_t)(r.x + 1); if (CFG.bell_indicator && pane_bell(leaf->pane) && r.w > 4) lo++; struct run { @@ -863,7 +1088,7 @@ static void draw_frame(app_t *a, screen_t *s, node_t *leaf) { if (found) { char action[48]; split_handle_action(leaf, 't', action, sizeof action); - hit_add(&s->hits, best_x, r.y, best_w, 1, action); + hit_add(&s->hits, best_x, ty, best_w, 1, action); } } } @@ -1422,7 +1647,28 @@ static void draw_cb(node_t *n, void *ud) { * is not decoration and must not be dimmed along with the thing it is * offered on. */ shade_leaf(d->a, d->s, n); - shade_chrome(d->a, d->s, n, n->rect, &n->content); + /* A compact pane's chrome is the ring of shared line it sits inside — one + * cell out on every side — rather than the rect's own edge; the state + * passes (a bell's flash, a theme's chrome chain) land there instead. The + * shared cells belong to the neighbours too, so a chain that recolours an + * *unfocused* frame will also touch the focused pane's stretch of a shared + * line: two owners is what sharing means. */ + rect_t chrome_r = n->rect; + if (compact_pane(d->a, n) && chrome_r.x && chrome_r.y) { + chrome_r.x--; + chrome_r.y--; + chrome_r.w = (uint16_t)(chrome_r.w + 2); + chrome_r.h = (uint16_t)(chrome_r.h + 2); + } + shade_chrome(d->a, d->s, n, chrome_r, &n->content); + /* Over the contents and under the guides: the status row of a compact pane + * overlays its last content row — there is no bottom border to carry it — + * so it has to go on after the pane composed and shaded. */ + if (compact_pane(d->a, n)) { + bool focused = n == cur(d->a)->focus; + draw_pane_status(d->a, d->s, n, focused ? FRAME_FOCUS : FRAME_IDLE, + focused); + } draw_split_guide(d->a, d->s, n); draw_float_guide(d->a, d->s, n); draw_drop_zones(d->a, d->s, n); @@ -1446,7 +1692,7 @@ void draw_tab_strip(app_t *a, screen_t *s) { * that is true. Worked out once rather than per tab. */ bool dragging_pane = a->drag.kind == DRAG_TITLE && a->drag.moved; uint16_t x = CFG.status_pad; - uint16_t y = CFG.gap; + uint16_t y = CFG.compact ? 0 : CFG.gap; /* compact spends no row on air */ /* Right side first, so a long tab list can never eat the indicators — the * same budgeting rule as the split button and the OSC buttons. */ @@ -1718,6 +1964,15 @@ static bool corner_uses(const corner_t *c, uint32_t id, size_t edge) { * Dotted while available, doubled and bold while engaged: the same grammar the * split guide already uses, where dashes are a possibility and weight is a * commitment. */ +/* A hint cell is painted only if the boundary still owns it. In classic + * layouts a gap holds nothing else, so this refuses nothing; on a compact + * divider the pane below has its title and buttons on the same line, and + * ruling dots through a name would be the top-row mistake all over again. */ +static bool edge_owns(screen_t *s, uint16_t x, uint16_t y) { + const char *o = hit_test(&s->hits, x, y); + return o && strncmp(o, "edge:", 5) == 0; +} + static void draw_resize_hint(app_t *a, screen_t *s, node_t *split, size_t idx, rect_t gapr) { bool active = a->drag.kind == DRAG_EDGE && a->drag.src == split->id && @@ -1744,11 +1999,13 @@ static void draw_resize_hint(app_t *a, screen_t *s, node_t *split, size_t idx, if (split->dir == SPLIT_COLS) { uint16_t x = (uint16_t)(gapr.x + gapr.w / 2); for (uint16_t y = gapr.y; y < gapr.y + gapr.h; y++) - screen_text(s, x, y, "\u250a", RESIZE_C, NO_COLOR, attrs0); + if (edge_owns(s, x, y)) + screen_text(s, x, y, "\u250a", RESIZE_C, NO_COLOR, attrs0); } else { uint16_t y = (uint16_t)(gapr.y + gapr.h / 2); for (uint16_t x = gapr.x; x < gapr.x + gapr.w; x++) - screen_text(s, x, y, "\u2508", RESIZE_C, NO_COLOR, attrs0); + if (edge_owns(s, x, y)) + screen_text(s, x, y, "\u2508", RESIZE_C, NO_COLOR, attrs0); } return; } @@ -1782,17 +2039,21 @@ static void draw_resize_hint(app_t *a, screen_t *s, node_t *split, size_t idx, if (split->dir == SPLIT_COLS) { uint16_t x = (uint16_t)(gapr.x + gapr.w / 2); for (uint16_t y = gapr.y; y < gapr.y + gapr.h; y++) - screen_text(s, x, y, active ? "\u2551" : "\u250a", RESIZE_C, NO_COLOR, - attrs); - screen_text(s, x, (uint16_t)(gapr.y + gapr.h / 2), "\u21d4", RESIZE_C, - NO_COLOR, ATTR_BOLD); + if (edge_owns(s, x, y)) + screen_text(s, x, y, active ? "\u2551" : "\u250a", RESIZE_C, NO_COLOR, + attrs); + uint16_t ay = (uint16_t)(gapr.y + gapr.h / 2); + if (edge_owns(s, x, ay)) + screen_text(s, x, ay, "\u21d4", RESIZE_C, NO_COLOR, ATTR_BOLD); } else { uint16_t y = (uint16_t)(gapr.y + gapr.h / 2); for (uint16_t x = gapr.x; x < gapr.x + gapr.w; x++) - screen_text(s, x, y, active ? "\u2550" : "\u2508", RESIZE_C, NO_COLOR, - attrs); - screen_text(s, (uint16_t)(gapr.x + gapr.w / 2), y, "\u21d5", RESIZE_C, - NO_COLOR, ATTR_BOLD); + if (edge_owns(s, x, y)) + screen_text(s, x, y, active ? "\u2550" : "\u2508", RESIZE_C, NO_COLOR, + attrs); + uint16_t ax = (uint16_t)(gapr.x + gapr.w / 2); + if (edge_owns(s, ax, y)) + screen_text(s, ax, y, "\u21d5", RESIZE_C, NO_COLOR, ATTR_BOLD); } } @@ -1811,21 +2072,82 @@ void draw_node(app_t *a, screen_t *s, node_t *n) { return; } - /* The gap between two children is the boundary you can drag. It is drawn as - * nothing, but it is a real target, derived from the rects the children were - * just given rather than recomputed from the config. */ + /* The gap between two children is the boundary you can drag. In classic + * layouts it is drawn as nothing; compact draws the divider line through it + * (draw_compact_lines). Either way it is a real target, derived from the + * rects the children were just given rather than recomputed from the + * config, and registered *before* the children so that a compact pane's + * title and buttons — which live on this very line — win their own cells. + * The hover hint is painted by draw_resize_hints once every pane has + * claimed what is its. */ for (size_t i = 0; i + 1 < n->nkids; i++) { rect_t gapr; if (!gap_rect(n, i, &gapr)) continue; char action[48]; snprintf(action, sizeof action, "edge:%u:%zu", n->id, i); hit_add(&s->hits, gapr.x, gapr.y, gapr.w, gapr.h, action); - draw_resize_hint(a, s, n, i, gapr); } for (size_t i = 0; i < n->nkids; i++) draw_node(a, s, n->kids[i]); } +/* The hover hints for every boundary, painted after the panes so the + * ownership question each cell asks (`edge_owns`) is answered by the + * finished hit list. */ +void draw_resize_hints(app_t *a, screen_t *s) { + if (!a->ntabs || !cur(a)->root) return; + struct gapinfo g[64]; + size_t n = collect_gaps(cur(a)->root, g, 64, 0); + for (size_t i = 0; i < n; i++) + draw_resize_hint(a, s, g[i].sp, g[i].i, g[i].r); +} + +/* The compact mode's lines, drawn before the panes so their titles land on + * top: one frame ring round the tab area, a divider through every gap — each + * extended one stroke into whatever perpendicular line it meets, which is + * what makes the junctions — and the focused pane's ring re-stroked in the + * frame colour, junction glyphs included, so focus reads off the lines the + * way it always has. */ +void draw_compact_lines(app_t *a, screen_t *s) { + if (!CFG.compact || !a->ntabs || !cur(a)->root) return; + if (a->flattened || cur(a)->zoom) return; + rect_t ar = app_tab_area(a); + if (!ar.x || !ar.y) return; /* no room for a ring: no lines to share */ + stroke_ring(s, ar, FRAME_IDLE, 0, true); + + /* The dividers' own cells first, their end-extensions after: an end only + * ever joins a line that is already there — the ring, or a perpendicular + * divider — which is what makes the junction glyphs, and what keeps a + * divider that stops short (at the minimised bar's rows) from leaving a + * stump in cells that hold no line. */ + struct gapinfo g[64]; + size_t n = collect_gaps(cur(a)->root, g, 64, 0); + for (size_t i = 0; i < n; i++) { + rect_t r = g[i].r; + if (g[i].sp->dir == SPLIT_COLS) + for (uint16_t y = r.y; y < r.y + r.h; y++) + stroke(s, r.x, y, BX_U | BX_D, FRAME_IDLE, 0, true); + else + for (uint16_t x = r.x; x < r.x + r.w; x++) + stroke(s, x, r.y, BX_L | BX_R, FRAME_IDLE, 0, true); + } + for (size_t i = 0; i < n; i++) { + rect_t r = g[i].r; + if (g[i].sp->dir == SPLIT_COLS) { + if (r.y) stroke(s, r.x, (uint16_t)(r.y - 1), BX_D, FRAME_IDLE, 0, false); + stroke(s, r.x, (uint16_t)(r.y + r.h), BX_U, FRAME_IDLE, 0, false); + } else { + if (r.x) stroke(s, (uint16_t)(r.x - 1), r.y, BX_R, FRAME_IDLE, 0, false); + stroke(s, (uint16_t)(r.x + r.w), r.y, BX_L, FRAME_IDLE, 0, false); + } + } + + node_t *f = cur(a)->focus; + if (f && f->kind == NODE_LEAF && !f->floating && !f->minimized && + !f->hidden && !f->collapsed && f->rect.x && f->rect.y) + stroke_ring(s, f->rect, FRAME_FOCUS, 0, false); +} + /* 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 diff --git a/src/app_layout.c b/src/app_layout.c index 6618a2a..8ba4a3f 100644 --- a/src/app_layout.c +++ b/src/app_layout.c @@ -21,6 +21,16 @@ /* ---- layout: a pure function of the tree and the rect ------------------- */ +/* The space between two siblings, which every piece of gap arithmetic — the + * layout, the fit check, the resize clamp — must agree on. Compact packs + * panes against 1-cell divider lines, so the "gap" is exactly the divider, + * whichever axis: `gap` and `gap_aspect` are about air, and compact has + * none. */ +uint16_t eff_gap(split_dir_t dir) { + if (CFG.compact) return 1; + return dir == SPLIT_COLS ? (uint16_t)(CFG.gap * CFG.gap_aspect) : CFG.gap; +} + /* Every leaf under a node, in tree order. */ size_t collect_leaves(node_t *n, node_t **out, size_t cap, size_t k) { if (!n || k >= cap) return k; @@ -201,12 +211,24 @@ static void layout_node(node_t *n, rect_t r, layout_ctx_t *ctx) { * rect too small for a frame gets neither, and the pane takes the whole * thing. Horizontal padding is aspect-corrected, like the gap: the config's * numbers are rows, so a square-looking ring is one number rather than an - * arithmetic problem. */ - bool framed = r.w >= 3 && r.h >= 3; - uint16_t left = framed ? 1 + CFG.pad_left * CFG.gap_aspect : 0; - uint16_t right = framed ? 1 + CFG.pad_right * CFG.gap_aspect : 0; - uint16_t top = framed ? 1 + CFG.pad_top : 0; - uint16_t bottom = framed ? 1 + CFG.pad_bottom : 0; + * arithmetic problem. + * + * A compact tiled pane spends no border cells at all: the lines live + * outside its rect, between the rects, so content is the rect minus + * padding only. Floats keep the classic frame — they are overlays, and an + * overlay needs its own edge — and so does everything laid out through + * the flatten/zoom paths (`place_hidden`), where the pane on show is not + * packed against anything. */ + bool classic = !CFG.compact || n->floating || ctx->place_hidden; + bool framed = classic && r.w >= 3 && r.h >= 3; + uint16_t bord = framed ? 1 : 0; + bool pads = framed || !classic; + uint16_t left = + (uint16_t)(bord + (pads ? CFG.pad_left * CFG.gap_aspect : 0)); + uint16_t right = + (uint16_t)(bord + (pads ? CFG.pad_right * CFG.gap_aspect : 0)); + uint16_t top = (uint16_t)(bord + (pads ? CFG.pad_top : 0)); + uint16_t bottom = (uint16_t)(bord + (pads ? CFG.pad_bottom : 0)); n->content = (rect_t){ .x = (uint16_t)(r.x + left), .y = (uint16_t)(r.y + top), @@ -238,8 +260,7 @@ static void layout_node(node_t *n, rect_t r, layout_ctx_t *ctx) { return; } - uint16_t gap = - n->dir == SPLIT_COLS ? (uint16_t)(CFG.gap * CFG.gap_aspect) : CFG.gap; + uint16_t gap = eff_gap(n->dir); uint16_t total = n->dir == SPLIT_COLS ? r.w : r.h; /* Does every child clear the floor? If not, this node collapses — a local @@ -363,7 +384,18 @@ static void ensure_tiled(app_t *a) { * 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; + /* Compact reserves exactly the outer frame: one cell a side, drawn as one + * ring around the whole tab rather than a border per pane. The ring is + * also where the top row of panes put their titles, so it is not optional + * air the way the gap is. */ + uint16_t gx, gy; + if (CFG.compact) { + gx = a->cols >= 3 ? 1 : 0; + gy = a->rows >= 3 ? 1 : 0; + } else { + 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, @@ -374,6 +406,11 @@ static rect_t tab_area(app_t *a) { : 1)}; } +/* The same rect for the draw passes: the compact frame ring and the outer + * split affordances are drawn against it, and recomputing it there would be a + * second opinion about where the layout put everything. */ +rect_t app_tab_area(app_t *a) { return tab_area(a); } + void layout(app_t *a) { if (!a->ntabs) return; /* Re-derived below, like everything else about a layout pass: whether this @@ -618,8 +655,7 @@ bool split_fits(node_t *leaf, split_dir_t dir) { uint16_t floor_ = dir == SPLIT_COLS ? CFG.min_split_cols : CFG.min_split_rows; uint16_t hard = dir == SPLIT_COLS ? MIN_PANE_COLS : MIN_PANE_ROWS; if (floor_ < hard) floor_ = hard; - uint16_t gap = - dir == SPLIT_COLS ? (uint16_t)(CFG.gap * CFG.gap_aspect) : CFG.gap; + uint16_t gap = eff_gap(dir); size_t k; uint16_t total; @@ -1531,8 +1567,7 @@ void transfer_weight(node_t *from, node_t *to, int amount) { node_t *sp = from->parent; if (sp && sp->nkids >= 2) { uint16_t floor_ = sp->dir == SPLIT_COLS ? MIN_PANE_COLS : MIN_PANE_ROWS; - uint16_t gap = - sp->dir == SPLIT_COLS ? (uint16_t)(CFG.gap * CFG.gap_aspect) : CFG.gap; + uint16_t gap = eff_gap(sp->dir); uint16_t span = sp->dir == SPLIT_COLS ? sp->rect.w : sp->rect.h; uint16_t gaps = (uint16_t)(gap * (sp->nkids - 1)); uint16_t avail = span > gaps ? (uint16_t)(span - gaps) : span; diff --git a/src/app_ui.c b/src/app_ui.c index d9e231c..d03ddd4 100644 --- a/src/app_ui.c +++ b/src/app_ui.c @@ -1004,7 +1004,12 @@ void app_compose(app_t *a, screen_t *s) { layout(a); find_corners(a); if (CFG.status_bar) draw_tab_strip(a, s); + /* Compact's shared lines go down first, so every pane's title and buttons + * land on top of them; the hover hints come after the panes, because each + * hint cell asks the finished hit list whether the boundary still owns it. */ + draw_compact_lines(a, s); draw_node(a, s, cur(a)->root); + draw_resize_hints(a, s); draw_corners(a, s); draw_min_bar(a, s); /* The floats, over everything tiled and under the modals below: painted diff --git a/src/config.c b/src/config.c index a306314..5d9d688 100644 --- a/src/config.c +++ b/src/config.c @@ -829,6 +829,7 @@ void config_defaults(config_t *c) { c->gap = 1; c->gap_aspect = 2; c->pad_top = c->pad_right = c->pad_bottom = c->pad_left = 0; + c->compact = false; c->rounded = true; c->title_align = ALIGN_CENTER; c->title_inset = 2; @@ -1558,6 +1559,8 @@ char *config_render(const config_t *c) { cb_add(&b, "padding %u %u %u %u // top right bottom left\n", c->pad_top, c->pad_right, c->pad_bottom, c->pad_left); cb_add(&b, "rounded %s\n", yesno(c->rounded)); + cb_add(&b, "compact %s // shared 1-cell borders instead of gaps\n", + yesno(c->compact)); cb_add(&b, "title_align \"%s\"\n", c->title_align == ALIGN_LEFT ? "left" : c->title_align == ALIGN_RIGHT ? "right" @@ -1827,6 +1830,7 @@ static const char *const KNOWN_TOP[] = { "bell_indicator", "bell_mark", "close_mark", + "compact", "dim_unfocused", "double_click_ms", "word_separators", @@ -2060,6 +2064,7 @@ static bool load_into(config_t *c, const char *path, int depth, char *err, } } c->rounded = kdl_arg_bool(kdl_child(root, "rounded"), 0, c->rounded); + c->compact = kdl_arg_bool(kdl_child(root, "compact"), 0, c->compact); c->status_bar = kdl_arg_bool(kdl_child(root, "status_bar"), 0, c->status_bar); c->status_line = kdl_arg_bool(kdl_child(root, "status_line"), 0, c->status_line); diff --git a/tests/test_compact.py b/tests/test_compact.py new file mode 100644 index 0000000..4d15131 --- /dev/null +++ b/tests/test_compact.py @@ -0,0 +1,338 @@ +#!/usr/bin/env python3 +"""Compact mode: shared 1-cell borders instead of gaps — and the border-drag +resize that `gap 0` needs either way. + +Panes pack flush against divider lines that meet in real junctions, one frame +rings the tab, and a pane's title rides the line above it. The dividers carry +the same resize drag the gaps did; interior edges give up click-to-split; the +outer frame keeps it. Floats, zoom and the flatten keep the classic frame. +""" + +import os +import sys +import tempfile +import time + +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 + + +def _lay(text): + f = tempfile.NamedTemporaryFile("w", suffix=".layout", delete=False) + f.write(text) + f.close() + return f.name + + +GAP0 = _cfg("gap 0\npadding 0\n") +COMPACT = _cfg("compact true\nhover_delay_ms 10\n") +GRID = _lay( + 'layout { tab { pane split="rows" { ' + 'pane split="cols" { pane; pane }; ' + 'pane split="cols" { pane; pane } } } }\n' +) + +SH = ["/bin/sh", "-c", 'printf "\\033]2;pane\\007"; stty raw -echo; cat'] + + +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(40) + + +# ---- gap 0: the border is all there is, so the border drags --------------- + + +def test_gap0_border_drag_moves_the_boundary(): + with Session(SH, cols=80, rows=20, config=GAP0) as s: + s.settle() + s.key("\\\\") + s.settle() + left = min(s.panes(), key=lambda p: p["x"]) + bx = left["x"] + left["w"] - 1 + by = left["y"] + left["h"] // 2 + check( + "flush panes leave no gap, so the cell is the border", + s.snapshot().hit_at(bx, by) == f"border:{left['id']}:r", + s.snapshot().hit_at(bx, by), + ) + before = left["w"] + drag(s, bx, by, bx + 6, by) + after = min(s.panes(), key=lambda p: p["x"])["w"] + check( + "dragging the border moved the boundary", + after == before + 6, + f"{before} -> {after}", + ) + + +def test_gap0_rim_drags_but_never_splits(): + with Session(SH, cols=80, rows=20, config=GAP0) as s: + s.settle() + s.key("\\\\") + s.settle() + snap = s.snapshot() + left = min(s.panes(), key=lambda p: p["x"]) + rim = snap.rim(left["id"], "r") + check("the border still has a rim", rim is not None) + s.click(*rim) + s.settle() + check( + "a rim click still splits nothing", len(s.panes()) == 2, str(len(s.panes())) + ) + before = left["w"] + drag(s, rim[0], rim[1], rim[0] - 5, rim[1]) + after = min(s.panes(), key=lambda p: p["x"])["w"] + check( + "but a rim drag moves the boundary", + after == before - 5, + f"{before} -> {after}", + ) + + +def test_gap0_handle_click_still_splits(): + with Session(SH, cols=120, rows=20, config=GAP0) as s: + s.settle() + s.key("\\\\") + s.settle() + left = min(s.panes(), key=lambda p: p["x"]) + h = s.snapshot().handle(left["id"], "r") + s.click(*h) + s.settle() + check( + "a handle click is still the split it always was", + len(s.panes()) == 3, + str(len(s.panes())), + ) + + +# ---- compact: the look ---------------------------------------------------- + + +def test_compact_boundary_costs_one_cell(): + with Session(SH, cols=64, rows=22, config=COMPACT, layout=GRID) as s: + s.settle(60) + ps = sorted(s.panes(), key=lambda p: (p["y"], p["x"])) + tl, tr = ps[0], ps[1] + check( + "one cell between side-by-side contents", + tr["content_x"] - (tl["content_x"] + tl["content_w"]) == 1, + f"{tl} | {tr}", + ) + bl = ps[2] + check( + "one cell between stacked contents", + bl["content_y"] - (tl["content_y"] + tl["content_h"]) == 1, + f"{tl} / {bl}", + ) + check( + "content starts one cell in from the screen edge", + tl["content_x"] == 1 and tl["content_y"] == 2, + str(tl), + ) + + +def test_compact_junctions_are_real_glyphs(): + with Session(SH, cols=64, rows=22, config=COMPACT, layout=GRID) as s: + s.settle(60) + snap = s.snapshot() + scr = snap.screen() + for g in "┬┴├┤┼": + check(f"the lines meet in a {g}", g in scr) + corner = [h for h in snap.hits if h["action"].startswith("corner:")] + check("the crossing is a drag target", len(corner) == 1, str(corner)) + c = corner[0] + check( + "and the target sits on the ┼ itself", + snap.line(c["y"])[c["x"]] == "┼", + repr(snap.line(c["y"])), + ) + + +def test_compact_titles_ride_the_shared_line(): + with Session(SH, cols=64, rows=22, config=COMPACT, layout=GRID) as s: + s.settle(60) + snap = s.snapshot() + ps = sorted(s.panes(), key=lambda p: (p["y"], p["x"])) + bl = ps[2] # bottom-left: its title line is the interior divider + hit = [h for h in snap.hits if h["action"] == f"panetitle:{bl['id']}"] + check( + "an interior pane's name is on the divider above it", + hit and hit[0]["y"] == bl["y"] - 1, + str(hit), + ) + check( + "and the name is drawn there", + "pane" in snap.line(bl["y"] - 1), + repr(snap.line(bl["y"] - 1)), + ) + + +# ---- compact: the mouse --------------------------------------------------- + + +def test_compact_dividers_drag_like_gaps(): + with Session(SH, cols=64, rows=22, config=COMPACT, layout=GRID) as s: + s.settle(60) + snap = s.snapshot() + ps = sorted(s.panes(), key=lambda p: (p["y"], p["x"])) + tl = ps[0] + dx = tl["x"] + tl["w"] # the divider column + dy = tl["y"] + 2 + check( + "the divider is the boundary's own hit", + (snap.hit_at(dx, dy) or "").startswith("edge:"), + snap.hit_at(dx, dy), + ) + before = tl["w"] + drag(s, dx, dy, dx + 5, dy) + after = sorted(s.panes(), key=lambda p: (p["y"], p["x"]))[0]["w"] + check( + "dragging it moves the boundary", + after == before + 5, + f"{before} -> {after}", + ) + + +def test_compact_interior_edges_resize_but_do_not_split(): + with Session(SH, cols=64, rows=22, config=COMPACT, layout=GRID) as s: + s.settle(60) + snap = s.snapshot() + ps = sorted(s.panes(), key=lambda p: (p["y"], p["x"])) + bl = ps[2] + divy = bl["y"] - 1 + free = snap.hit_at(bl["x"], divy) + check( + "a divider's free cells stay the resize handle", + (free or "").startswith("edge:"), + free, + ) + interior = [ + h + for h in snap.hits + if h["action"] in (f"border:{bl['id']}:t", f"brim:{bl['id']}:t") + ] + check("an interior edge offers no split", not interior, str(interior)) + + +def test_compact_outer_frame_still_splits(): + with Session(SH, cols=100, rows=22, config=COMPACT) as s: + s.settle() + p = s.pane(0) + snap = s.snapshot() + h = snap.handle(p["id"], "l") + check("the outer frame has a handle", h is not None) + check("on the frame's own column", h[0] == p["x"] - 1, str(h)) + s.click(*h) + s.settle() + check("and clicking it splits", len(s.panes()) == 2, str(len(s.panes()))) + + +def test_compact_hover_hint_spares_the_title(): + with Session(SH, cols=64, rows=22, config=COMPACT, layout=GRID) as s: + s.settle(60) + ps = sorted(s.panes(), key=lambda p: (p["y"], p["x"])) + bl = ps[2] + divy = bl["y"] - 1 + s.send(rf"\e[<35;{bl['x'] + 2};{divy + 1}M") + time.sleep(0.05) + s.settle(30) + row = s.snapshot().line(divy) + check("the armed divider goes dotted", "┈" in row, repr(row)) + check("without ruling through the pane's name", " pane " in row, repr(row)) + + +# ---- compact: the frame's colour and everything classic ------------------- + + +def test_compact_focus_ring_wears_the_frame_colour(): + with Session(SH, cols=64, rows=22, config=COMPACT, layout=GRID) as s: + s.settle(60) + snap = s.snapshot() + f = s.focused() + ring = snap.style_at(f["x"] - 1, f["y"] + 1) + # another pane's stretch of the frame, for contrast + others = [p for p in s.panes() if p["id"] != f["id"]] + far = max(others, key=lambda p: abs(p["x"] - f["x"]) + abs(p["y"] - f["y"])) + idle = snap.style_at(far["x"] + far["w"], far["y"] + 1) + check("the focused ring is coloured", ring is not None, str(ring)) + check( + "differently from an idle stretch of line", + idle is None or (ring or {}).get("fg") != idle.get("fg"), + f"{ring} vs {idle}", + ) + + +def test_compact_dead_pane_epitaph_overlays_the_last_row(): + cfg = _cfg('compact true\nkeep_dead "all"\n') + with Session(["/bin/sh", "-c", "exit 3"], cols=60, rows=12, config=cfg) as s: + snap = s.until(lambda sn: "re-run" in sn.screen()) + p = s.pane(0) + row = snap.line(p["content_y"] + p["content_h"] - 1) + check( + "the epitaph rides the last content row", + "status 3" in row and "[re-run]" in row, + repr(row), + ) + os.unlink(cfg) + + +def test_compact_float_keeps_the_classic_frame(): + with Session(SH, cols=64, rows=22, config=COMPACT, layout=GRID) as s: + s.settle(60) + s.key("f") + s.settle(30) + f = s.focused() + check( + "a float's contents sit inside its own border", + f["content_x"] == f["x"] + 1 and f["content_y"] == f["y"] + 1, + str(f), + ) + s.key("f") + s.settle(30) + + +def test_compact_is_a_config_key(): + import subprocess + + from harness import BIN + + env = dict(os.environ, SLOSH_CONFIG="/nonexistent/slosh.kdl") + out = subprocess.run( + [BIN, "--dump-config"], capture_output=True, text=True, env=env + ).stdout + check( + "dump-config writes the knob", + any(l.split() and l.split()[0] == "compact" for l in out.splitlines()), + out[:200], + ) + r = subprocess.run( + [BIN, "--check", COMPACT], capture_output=True, text=True, env=env + ) + check("and --check accepts compact true", r.returncode == 0, r.stdout + r.stderr) + + +if __name__ == "__main__": + test_gap0_border_drag_moves_the_boundary() + test_gap0_rim_drags_but_never_splits() + test_gap0_handle_click_still_splits() + test_compact_boundary_costs_one_cell() + test_compact_junctions_are_real_glyphs() + test_compact_titles_ride_the_shared_line() + test_compact_dividers_drag_like_gaps() + test_compact_interior_edges_resize_but_do_not_split() + test_compact_outer_frame_still_splits() + test_compact_hover_hint_spares_the_title() + test_compact_focus_ring_wears_the_frame_colour() + test_compact_dead_pane_epitaph_overlays_the_last_row() + test_compact_float_keeps_the_classic_frame() + test_compact_is_a_config_key() + sys.exit(report()) |
