Skip to content

feat(ui): the panes are divided where you drag them, not where I guessed - #87

Closed
youhide wants to merge 1 commit into
c3b-empty-states-actfrom
c4-resizable-panes
Closed

youhide wants to merge 1 commit into
c3b-empty-states-actfrom
c4-resizable-panes

Conversation

@youhide

@youhide youhide commented Sep 17, 2026

Copy link
Copy Markdown
Owner

Stacked on #86 — GitHub will retarget this to main when that merges.

Three numbers decided how the main window was divided: a 230px sidebar, a 6:4 graph against the detail pane, and a 280px file list inside the working directory. Each was a guess about a window nobody had seen, and no window could argue with it. Every divider between those panes is draggable now.

Two units, and the reason for each

Divider Held as Why
Sidebar ↔ everything right of it pixels What it needs is the length of a branch name, which does not grow because the window did
Graph ↔ detail pane fraction Two halves of one view: a taller window should keep the graph the subject and the commit under it the footnote
File list ↔ diff, in the working directory pixels A path, same as the sidebar

Nothing can be dragged out of existence: the pixel splits stop at a minimum and at half the space they divide, the fraction keeps 15% for each side. A pane with no height has no divider left to drag it back with.

How a drag arrives

mouse_area reports that a press happened, not where, so the first pointer movement after a grab is the anchor and every size is a delta from it. That is what makes layout.rs testable without a window — nothing in it knows where anything is on screen.

The pointer subscription exists only while a divider is held. Following every mouse movement for a whole session to serve a drag nobody is doing would be the most expensive subscription in the application.

extent — the size of the space being divided — is the one thing pointer deltas cannot give, so the view measures it with responsive and the grab carries it.

Where it is written

state.toml, beside the window's own geometry, on release rather than per frame. State, not a setting: it changes by dragging, and nobody is going to open a file to choose a number of pixels. Clamped again on the way in, so a hand-edited file cannot open hideGit with a sidebar wider than the window. A state.toml written before this existed loads unchanged — there is a test for that, because deny_unknown_fields cuts both ways and losing the whole session over a missing table would be a poor upgrade.

Double-clicking a divider restores it, which is why none of this needs a settings entry.

What it cost, said rather than hidden

staging::view reached fourteen arguments. #86 already flagged that bundling them into a struct is the right answer and is not this change; that is still true.

Its file list became its own function, because the row it sits in is now built inside a responsive closure that may run more than once per frame and therefore cannot consume anything.

Tests

Where What
layout.rs (13) the delta arithmetic, both clamps, the sign of the vertical drag, the anchor, NaN from the pointer, and a config file asking for the impossible
widget/split.rs (3) a press on the divider grabs it and carries the extent; a press beside it grabs nothing
app.rs (5) the four messages through update, that a divider dragged inside the working directory resizes the window's layout, and that switching tabs does not take the sidebar width with it
config.rs (2) a round trip, and a state.toml from before the dividers existed

Not hand-tested in a window yet

Everything above is the headless suite. The drag itself — pointer capture, the resize cursor, whether the hairline is comfortable to hit — wants a look in a real window before this merges.

Gate

cargo fmt --all -- --check, cargo clippy --workspace --all-targets --all-features -- -D warnings, cargo test --workspace — 955 tests, all green.

Three numbers decided how the main window was divided: a 230px sidebar, a
6:4 graph against the detail pane, and a 280px file list inside the working
directory. Each was a guess about a window nobody had seen, and no window
could argue with it — a long commit message on a laptop screen had four
tenths of the height and no way to ask for more.

Every divider between those panes is draggable now, the file list included.

## Two units, and the reason for each

The sidebar and the file list are pixels: what they need is set by the
length of a branch name or a path, and that does not grow because the
window did. The graph and the detail pane are a fraction of the height they
share, because they are two halves of one view — a taller window should
keep the graph the subject and the commit under it the footnote.

Nothing can be dragged out of existence. The pixel splits stop at a minimum
and at half the space they divide; the fraction keeps 15% for each side. A
pane with no height has no divider left to drag it back with.

## How a drag arrives

`mouse_area` reports that a press happened, not where, so the first pointer
movement after a grab is the anchor and every size is a delta from it. That
is what makes `layout.rs` testable without a window: nothing in it knows
where anything is on screen. The pointer subscription exists only while a
divider is held — following every mouse movement for a session to serve a
drag nobody is doing is the most expensive subscription this could have had.

`extent` — the size of the space being divided — is the one thing the
arithmetic cannot derive, so the view measures it with `responsive` and the
grab carries it.

## Where it is written

`state.toml`, beside the window's own geometry, on release rather than per
frame. It is state, not a setting: it changes by dragging, and nobody is
going to open a file to choose a number of pixels. It is clamped again on
the way in, so a hand-edited file cannot open hideGit with a sidebar wider
than the window. A state file written before this exists loads unchanged.

Double-clicking a divider restores it, which is why none of this needs a
settings entry: the control that moved it is the control that undoes it.

## What it cost

`staging::view` reached fourteen arguments — already flagged in #86 as
wanting a struct, and still not this change. Its file list became its own
function, because the row it sits in is built inside a `responsive` closure
that may run more than once and therefore cannot consume anything.

## Tests

Thirteen on the arithmetic, three on the divider widget — including that a
press beside it grabs nothing — and five driving `update`, one of which
holds that a divider dragged inside the working directory resizes the
window's layout rather than the repository's. Two on the config file: a
round trip, and a `state.toml` from before the dividers existed.
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