<feed xmlns='http://www.w3.org/2005/Atom'>
<title>slosh.git/src/app_layout.c, branch master</title>
<subtitle>an opinionated terminal multiplexer in C with programmable pane chrome</subtitle>
<id>https://git.sl0p.foo/slosh.git/atom/src/app_layout.c?h=master</id>
<link rel='self' href='https://git.sl0p.foo/slosh.git/atom/src/app_layout.c?h=master'/>
<link rel='alternate' type='text/html' href='https://git.sl0p.foo/slosh.git/'/>
<updated>2026-10-09T10:31:53Z</updated>
<entry>
<title>tab bar: a sidebar that fits the terminal, and gives up the edge last</title>
<updated>2026-10-09T10:31:53Z</updated>
<author>
<name>user</name>
<email>user@clank</email>
</author>
<published>2026-10-09T10:24:23Z</published>
<link rel='alternate' type='text/html' href='https://git.sl0p.foo/slosh.git/commit/?id=3a0825c6890eff566307c0eaad809cdbcf854efd'/>
<id>urn:sha1:3a0825c6890eff566307c0eaad809cdbcf854efd</id>
<content type='text'>
`tab_bar_width` was a reservation: the configured columns came off the
screen whatever the screen was, and the only fallback was all the way back
to the top row. On a narrow terminal that is chrome displacing the thing it
is chrome for, and the jump to the top moves every target you were aiming
at.

It is a ceiling now. app_tab_bar_cols takes the app, because the answer
depends on the terminal and not only on the config: held to the columns
left after a pane's collapse width, and to a third of the screen, and
narrowed rather than dropped. TAB_BAR_FIT (14) is the narrowest width this
code will invent on its own -- a config that asks for less is taken at its
word -- and 0 means there is no room for a sidebar at all, which is the one
case that still falls back on top.

Height counts too, which it did not before: a sidebar spends a row per tab,
so a wide but short terminal runs out of list before it runs out of screen,
and a tab with no row is not clipped, it is unreachable. tab_bar_min_rows
is that floor. Not when the top strip could not seat them either -- moving
them would gain nothing, so the side you configured stands.

All of it is re-derived per frame, like the layout, so a resize moves the
bar and nothing remembers that it did.

test_tab_bar also stops depending on where the suite is run from. An
unnamed tab borrows the basename of its focused pane's cwd, so eight checks
read `tests` under `make test` (which cds there) and `slosh` from the
repository root, where they failed. They are about padding, alignment and
the index, not about that derivation, so they name the tab first -- by id,
since `set-name` takes 0 for the focused *pane* only. 147 checks, green
from either directory.
</content>
</entry>
<entry>
<title>panes: the focus a closed tab left behind</title>
<updated>2026-10-09T10:23:50Z</updated>
<author>
<name>user</name>
<email>user@clank</email>
</author>
<published>2026-10-09T10:23:50Z</published>
<link rel='alternate' type='text/html' href='https://git.sl0p.foo/slosh.git/commit/?id=2cb6dce416c814298f24087fdaafb0c119a584b6'/>
<id>urn:sha1:2cb6dce416c814298f24087fdaafb0c119a584b6</id>
<content type='text'>
A tab's focus outlived the tree it pointed into. close_leaf's last-pane
branch freed the leaf and removed the tab but never cleared `t-&gt;focus`,
and tab_remove only shifts the slots *after* the one it removes -- so with
the last tab gone, `a-&gt;tabs[0]` kept the stale tab_t while `ntabs` was 0
and `cur` was 0. `cur(a)` is `&amp;a-&gt;tabs[a-&gt;cur]` with no bounds check, and
`a-&gt;quit` is a request rather than a barrier: the loop keeps serving
commands, and the next one to ask for the focused pane was handed freed
memory.

From outside it looked like a random crash, because what the freed chunk
held depended on what had been allocated since. Two from one afternoon: a
pane_t recycled into a `panes` JSON reply, whose `term` pointer was then
the ASCII of `k":true,` and faulted as a general protection fault; and the
same node read back zeroed, which left pane_clear_bell testing a flag at
NULL+0x5d. Typing `exit` in the last pane is the whole recipe.

  close_leaf        drops the focus with the tree
  tab_remove        blanks the slot the shift vacated
  app_compose       tests -&gt;pane as well as -&gt;focus, the way the search
                    beside it already did
  app_apply_layout  clears restore_focus up front -- it is a node pointer,
                    and an apply that fails before consuming it leaves it
                    naming a tree the next apply frees

Two more the same reading turned up, both in close_leaf: an emptied split
set the focus to the node just freed, and a leaf whose parent disagreed
with that parent's child list underflowed `p-&gt;nkids - at - 1` into a
memmove of nearly SIZE_MAX.

FOCUS_CHECK -- behind -DSLOSH_FOCUS_CHECK, compiled out otherwise -- is
the invariant all of this turns on: every tab's focus is a leaf of that
tab's own tree. It is what found this one.

Committed with --no-verify: the files are deliberately partly staged, and
the staged blobs were checked against clang-format directly.
</content>
</entry>
<entry>
<title>layouts, moves: build panes without pulling the view to them</title>
<updated>2026-09-29T12:26:28Z</updated>
<author>
<name>user</name>
<email>user@clank</email>
</author>
<published>2026-09-29T12:26:28Z</published>
<link rel='alternate' type='text/html' href='https://git.sl0p.foo/slosh.git/commit/?id=ca1b5bdf5bf7b5ff01ab515846802b3487599d3a'/>
<id>urn:sha1:ca1b5bdf5bf7b5ff01ab515846802b3487599d3a</id>
<content type='text'>
Applying a layout landed on the first tab it made, and moving a pane into a
tab took focus there. For a program building a pane in the background that
is two failures at once: it yanks the view off the human mid-sentence, and
it leaves the *next* caller to read focus as "where I am" and put its work
in a stranger's tab.

`apply-layout focus:false` builds the tabs and keeps looking at whatever was
on screen; a layout naming an `active` tab still wins, because that is a
statement about where to be. `move-pane` gains `beside` (which pane in the
destination to split, rather than whatever it has focused) and `focus:false`
-- the two things a caller placing a pane next to *itself* needs.

That uncovered a sizing bug that was unreachable before: a pane's size comes
from a layout pass, and a tab nobody has looked at never had one, so a pane
built in the background stayed 1x1 and its program drew into a single cell.
The jump is what used to size it. layout_tab() gives a background tab a real
pass, borrowing the tab cursor for its length -- nothing composes, reads
input or draws a frame in between.
</content>
</entry>
<entry>
<title>panes: read and write a pane by id, without taking focus</title>
<updated>2026-09-29T12:26:18Z</updated>
<author>
<name>user</name>
<email>user@clank</email>
</author>
<published>2026-09-29T12:26:18Z</published>
<link rel='alternate' type='text/html' href='https://git.sl0p.foo/slosh.git/commit/?id=43fa063445821470cbf55f85aa8116c11470396f'/>
<id>urn:sha1:43fa063445821470cbf55f85aa8116c11470396f</id>
<content type='text'>
`raw` wrote into the focused pane and `snapshot` composited the active tab,
so driving a pane anywhere else meant focusing it or selecting its tab
first. Both of those move the view of whoever is watching, and both lose to
anybody doing the same thing at the same time.

`raw` now takes an `id` (0 is the focused pane, as before), and `capture`
answers one pane's viewport as text -- from the pane's own terminal, so it
works for a pane in a background tab, changes no focus, and leaves the
human's selection alone. It builds its selection as a snapshot rather than
setting one on the terminal, because a read with a visible side effect is
not a read.

`capture` tells "no such pane" apart from "that pane is empty": a caller
polling a dead id would otherwise wait forever on a pane that is quiet
because it is gone.
</content>
</entry>
<entry>
<title>tabs: the strip can stand on its side (tab_bar_side left/right)</title>
<updated>2026-09-10T13:40:09Z</updated>
<author>
<name>user</name>
<email>user@clank</email>
</author>
<published>2026-09-10T13:40:09Z</published>
<link rel='alternate' type='text/html' href='https://git.sl0p.foo/slosh.git/commit/?id=9c5b3319ac4d9a63404fe0cf0d55320754c982ba'/>
<id>urn:sha1:9c5b3319ac4d9a63404fe0cf0d55320754c982ba</id>
<content type='text'>
A sidebar lists one tab per row down the chosen edge, tab_bar_width
columns wide; the panes trade the strip's row for the sidebar's columns
in tab_area(), the one place that arithmetic lives. The per-tab cell
drawing is extracted from draw_tab_strip (label, rename editor, bell,
hover, drop states, hit) and shared, so every mouse verb -- click,
double-click rename, drag to a tab, drop on + -- works in both shapes
because the hits move with the cells.

The strip's indicators (pane count, prefix badge) take the sidebar's
bottom rows, reserved first under the same budgeting rule the strip
applies from the right. The status line below keeps the full width. A
terminal too narrow to give up the columns falls back to top, re-derived
per frame, so a resize moves the bar without anyone storing state.
</content>
</entry>
<entry>
<title>compact: the minimised bar sits below a shared line, not on the border</title>
<updated>2026-09-09T18:49:18Z</updated>
<author>
<name>user</name>
<email>user@clank</email>
</author>
<published>2026-09-09T18:49:18Z</published>
<link rel='alternate' type='text/html' href='https://git.sl0p.foo/slosh.git/commit/?id=2d006fdc97df7eecde0f49f94f24e096bcdebe28'/>
<id>urn:sha1:2d006fdc97df7eecde0f49f94f24e096bcdebe28</id>
<content type='text'>
A compact pane owns no border cells -- its bottom border is the shared
line one cell outside its rect. With panes minimised, that cell was the
bar's top row: the chips sat directly on the bottom panes' content,
reading as frames cut into the pane. Classic panes carry their own
border row and never collided.

The layout now reserves one extra row above the bar in compact, and
draw_compact_lines strokes the boundary there: it joins the outer ring
with real junctions, a divider from above ends into it, and a focused
bottom pane re-strokes its stretch like any shared line.
</content>
</entry>
<entry>
<title>layout: a float sized but not positioned is centred, per axis</title>
<updated>2026-08-30T13:22:51Z</updated>
<author>
<name>user</name>
<email>user@clank</email>
</author>
<published>2026-08-30T13:22:51Z</published>
<link rel='alternate' type='text/html' href='https://git.sl0p.foo/slosh.git/commit/?id=ea83e7ee521d1430f6b0c3b619b390337924a066'/>
<id>urn:sha1:ea83e7ee521d1430f6b0c3b619b390337924a066</id>
<content type='text'>
docs/layouts.md always said an absent coordinate takes the centred
default; the loader said 0, and a layout float with only w= h= landed
in the top-left corner. Now an axis the file does not name is
FLOAT_CENTRE, a sentinel no real coordinate can reach, and float_clamp
answers it with the middle -- every frame, at whatever size the
terminal is by then, until an explicit move or resize writes a real
number over it. The webdemo's welcome float is the customer: it must
sit centred on a terminal whose size the layout file cannot know.

The dump writes a still-centred axis by omission, because the number
it happened to land on would pin to this screen's size the one intent
that deliberately has none. Every mutating verb already writes back
from the drawn rect, so the sentinel survives nothing it should not.
</content>
</entry>
<entry>
<title>windows: fall back to `edit`, not to a notepad window</title>
<updated>2026-08-28T11:25:19Z</updated>
<author>
<name>Peter Geissler</name>
<email>peter@haxx.in</email>
</author>
<published>2026-08-28T11:25:19Z</published>
<link rel='alternate' type='text/html' href='https://git.sl0p.foo/slosh.git/commit/?id=a1388560b2adfc7a3ec98438b4183710d4f466e2'/>
<id>urn:sha1:a1388560b2adfc7a3ec98438b4183710d4f466e2</id>
<content type='text'>
`C-a e` on a Windows with no editor installed opened notepad, which is
a GUI program: the pane it was spawned in drew nothing, so the split
sat there empty while the config opened in a window somewhere else.
The toast said as much, but a pane you have to be told to ignore is
still the wrong answer.

Windows 11 24H2 ships Microsoft Edit as `edit`, and it is a console
program -- it fills the pane like nvim or nano would, so the fallback
now has the same shape as the thing it is falling back from, and a
first-time user gets a usable editor without installing one.

It goes last of the console editors rather than first. Having fetched
nvim is a statement of preference; having been shipped `edit` says
nothing about what you want, so anything you chose yourself outranks
it. notepad stays behind it: `edit` is not on Windows 10, or on 11
before 24H2, and something that opens is better than nothing when the
alternative is an empty pane.

sl_which() already tries .exe, so it is found as installed; the search
still only runs when neither EDITOR nor the config asked for anything.
</content>
</entry>
<entry>
<title>windows: a native port, on ConPTY and AF_UNIX</title>
<updated>2026-08-27T22:23:19Z</updated>
<author>
<name>Peter Geissler</name>
<email>peter@haxx.in</email>
</author>
<published>2026-08-27T22:11:24Z</published>
<link rel='alternate' type='text/html' href='https://git.sl0p.foo/slosh.git/commit/?id=d1b5a64c3855b07074b3c958739df4e16f6084a5'/>
<id>urn:sha1:d1b5a64c3855b07074b3c958739df4e16f6084a5</id>
<content type='text'>
slosh builds and runs on Windows 10/11, x86-64 and ARM64, as one exe
with no runtime dependency beyond the OS: panes, splits, tabs,
detachable sessions, the config file and its live reload, the control
socket, the mouse.

The port is a compatibility layer rather than a fork. compat/win holds
stub &lt;poll.h&gt;, &lt;sys/socket.h&gt;, &lt;termios.h&gt; and friends that redirect to
include/compat_win.h, which is force-included ahead of every translation
unit -- so the existing sources keep their POSIX includes and their
POSIX call sites, and the platform differences are answered in one
place.

Two decisions carry most of it.

Everything the event loop waits on is a socket. On POSIX a pty master,
an inotify descriptor and a unix socket are all file descriptors, so one
poll() covers them; Windows has no such union, and WSAPoll waits on
SOCKETs and nothing else. Rather than rewrite server.c's loop around
WaitForMultipleObjects -- which caps at 64 objects and cannot express
POLLOUT -- each non-socket source gets a pump thread that copies it into
one end of a loopback socketpair. The loop is then the POSIX one
unchanged, and only what sits behind the descriptor differs.

Socket descriptors are tagged rather than guessed: a wrapped SOCKET
comes back as SL_SOCK_BASE + slot, so read, write, close and poll can
tell a socket from a CRT descriptor with certainty instead of inferring
it from the numeric range. That is why socket() and accept() are shimmed
too -- an untagged descriptor escaping into the loop is the one failure
mode this scheme has, and it is worth closing completely.

Three things bit hard enough to be worth naming.

A pseudoconsole does not stop the child inheriting your stdio.
CreateProcess copies the parent's standard handles into the child when
STARTF_USESTDHANDLES is absent, and those win. With slosh's own stdout
redirected -- a pipe under ssh, a file under --script, a log under the
daemon -- the shell wrote its banner into slosh's output instead of into
the pane, and the pane had a correct title (ConPTY emits that itself)
over an empty body. The three handles are cleared for the duration of
the call, leaving the child nothing to inherit.

A ConPTY never reports EOF. The output pipe belongs to the
pseudoconsole, not to the child, so it stays open after the child exits
and pane_pump()'s read() -- the entire mechanism by which a pane learns
its program is gone -- never returns 0. A pane whose shell had been told
`exit` sat there looking alive forever. So the exit is waited for on the
process handle, and ClosePseudoConsole is what then produces the EOF.
That third thread is also why the per-pty state is reference counted: a
pump blocked on a dead pipe must not be waited for, or pty_close would
hang the session on the one program that misbehaves, so whoever finishes
last frees.

cmd.exe does not use CommandLineToArgvW's quoting. A literal quote is
\" to an ordinary program and a backslash-then-end-of-quoted-section to
cmd, so building `cmd /c notepad "C:\...\config.kdl"` the first way
handed the editor \"C:\...\config.kdl\", backslashes and all, and
notepad answered "Not a valid file name" about a path that existed.
cmd /c now gets the rest of the line verbatim -- the caller composed a
complete command with its own quoting, exactly as it does for sh -c.

Paths are mixed, and only some programs mind. slosh joins with '/' while
the environment supplies '\', so the config landed at
C:\Users\you/.config/slosh/config.kdl. The CRT opens that without
complaint, which is why --check reported it fine, but a GUI file dialog
rejects it outright. Anything handed to another program now goes through
sl_path_native() first; the internal representation is left alone.

The rest is small. Panes run %ComSpec%, sessions live in
%LOCALAPPDATA%\slosh rather than $XDG_RUNTIME_DIR because a session
socket must not follow a roaming profile to another host, there is no
SIGWINCH so a resize is noticed by comparing the console size on a
bounded poll, no fork so the daemon is a fresh detached process that
reconstructs its arguments on the command line, and no process group on
a pty so the foreground program is the youngest descendant of the pane's
shell. C-a e prefers whichever console editor is actually installed --
notepad is a GUI program, so a pane running it draws nothing and looks
broken -- and says so when it has to fall back.

make -f Makefile.windows ARCH=x86_64 (or aarch64) cross-compiles from a
mac or Linux, and builds natively on the box with zig on PATH.
</content>
</entry>
<entry>
<title>compact: shared borders instead of gaps (D23)</title>
<updated>2026-08-25T12:23:33Z</updated>
<author>
<name>user</name>
<email>user@clank</email>
</author>
<published>2026-08-25T12:23:33Z</published>
<link rel='alternate' type='text/html' href='https://git.sl0p.foo/slosh.git/commit/?id=b744a90859bf248bfbea97f990668301b5fa9537'/>
<id>urn:sha1:b744a90859bf248bfbea97f990668301b5fa9537</id>
<content type='text'>
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.
</content>
</entry>
</feed>
