Skip to content

tabs: mark a pane here, pull it into any tab there (C40) — and a rename caret that shoved the name around - #156

Merged
navbytes merged 2 commits into
mainfrom
mark-and-pull
Aug 27, 2026
Merged

tabs: mark a pane here, pull it into any tab there (C40) — and a rename caret that shoved the name around#156
navbytes merged 2 commits into
mainfrom
mark-and-pull

Conversation

@navbytes

Copy link
Copy Markdown
Owner

Two independent changes, one commit each.

1. C40 — mark and pull (Alt+Shift+x / Alt+Shift+v)

C28's Alt+Shift+i/Alt+Shift+m carry the focused pane one tab at a time. On a three-tab workspace that is the whole feature; on a ten-tab one it is a walk, and the walk re-parents the pane into every tab it passes through. C40 is the same verb with an arbitrary destination:

  • Alt+Shift+x marks the focused pane (marking the marked one unmarks).
  • Alt+Shift+v pulls it into whatever tab is active then — same host, same widest-way split, same refusal below C25's floor, focus following the pane, source tab removed if it was its last.

Nothing moves on the mark, and that is deliberate. A pane is a live process; it cannot sit in a clipboard between two tabs. This is tmux's select-pane -m, not a cut.

carry_pane_to_tab is now shared by both verbs rather than copied — the interesting half is the refusal (fit check before anything is removed, emptied-source rule, destination index fixup), and a second copy would be a second set of edge cases.

Chrome: while a mark is pending, C9's hint bar leads with Alt+Shift+v pull marked pane — the marked pane is usually in another tab, so without it this is a mode you cannot see. Pairs drop whole from the right, hence leading.

Mark lifecycle: cleared by the pull, by "already in this tab", by a pane that has since gone; kept on a "no room" refusal, the one outcome you can fix and retry. Also cleared when the marked pane closes — next_pane_id is max+1, so ids are recycled and a stale mark would eventually name a stranger.

Chord choice: x/v come from §8's free pool, reserved there for clipboard vocabulary — this is that vocabulary, on panes rather than text. Only the shifted forms are taken, so bare Alt+x/Alt+v keep reaching the pane through U5 (emacs M-x/M-v) — the same argument that struck b/d out of the pool.

One bug found in the shared path while testing: pulling into the tab you are standing on picked its host through tab_focus_target, which remember_tab_focus only stamps on the way out of a tab — so the pull split whatever pane the tree listed first, not the one under your eyes. pull_pane stamps live focus first.

Docs: C40 contract + key-table row 13d + the spent v/x reservation in DESIGN-ui.md, one row in the README keymap.

2. The rename caret displaced the text it moved through

Reported from live use: moving the cursor back in the rename dialog "starts to displace the characters". It did. U16's caret was inserted into the string, so it cost a real cell — the field was one column wider than its text, and everything after the point sat one column right. Walking the point left walked that displacement with it.

The caret now rides the character at the point as a reversed cell (theme::attention() — what a terminal's own block cursor is), so it costs no columns and the text cannot move. At end-of-buffer it is a reversed space, appended, displacing nothing.

rename_field returns spans rather than a String, which fixes its three other callers for free — they had the same bug: the C32 pane editor's name row and note rows, and C36's broadcast composer. The append-only carets (P21 search prompt, picker/roster type-aheads) keep the glyph on purpose: no point to move, nothing to displace.

Review notes

  • The intermediate commit builds and tests clean on its own (checked, not assumed).
  • hint_pairs gained a marked: bool parameter; the render test shim passes false, so every existing expectation is unchanged.
  • Both verbs are in the layout fuzzer's action table now (200 seeds x 400 steps, invariants clean) — the mark is the one pane-carrying verb whose source tab is arbitrary rather than active, so it reaches tree/map states the walk-based move cannot.
  • Outstanding: the src/ui/** design-supervisor audit CLAUDE.md asks for has not been run on this branch.

Verification

981 unit tests + all 35 integration binaries green; cargo +1.96.1 clippy --all-targets -- -D warnings and fmt --check clean.

The caret fix was verified through the real binary, not just the unit seam: typing abcdef into the rename dialog and pressing Left three times leaves mainabcdef in place where it used to become mainabc▏def, and typing Z there gives mainabcZdef.

A third reported bug — text escaping the floating pane's border — is not in this PR. Driven through the real binary at 120x40 and at the 80x24 floor, filled edge to edge, with ZWJ emoji / CJK / powerline glyphs, and across hide-resize-show, the float's output stayed inside its border every time; blit_screen clips to inner.width.min(cols)/inner.height.min(rows), so roost cannot paint a pane's text outside its own box. Parked until it reproduces.

C28's `Alt+Shift+i`/`Alt+Shift+m` carry the focused pane one tab at a
time. On a three-tab workspace that is the whole feature; on a ten-tab
one it is a walk, and the walk is not free — every step re-parents the
pane into a tab it is not staying in, splitting and re-splitting layouts
on the way past. C40 is the same verb with an arbitrary destination.

- `Alt+Shift+x` marks the focused pane. Marking the marked pane unmarks.
- `Alt+Shift+v` pulls the marked pane into whatever tab is active *then*,
  landing it exactly as C28 lands one: same host, same widest-way split,
  same refusal below C25's floor, focus following the pane, source tab
  removed if the pane was its last.

**Nothing moves on the mark, and that is not a compromise.** A pane is a
live process; it cannot sit in a clipboard between two tabs. This is
tmux's `select-pane -m`, not a cut — the mark is a *name* roost holds,
and the pull is the whole move, atomic, with C28's refusals intact.

`carry_pane_to_tab` is now shared by both verbs rather than copied. The
interesting half of that function is the *refusal* — the fit check
before anything is removed, the emptied-source rule, the destination
index fixup when the source tab goes with it — and a second copy would
have been a second set of edge cases to keep in step. It answers whether
the pane actually moved, which is what decides the mark's fate.

**The mark is visible or it does not exist.** By the time it matters the
marked pane is usually in another tab, so a mark with no chrome would be
a mode the user cannot see. While one is pending, C9's hint bar *leads*
with `Alt+Shift+v pull marked pane` — leading because pairs drop whole
from the right, and this is the one that has to survive a narrow
terminal. The mark flash names the pull chord, resolved (C34), never
spelled.

The mark clears on every outcome that resolves the gesture — the pull,
"already in this tab", a marked pane that has since gone — and is *kept*
when the destination refuses for want of room, the one refusal the user
can fix and retry. It also clears when the marked pane closes:
`next_pane_id` is max+1, so pane ids are recycled, and a stale mark
would not merely be dead, it would eventually name a stranger. Same
hygiene as `raw` / `tab_focus`, in the same place, for the same reason.

**Only the shifted forms are taken.** `x`/`v` come from §8's free pool,
where they were reserved for clipboard vocabulary — this is that
vocabulary, on panes rather than on text. Bare `Alt+x`/`Alt+v` stay
unbound, so emacs' `M-x` and `M-v` keep reaching the pane through U5,
which is the same live-regression argument that struck `b`/`d` out of
the pool. And the shifted forms are where the verb belongs anyway:
C28/C33's rule is that a shifted chord carries the *pane* where its
unshifted sibling carries *you*.

One bug found while testing, in the shared path rather than the new one:
pulling into the tab you are standing on picked its host through
`tab_focus_target`, and `remember_tab_focus` only stamps on the way
*out* of a tab — so the pull split whatever pane the tree happened to
list first instead of the one under your eyes. `pull_pane` stamps the
live focus first; the pane you are on is where a pull lands, exactly
like `Alt+n`.

Six unit tests, plus both verbs added to the layout fuzzer's action
table — the mark is the one pane-carrying verb whose *source* tab is
arbitrary rather than the active one, so it reaches tree/map states the
walk-based move cannot. 200 seeds x 400 steps, invariant clean.
Reported from live use: in the rename dialog, moving the cursor back
"starts to displace the characters". It did, exactly.

U16 put the `▏` caret *at* the insertion point instead of always at the
end, and it did that by **inserting the glyph into the string**. A glyph
costs a real cell, so the field was one column wider than its own text
and everything after the point sat one column right of where it belongs.
Walking the point left walked that displacement left with it: pressing a
key that is supposed to move only the cursor visibly shoved the tail of
the name around. With the point at the end — where it always was before
U16 — nothing followed the caret, which is why this shipped unnoticed.

The caret now rides the character at the point (`theme::attention()`,
reverse and nothing else, §2's own attention idiom) rather than sitting
before it. That is what a terminal's block cursor is, and it is the
reason terminals draw cursors that way: a reversed cell costs no
columns, so the text cannot move. Past the last character there is
nothing to reverse, so the caret is a reversed space — appended, which
displaces nothing.

`rename_field` returns spans rather than a `String` now, since one cell
of the field carries its own style. Its three other callers come along
for free and needed the same fix: the C32 pane editor's name row and
note rows, and C36's broadcast composer, all of which have a movable
point. The name row's edge-to-edge underline moves to the row's own
style so the caret's reversal patches on top of it instead of replacing
it, and its padding is measured with `spans_width` — display columns
(D1), so a wide glyph in a name still pads correctly.

The append-only carets are deliberately left as glyphs: the P21 search
prompt and the picker/roster type-aheads have no point to move, so
nothing there can be displaced by a caret that takes a cell.

Verified through the real binary, not just the unit seam. Typing
`abcdef` into the rename dialog and pressing Left three times leaves
`mainabcdef` in place, byte for byte, where it used to become
`mainabc▏def` — a column wider, with `def` displaced — and typing `Z`
there gives `mainabcZdef`. The new unit gate pins the property rather
than the picture: the field's width must not depend on where the point
is, at any of the five positions in a four-character buffer.
@navbytes
navbytes merged commit 11ac735 into main Aug 27, 2026
3 checks passed
@navbytes
navbytes deleted the mark-and-pull branch August 27, 2026 15:44
navbytes added a commit that referenced this pull request Aug 27, 2026
…e request (#157)

One change landed since v0.1.11 (#156), and it is two things:

**C40 — mark a pane here, pull it into any tab there.**
`Alt+Shift+x` marks the focused pane, `Alt+Shift+v` pulls it into
whatever tab is active then. C28's `Alt+Shift+i`/`Alt+Shift+m` already
carried a pane between *neighbouring* tabs; on a ten-tab workspace that
is a walk, and the walk re-parents the pane into every tab it passes
through. This is the same verb with an arbitrary destination — same
host, same widest-way split, same refusal below C25's floor, focus
following the pane.

Nothing moves on the mark: a pane is a live process and cannot sit in a
clipboard between two tabs, so this is tmux's `select-pane -m`, not a
cut. While a mark is pending the hint bar leads with the pull chord —
the marked pane is usually in another tab, so without that it would be a
mode you cannot see. Bare `Alt+x`/`Alt+v` stay unbound, so emacs' `M-x`
and `M-v` keep reaching the pane through U5.

Found in the shared path while testing it: pulling into the tab you are
standing on picked its host from `tab_focus`, which is only stamped on
the way *out* of a tab — so it split whatever pane the tree listed
first rather than the one under your eyes.

**The rename caret displaced the text it moved through.** U16's `▏` was
inserted into the string, so it cost a real cell: everything after the
point sat one column right, and walking the point left visibly shoved
the tail of the name around. It now rides the character at the point as
a reversed cell — what a terminal's own block cursor is — which costs no
columns, so the text cannot move. The C32 pane editor and C36 broadcast
composer shared the helper and had the same bug; they are fixed with it.

The landing page's keymap gains the pair and its count goes 18 -> 19.

Touching `.github/release-request` dispatches Release, which builds four
targets, creates the `v0.1.12` tag from Cargo.toml itself, and publishes
with SHA256SUMS.txt.

Verified before cutting: 981 unit tests and all 35 integration binaries
green, `cargo +1.96.1 clippy --all-targets -- -D warnings` and
`fmt --check` clean.

Note for after the merge: `HOMEBREW_TAP_TOKEN` is still unset, so
release.yml will skip the tap sync again (it did for v0.1.10 and
v0.1.11). The formula needs the usual hand-sync —
`RENDER_ONLY=1 RELEASE_TAG=v0.1.12 scripts/update-homebrew-formula.sh`,
PR'd to navbytes/homebrew-tap.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant