# I dragged one divider and five windows moved

URL: https://tuios.dev/blog/one-divider-five-windows

> Dragging one divider in the tuios tiling layout moved five windows. It was three defects: neighbours found by geometry, a restarting ease, a truncated ratio.

The report came in three stages. First: "when i have multiple terminals and i
resize one of them sometimes the terminals that dont need to be resized also
end up stretching or shrinking". After some back and forth it narrowed to
shared borders only. Then it narrowed again: "when i vertically resize the top
one the bottom split doesnt maintain its ratio".

Each narrowing looked like one bug slowly coming into focus. It was the
opposite. That one gesture had three distinct defects, and each rewording was
a different one of them showing through. I spent a while looking for the
single cause of a compound symptom, which is a reliable way to fix a third of
a bug and be told, correctly, that nothing has changed.

I had also just finished [a round of performance
work](https://tuios.dev/blog/measuring-before-optimising) on this exact code path, with
benchmarks around it, all green. After the first report I ran them again.
Green. I extended them and ran them a third time. Still green. Three rounds of
benchmarks vouched for a path that the one person using it kept saying was
broken.

## An instrument for the actual question

The benchmarks answered "how long does a motion event take". The report was
about which windows move. Nothing I had measured that, and re-running a timer
was never going to say anything about geometry.

So the fourth round was not a benchmark. I put a trace into the binary itself:
run with `TUIOS_RESIZE_TRACE=1` and it logs, per input event, the pointer
position, which window is grabbed and by which corner, and every window whose
geometry changed as a result. No timing, no statistics. A record of who moved.

One real drag settled what three rounds of benchmarks could not. This is the
summary of the first captured log, a single grab moving a single divider:

*[An interactive figure goes here. Open the page to use it.](https://tuios.dev/blog/one-divider-five-windows)*

Everything else in this post fell out of that one log.

## Defect one: neighbours found by geometry

`adjustTilingNeighborsGeneric` decided which windows a drag affects by
scanning every window in the workspace for an edge within one cell of the
dragged line. Not by asking the layout tree which panes share the divider. By
coordinates.

The layout is a [binary space partitioning tree](https://tuios.dev/docs/bsp-tiling), and two dividers in entirely
different subtrees land on the same screen line whenever their ratios happen
to agree. Fresh splits all start at 0.5, so they agree by default. Drag a
divider, and any unrelated divider that happens to be collinear with it gets
picked up as a neighbour, along with every window attached to it.

This is also why the bug was intermittent, and why the first report said
"sometimes". It fired only when two dividers were collinear, and whether they
were depended on the entire resize history of the workspace. It is easier to
cause than to describe:

*[An interactive figure goes here. Open the page to use it.](https://tuios.dev/blog/one-divider-five-windows)*

Here it is on a 2x2 grid, dragging the divider inside the left column:

*[An interactive figure goes here. Open the page to use it.](https://tuios.dev/blog/one-divider-five-windows)*

The whole right column re-laid itself out in response to a drag it had no part
in.

The fix reverses the direction of authority. Resize is now tree-driven: walk
up from the dragged leaf to the nearest ancestor that splits on the drag axis,
move that node's ratio, and rebuild the geometry from the tree. Only windows
under that ancestor can move, because only their geometry depends on its
ratio. Coordinates stop being evidence of adjacency.

On the 2x2 grid above, the tree makes the answer plain. The drag is
vertical, so the walk stops at the first horizontal split above the dragged
pane, and that node owns only the left column:

*[An interactive figure goes here. Open the page to use it.](https://tuios.dev/blog/one-divider-five-windows)*

Its ratio moves, and the right column's ratio is never touched, so when the
tree is laid out again win-3 and win-4 come back exactly where they were. Whether the two dividers happen to share a row no longer
enters into it.

## Defect two: easing toward a target that already moved

[The previous post](https://tuios.dev/blog/measuring-before-optimising) already told how I
found the second defect: with [shared borders](https://tuios.dev/docs/bsp-tiling#shared-borders) on, `SyncBSPTreeFromGeometry`
calls `ApplyBSPLayout` on every composed frame during a drag. What belongs
here is what that actually did, counted rather than inferred, at nine
windows:

```
52 snap animations created over 20 drag frames    (0 with plain borders)
205 PTY resizes over 30 frames                    (0 with plain borders)
```

Each animation cancelled the one before it and started a fresh 300 ms ease
toward a target that had already moved by the next frame. The panes lived
permanently in the opening milliseconds of an ease and never arrived anywhere.
Each of the 205 PTY resizes was a daemon round trip to resize a terminal whose
size was about to change again.

The per-frame cost predated the performance work. What the performance work
changed was frequency: raising the drag tick from a fixed 30 FPS to NormalFPS
made the same per-frame work happen two to eight times more often. The
optimisation amplified an existing defect, which is a large part of why the
report arrived when it did.

The benchmarks could not see any of this, for three independent reasons.
Building an animation object is cheap, and the defect is that panes never
arrive, which is felt rather than timed. `DaemonResizeFunc`
is nil under test, so 205 round trips in production cost 205 nil checks in the
benchmark. And the benchmark drove motion into idle terminals, where
coalescing made almost every frame a cache hit, so `ApplyBSPLayout` ran once
in twenty motion events under measurement and once per frame in real use.

## Defect three: the ratio that never comes back

The final form of the report, "the bottom split doesnt maintain its ratio",
was a third defect, and the subtlest. Resizing a pane vertically walked the
ratio of an untouched split below it.

The mechanism is one line of arithmetic in each direction.
`applyLayoutRecursive` converts a ratio to a divider position with
`int(ratio*extent)`, which truncates. `SyncRatiosFromGeometry` reads the
truncated divider back as `line/extent`. One pass through that pair takes
0.500 to 0.482759, which is exactly 14/29. The fractional row that truncation
discarded is now gone from the ratio itself, and nothing ever puts it back.

After that first pass the value is a fixed point: 14/29 of 29 rows truncates
to divider 14, which reads back as 14/29. That is why
`TestSyncRoundTripIsStable` never saw the defect. Syncing a pristine layout at
a fixed size round-trips perfectly, because the loss happens on the first pass
and the test can only observe the passes after it.

A loss of 1.7 points sounds too small to matter. It compounds at the next
resize instead: grow the pair from 28 rows to 36 and a stored ratio of 0.482759 hands
out 17 rows against 19, where 0.5 would have given 18 against 18. The trace
showed the lifetime of one such ratio across a single drag: it starts at
0.500, never returns to it, ranges between 0.455 and 0.500, and settles at
0.480. Across the whole log the same pair ranged from 0.161, five rows against
26, to 0.654, seventeen against 9. This was a split the user had set once and
never touched again.

*[An interactive figure goes here. Open the page to use it.](https://tuios.dev/blog/one-divider-five-windows)*

## The theory I got wrong

Partway through, watching the trace, I saw one pane hold at exactly 13 rows
across six consecutive growth events while its sibling took every new row. I
concluded that growth and shrinkage took different code paths, one respecting
the ratio and one ignoring it, and went looking for the second path.

There is no second path. Growth and shrinkage run the same code. The pane held
at 13 because the truncation loss had already drifted its ratio to a value
whose product with each new extent kept truncating to 13 rows. The observation
was real and careful. The explanation I reached for was structural, and the
truth was arithmetic. The same trace that produced the wrong theory also
killed it, once I followed the ratio value instead of the row counts.

## The defect that was not one

One item from the trace still looked wrong after all three fixes: 9838fc53,
changing width 631 times with 22 direction reversals. It looked like jitter,
some feedback loop hunting between two states, and I had it pencilled in as
defect four.

Then I measured it properly, with a drag swept out and then back, before and
after the other fixes: 48 height changes each time, zero hysteresis each time.
Every pointer position produced exactly the same pane heights on the way out
and on the way back. That is not instability. That is a pane correctly
tracking a divider one cell at a time as the drag passes over it, and the
direction reversals were reversals of my hand.

I had flagged it as a bug because it looked like other bugs, without checking
whether the return path differed from the outbound one. It did not, in any
run. Zero hysteresis means the system has no memory and is simply following
the pointer, which is the specification. Anything I had "fixed" there would
have introduced the very lag the rest of this work removed.

## What the instrument cost

The trace was an evening: an environment variable, a log line per geometry
change, and a script to summarise the output. It found three defects and
acquitted a fourth candidate in one drag, after three rounds of benchmarks
had found nothing, because it recorded the thing the report was about.

A benchmark compresses behaviour into a duration. None of these defects lived
in a duration. Windows moved that should not have, targets were never reached,
a ratio drifted one way and stayed there. All of it was invisible to a timer
and plain in a log of who moved. The narrowing reports were never one bug
coming into focus. They were four phenomena taking turns at the front, and I
only saw that once I stopped assuming they were the same thing and looked at
the evidence.

*Note, September 2026: the trace never landed. `TUIOS_RESIZE_TRACE` is not in
the tuios repository or its history, so setting it on a released binary does
nothing.*
