| Commit message (Collapse) | Author | Age |
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
Wearing a theme meant pasting its `theme { }` block into config.kdl, or
including a path to a file you had copied there yourself. Both make the
palette a literal in your config, and a literal is the one thing you cannot
change cheaply: not from a script, not from a running session, not by a list
of what is available, and never again from upstream -- the colours you pasted
are the colours that theme had the day you pasted them.
So a theme is a file in a directory now, and a config names it:
theme_name "phosphor"
theme { frame_focus "#00ff88" } // ...but that one colour is mine
theme_name is applied after the includes and before everything the file says
itself, which is the rule includes already follow and for the same reason: the
loader reads a document by asking it for names rather than walking it in order,
so "here" is not a position it could honour, and "what you asked for is the
base, what you wrote is yours" is the rule worth having.
Two directories, yours first: `themes/` beside the root config -- beside the
file that was loaded, so a config somewhere else (SLOSH_CONFIG, or one handed
to --check) takes its themes with it -- and then <prefix>/share/slosh/themes,
compiled in from PREFIX. install(1) has been putting themes there all along
and nothing could read them; now a shipped theme is nameable without being
copied, and a file of yours with the same name shadows it, which is how a
root-owned theme gets edited.
A name, not a path. The directory is what makes a theme switchable, listable
and shadowable, and a path silently does none of those, so `theme_name
"../x.kdl"` is refused with a line pointing at `include`, which still works
and is still the answer for a file somewhere a directory of names cannot
reach. A theme that is not there is one complaint and no more (D9): the rest
of the config applies, because losing your keybindings over a mistyped colour
scheme would be the wrong trade. A theme file is read by the same loader, so
it is watched like any other: saving one reloads the session wearing it, and
the derivations (tab_status_fg, tab_idle_bg) run over the theme's own colours
exactly as before.
Switching without editing anything, over the socket a session already has:
slosh -s main cmd theme # amber default* ...
slosh -s main cmd '{"cmd":"theme","name":"amber"}' # now
slosh -s main cmd '{"cmd":"theme","name":"amber","save":true}'
The session holds the name over later reloads, so saving your config for an
unrelated reason does not put the old colours back; `save` writes the
theme_name line into the file, replacing the one already there and leaving
every other line and comment alone -- dumping the whole config back over
itself would reformat a hand-written file and drop its comments, which is not
a fair price for changing a colour scheme. A name is tried on a throwaway
config first, so a typo costs nothing. An empty name gives the answer back to
the file.
--dump-config stops writing colours: it writes the theme's name and the
colours a config overrode by hand, so a dumped config keeps following its
theme instead of freezing it. --dump-theme is the other half and is new: the
resolved palette as a theme file, which is how you start one from the session
in front of you rather than from an empty file. It is also what keeps
contrib/themes/default.kdl honest -- that file is now the annotated reference
(the commented palette moved there out of config.kdl, where it was 119 lines
of colour in the middle of the prose), and a test compares it against the
compiled-in one.
While in there: tests/test_themes.py's "every theme sets every colour" guard
had been grepping for `&c->`, the spelling before the colour table moved to
offsetof, so it had been finding zero colours and passing for months. Fixed,
with the derived and opt-in colours exempted for the reasons they are derived
and opt-in, and with a check that the guard found a table at all.
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
Three stale-build landmines, all found by shipping v0.1.7 -- the first
release where new code (GhosttySearch) actually needed the vendor bump
of 2026-09-01:
- $(VT_LIB) had no prerequisites, so an existing .a *was* the build:
the macOS builder compiled pane.c against zig-out headers from before
the bump. The vendor manifest is now the prerequisite; a re-vendor
rewrites it, so a bump rebuilds the lib and its installed headers.
- web/demo had the same guard and also never passed -Dversion-string:
upstream's build.zig ran git describe in our repo, and on the tag
checkout web/publish builds from, a slosh tag is a fatal version
mismatch. Both fixed; both had hidden behind a stale build/vt.
- The builder's zig-pkg (excluded from the sync, and left half-empty by
a once-interrupted push) is an input, not a cache: it is now synced
separately and additively, so a bump's new packages arrive and a
partial local tree cannot maim the builder's copy.
|
| |
|
|
|
|
|
|
|
|
|
|
| |
The xterm-256color fallback works everywhere; the real entry is
better, and a tarball user on a bare box had no easy way to get it.
The entry is generated from the vendored tree (contrib/terminfo, so
it describes the terminal core we actually ship), compiled with tic,
embedded as bytes, and written to ~/.terminfo under every name it
answers to, letter and Darwin-hashed layouts both. No root, no
package, no conflict with a ghostty installed later. Proven end to
end in a mount namespace: bare box falls back, installs, and the
next pane says xterm-ghostty.
|
| |
|
|
| |
Last input wins, canvas size is adjusted to active connection.
|
| |
|
|
|
|
|
|
|
|
|
|
| |
The static musl release builds cannot dlopen -- musl compiles it out
-- so loadable shader plugins were a feature most shipped binaries
could not use. Nothing we ship used it either: every shader in
contrib/shaders is an expression. Gone: the dlopen/glob loader in
shader.c, shader_dir in the config, the plugin ABI table and entry
macro, the Windows dl*/glob shims that existed only for it, the
contrib/shader-plugin skeleton, its tests, and every doc mention.
shader_abi.h stays as the shader model (cell_t, shade_ctx_t,
shader_t) with a note about what it used to be.
|
| |
|
|
|
|
|
|
| |
The coming-soon gate (FULL=1) and its page go: the site is finished
enough to be the default. web/publish (make www-publish) builds,
refuses to sync a tree that does not look like the site, and rsyncs
to /opt/slosh -- around /arch, which contrib/ship owns, and around
the served demo when a NO_DEMO build has none to offer.
|
| | |
|
| | |
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
A pane image is re-emitted to the client as decoded RGBA, which for one
screenshot is ~4MB of base64 in a single indivisible message -- and
conn_send silently dropped anything that would overflow the 4MB outbox,
while push_graphics ignored the refusal and the model advanced anyway.
Everything stateful in that dropped frame was lost for good: the image
was marked sent (so its placements drew nothing, forever), and the
deletions rode out with it (so a placement the client had was never
revoked). Reported as screenshots in a recovered pi transcript parked
at a fixed spot on the screen, immune to scrolling and pane swaps --
those were placements whose deletion left with a dropped frame. The
1000x800 one could never be delivered at all: its transmission alone
was bigger than the cap.
So the frame is now transactional. Deletions queue and are repeated
every flush until gfx_commit() confirms the client heard them;
transmissions recorded in a frame are unwound by an undelivered commit
so they are sent again. A flush nobody commits counts as undelivered at
the next gfx_begin(), which is what the `graphics` control command is:
bytes rendered for a CLI, not the client. The server reports the
verdict and keeps the paint pending until it lands.
The outbox cap now bounds the backlog rather than backlog-plus-message,
so one big transmission on a drained queue always fits -- and that
surfaced a second way to lose: a queued image legitimately holds the
backlog over the cap until the client swallows it, and push_frame read
that as a dead client and closed the connection. The session
auto-detached every time a screenshot crossed the wire. A full outbox
is a client that is *behind*, not gone: push_frame now marks the screen
for a full repaint (the dropped diff means the client's cells are
unknown) and retries as POLLOUT drains the queue; a client that truly
left still shows up as a write error there.
The headless suite pins the owed-until-committed contract; the new
tests/live_graphics.py drives the real socket with a
bigger-than-the-cap image and a reader that stalls, and would have
caught both bugs: the flood must not detach, the transmission must
arrive whole, and a placement scrolled away must be deleted -- then
come back on scroll without a second copy of the pixels.
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
| |
contrib/release X.Y.Z moves ./VERSION, commits and cuts the annotated tag
together, so the floor and the tag cannot disagree by construction. It
refuses a dirty tree, a detached HEAD, a malformed or non-increasing
version, and an existing tag; notes passed as the second argument land in
the tag message. It prints the push and `make release` commands instead of
running them, because publishing should be deliberate.
contrib/release --check verifies the invariant (VERSION equals the newest
tag) on its own: `make release` runs it before building anything, and the
new pre-push hook runs the same comparison against the commit a v* tag
points at, so a tag made by hand is caught before it escapes the machine.
|
| |
|
|
|
|
|
|
| |
DESIGN.md was a scratch design doc and .fastfeedback a per-repo tool cache;
neither belongs in the tree. Both stay on disk, ignored. Everything that
pointed at DESIGN.md now points at docs/design.md, and the decision counts
quoted from it (which had already drifted: the README said twenty-one, the
docs said twenty-two) are gone with it.
|
| |
|
|
|
|
|
|
|
| |
make -f Makefile.windows dist stages slosh.exe as
slosh-<version>-windows-<arch>.zip -- a zip, not a tarball, because Windows
is the machine that will open it -- carrying the same licensing paperwork as
the linux tarballs, with docs/windows.md riding along instead of the
manpages. `make release` now builds both architectures via release-windows,
so the windows zips land under the same SHA256SUMS as everything else.
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
The functions the last two commits fixed are only interesting on the
platform the suite does not run on, which is how they came to be wrong
in the first place. So the Windows cases are asserted under #ifdef and
the POSIX ones assert the opposite: that a drive letter is an ordinary
name here, that a backslash is an ordinary character, that neither rule
leaked across.
That second half is the one worth having. Both fixes are guarded, and a
guard is exactly the kind of thing a later edit removes without meaning
to -- `\` splitting a POSIX path would invent a directory out of a file
called `a\b`, and nothing else in the suite would notice.
Pure functions, so it joins the C unit tests rather than driving a
session. Run on the Windows box as well, cross-compiled: 28 checks
there, 20 here, the difference being the cases each platform has.
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
The vendored tree has no .git, so upstream's version detection runs
git describe in *our* repository -- and the moment this repo grew its
first release tag (v0.1.0), ghostty's build refused it: 'tagged
releases must be in vX.Y.Z format matching build.zig'. Our v0.1.0 is
not ghostty's 1.3.2-dev, and upstream is right not to let a stranger's
tag name its build.
-Dversion-string is upstream's own escape hatch for exactly this. The
value is read from the vendored build.zig.zon rather than written here,
so a re-vendor updates it in the one place it already lives. Both
invocations (the host vendor target and the cross-built release legs)
get it through one VT_FLAGS.
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
Section 5 is where unix keeps its file formats, and slosh has two
documents sharing one syntax -- the configuration and the layouts -- so
they share a page the way they share a parser. docs/slosh.5 covers the
KDL subset, where the file lives and how reload refuses a broken one,
include and its precedence, every top-level setting grouped the way
config.md groups them, the theme block, the shader vocabulary with the
expression language summarised, the states and their ranking, the keys
block including direct bindings, and the layout schema (tab, pane, and
their properties).
Held to the source like slosh.1 before it: every name in config.c's
KNOWN_TOP -- the list the loader refuses unknown settings against,
itself held to the loader by test_config.py -- must appear in the page,
so a knob cannot be added without this page hearing about it. The pair
must cross-reference each other, and both must be pure ASCII and lint
clean under groff -ww.
slosh(1) grows a SHADERS section -- the one feature a synopsis reader
would never guess a multiplexer had -- and its SEE ALSO now names
slosh(5) and nothing else: the other multiplexers can advertise
themselves.
Install puts the page under share/man/man5, the release tarballs carry
both pages, and the Homebrew formula installs what the commit it is
building has, guarded as before.
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
The distributables were a bare binary and a directory of Markdown, which
is fine until somebody types 'man slosh' -- the unix-traditional place
to ask. docs/slosh.1 is hand-written man(7), pure ASCII roff, no
toolchain: the synopsis, the options, the session commands, the default
keys, the mouse (including the new drag-past-an-edge scrolling), where
the configuration lives, what SLOSH_* means in a pane's environment,
files, exit status. The prose docs stay the long form; the page says so
and points at them.
'make install' puts it under PREFIX/share/man/man1 (and uninstall takes
it away), the release tarballs carry it beside the LICENSE -- a tarball
is what a packager starts from -- and the Homebrew formula installs it
when the commit it is building has one, guarded because the formula must
be able to build commits older than the page.
Prose about a program drifts unless something holds them together, so
test_docs.py holds the page to the source the way it already holds the
site to docs/nav: every option main.c's parser compares against and
every word command must appear in the page, every SLOSH_* variable the
code reads or sets must be documented, the roff must be pure ASCII (it
travels through implementations that never heard of preconv), and where
groff exists the page must lint clean at CHECKSTYLE=3 with -ww. Adding
a flag without documenting it is now a failing test, which is the only
kind of documentation policy that survives contact with a deadline.
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
Streamline the release into a single target that stages all three binaries
in dist/ with a SHA256SUMS:
linux x86_64 } cross-built here with 'zig cc -target ... -s', static musl
linux aarch64 } (runs on any distro), packaged as a .tar.gz with the binary
+ README + LICENSE + THIRD-PARTY-LICENSES
macos arm64 handed to 'macos-dist', which signs/notarizes on the builder
zig is already the compiler, so the two linux cross-builds need no extra
toolchain -- just the vendored libghostty-vt rebuilt per -Dtarget. Verified
both: static, stripped (~2MB), and both run (aarch64 under qemu-user).
Factor the version ladder out of the Makefile into contrib/version, and call
it from both Makefile and Makefile.macos, so the linux tarballs, the signed
zip and the binary's own --version are all named from the same tag instead
of Makefile.macos using its own 'git describe --always' scheme (which would
have named the mac artifact after a bare commit hash while linux used the
tag).
make release-linux # just the two linux tarballs
make release REF=<tag> # all three + SHA256SUMS
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
VERSION was a constant in the Makefile that had to be bumped by hand and
could silently disagree with the git tag a release was cut from. Derive it
instead, with a committed ./VERSION as the floor and no-git fallback:
on a tag v0.2.0 -> 0.2.0
past a tag v0.2.0-5-gabc123 -> 0.2.0-5-gabc123 (sorts after 0.2.0)
before any tag 0.1.0-197-g2d1815 -> ./VERSION + commit count + short id
no .git at all 0.1.0 -> ./VERSION verbatim (cgit snapshot ->
Homebrew source build)
A dirty tree appends -dirty. The commit count keeps 'brew upgrade' ordering
monotonic before the first tag exists.
version.h now bakes just VERSION (dropping the redundant +GITDESC, which the
derived string already carries). contrib/brew-release reads the base from
./VERSION rather than grepping the Makefile's VERSION line, which is now a
$(shell ...) expression it could not parse.
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
Makefile.macos was kept out of git because it named a team, a person and
a mac. But almost all of it is mechanism -- build, sign, zip, notarize,
staple, unlock a headless keychain -- and that is the project's, not the
releaser's. Keeping the whole file out to protect four values meant the
one machine that had it was the only place a release could be cut from,
and that nobody could read how signing worked without being handed it.
So the split is inverted. Makefile.macos, the builder doc and the scripts
are tracked; macos-release.mk holds TEAM_ID, SIGNER and BUILDER and is
the only thing gitignored. macos-release.mk.example is the template.
The identity strings are now assembled from SIGNER and TEAM_ID rather
than written out, which means an unconfigured checkout composes
"Developer ID Application: ()" -- plausible, wrong, and diagnosed only
by codesign a minute into a build. require-identity and require-builder
catch it before anything runs and name the file to create; `help` prints
"(unset)" rather than the empty shape.
macos-remote-build now pushes macos-release.mk instead of Makefile.macos.
That is the better arrangement anyway: the builder gets its pipeline from
git at the commit being released, so a release is signed by the flow
belonging to it and not by whatever the laptop was carrying.
MACOS-BUILDER.md was in the same position -- local, and referenced by a
file that is now tracked -- so it becomes docs/macos-release.md with the
certificate name and notary key id replaced by their shapes.
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
An unsigned mac binary is "damaged and can't be opened", so a release has
to be signed with a Developer ID and notarized by Apple, and that needs a
mac holding a private key. That mac is not the machine you develop on --
under `make all` it never is -- so `make macos-dist` builds nothing here:
it syncs the tree to the builder over ssh, has it build, sign, notarize
and staple, verifies the result there and fetches the zip back. The near
end needs only ssh, rsync and a hasher, so it works from Linux.
contrib/macos-builder-setup turns a bare mac into that builder and is
re-runnable, with --check to say what is missing. The two things that
make an unattended builder different from a laptop are handled rather
than worked around: a keychain of its own, unlocked from a 0600 password
file because a headless mac has no unlocked login keychain and codesign
dies with "User interaction is not allowed"; and exactly one copy of the
identity in the search list, because two make codesign ambiguous.
No identity is in the repo. Who signs, which team, which mac -- that is
Makefile.macos, which stays out of git along with dist/, and macos-dist
tells you so when it is not there.
|
| |
|
|
|
|
|
|
|
|
|
| |
There was no way to install slosh short of copying build/slosh by hand,
which packagers cannot do and users should not have to. PREFIX and
DESTDIR are honoured because that is the contract every package manager
already expects; the Homebrew formula calls exactly this.
Only the binary is needed at runtime. The config, layout, themes, chrome
and shaders are examples you copy into ~/.config/slosh, so they go to
share/slosh and nothing reads them from there.
|
| |
|
|
|
|
|
|
| |
In lib-vt mode upstream's build.zig auto-enables an xcframework
whenever xcodebuild is on PATH. On a Mac with only the Command Line
Tools that xcodebuild fails, turning a working build into a broken
one -- and we only ever consume libghostty-vt.a anyway. Pass
-Demit-xcframework=false.
|
| |
|
|
|
|
|
|
|
|
|
|
|
| |
Targets: kdl, jsonval, expr, proto, osc5577, input — each compiled
standalone against its own .c with ASan + UBSan, no vendor deps.
Includes seed corpora from shipped configs/themes/chrome, real control
API lines, terminal escape sequences, and regression inputs from the
bugs already fixed. standalone_main.c replays files without the fuzzer
runtime for debugging and CI.
Top-level: make fuzz (run all, TIME=15 default), make fuzz-corpus
(replay seeds only, fast CI gate).
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
Consolidate everything the website is made of:
- web_new/ -> web/: the front page, install.sh, and build-site
- contrib/webdemo/ -> web/demo/: the in-browser machine, committed for
the first time (it had never been checked in)
- make www at the top level assembles the whole site into build/www
(NO_DEMO=1 skips the guest cross-compile)
- web_plan.md deleted: the plan is fulfilled and web/README.md is now
the record
Path fallout: the overlay theme symlink gains one ../ (web/demo is a
level shallower relative to contrib/themes), PYSRC in the Makefile
follows the demo scripts, contrib/README.md drops its webdemo section.
ROOT := ../.. in the demo Makefile survives unchanged -- web/demo sits
at the same depth contrib/webdemo did.
Verified: make www builds front page + docs + demo end to end,
web/demo make check boots the image to slosh, tests/test_docs.py and
make fmt-check green.
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
logo.txt is the source of truth; the Makefile bakes it into
build/logo.h the way version.h is baked. When a client attaches
(MSG_HELLO turning a connection into the display), the logo appears
centered over everything for splash_ms (900, 0 for never), on a
cleared backdrop, in the accent -- and one of six effects runs over
its rect through the ordinary shader pass: a diagonal sheen, plasma,
rings, sparkle, a left-to-right reveal, a breathe. Which one is picked
by the splash's own timestamp, so every attach looks a little
different without anybody keeping a counter.
Every effect reads the clock, so the frame clock runs only while the
splash is up (note_animation, same as any animated chain), and `since`
in a splash context is milliseconds since it appeared, so one-shot
effects sweep exactly once. Any key or click ends it and still lands
where it was aimed: a greeting is not a modal. A screen the box does
not fit on gets no greeting rather than a cropped one.
Headless and scripted sessions never attach, so the whole fast suite
never sees it; {"cmd":"splash"} triggers it on demand, which is what
makes it testable from a scripted session -- and lets you replay the
greeting whenever you like.
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
The out-of-the-box look, rethought as one piece:
- the compiled-in palette calms down: muted blue #7aa2f7 on the same
neutral darks, with the colours that mean something kept semantic --
amber bell, red-orange dead. sl0p stays one include away as the house
style, and contrib/themes/default.kdl is the starting point written
down (theme-tour shows it).
- derived state defaults now actually follow the theme: the scrolled
wash is rebuilt from scroll_bg after the theme block (it used to bake
the compiled accent), and declaring a state -- in any file of an
include chain -- still wins. The dump writes derived states as
comments so C-a e cannot pin what is meant to keep following.
- a bell you can see: the frame blinks three times in theme's bell
colour then breathes until answered, and a soft diagonal shimmer --
the bell colour halved towards default_fg, width breathing on a sine
-- crosses the body for the same 900ms. bell_indicator false silences
all of it; states { bell { } } replaces it like any state. since
carries the clock dependency, so an idle session pays nothing.
- scrollback says where it continues: while scrolled, the viewport's
top and bottom rows melt towards an edge with content past it and
turn solid at the buffer's end. New expression variables above/below
(lines hidden past each edge, own dependency bit, cached per scroll
position), carried in shade_ctx_t -- SLOSH_SHADER_ABI 3 -> 4.
- expressions survive the dump: a program keeps its source and cb_chain
writes it back, so amount="(y % 2) * 40" no longer dumps as a number.
- headers move to include/, -Iinclude everywhere; the plugin Makefile's
SLOSH var becomes SLOSH_DIR because slosh exports SLOSH=1 into every
pane it runs, and building from inside a session read that as a path.
- comment drift culled where features outran the prose, and the
derivation boilerplate folded into derived_pass()/ctx_scroll().
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
sl0p + pty was error-prone to type and the zero made it worse; slosh
(slop + sh) is one syllable, impossible to typo, and slosh.foo is ours.
Mechanical rename everywhere it appears: binary, env vars (SLOSH,
SLOSH_SESSION, SLOSH_BIN, SLOSH_CONFIG), socket dir (/tmp/slosh-<uid>,
$XDG_RUNTIME_DIR/slosh), config path (~/.config/slosh), header guards,
src/sl0ppty.h -> src/slosh.h, contrib/sl0ppty-dev -> contrib/slosh-dev,
the driving-slosh skill, docs and tests. The sl0ppi fork references and
the sl0p theme stay: one is history, the other is the brand.
The rename surfaced a latent bug in test_osc5577: the hello-reply
answer always appeared on screen twice (ECHOCTL echo plus cat -v), but
a seven-character implementation name happened to wrap the second copy
across the 60-column boundary, so counting substrings in wrapped text
found one and passed for the wrong reason. A five-character name moved
the wrap and exposed it. The loop guard itself is correct -- with echo
off before the request there is exactly one reply -- so the test now
counts on de-wrapped text and tolerates the pane's own echo.
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
The previous commit shipped the documentation for `make tools` and the `.gitignore`
line for the venv it creates, and not the target itself. A `git reset --hard`,
undoing a throwaway probe commit, took the working tree with it -- which is what
`--hard` does. The `.gitignore` line was noticed missing and rewritten; the three
files it was standing next to were not, so `docs/design.md` has been describing a
target that did not exist. This is that target.
`ruff` is not a system package on most machines, and `pip install --user ruff` is
refused outright on a distro python (PEP 668):
$ make tools
ruff 0.16.3 in .venv
survives make clean; rm -rf .venv to start over
clang-format stays a system package, because everywhere it is one; `make tools`
says so when it is missing rather than pretending it handled it.
**The venv is looked in before `$PATH`, and that order is the point.** `ruff.toml`
now pins the version with ruff's own `required-version`, so a ruff of the wrong one
refuses to run and names both versions. Preferring `$PATH` would then strand
somebody with 0.14 from their package manager and 0.16.3 sitting in the `.venv` the
repo just built them -- refused by the tool, with the fix on disk and nothing
looking at it. Tested with exactly that, for `make fmt-check` and for the hook.
The pin exists because a formatter whose output moves between releases turns
"formatted" into "formatted by whoever committed last", and the diff lands on the
next person to touch the file. Bumping it is one line in `ruff.toml`, one in the
Makefile, and a reformat commit -- the honest cost of a new version.
The hook resolves ruff the same way, off `git rev-parse --show-toplevel` so it works
from a subdirectory, and its message when ruff is missing now says `make tools`.
Verified from a deleted `.venv`: `make fmt` stops with `ruff not found. run: make
tools`; after `make tools` both `fmt` and `fmt-check` work with nothing on `$PATH`;
`make fmt` over the whole tree changes nothing, so the pinned version agrees with
what is committed; the hook formats a staged Python file through the venv, and still
does with 0.14.0 ahead of it on `$PATH`.
The hook was tested by running `.githooks/pre-commit` directly against a staged
file. The obvious way -- a real probe commit -- needs the working tree clean to be
meaningful, and getting there is what caused the mess this commit is repairing.
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
The other half of the pre-commit hook. C goes through clang-format, Python through
ruff: `ruff check --select I --fix` to sort the imports, then `ruff format` to lay
them out. `make fmt` does both, `make fmt-check` reports, `make hooks` installs it.
ruff rather than black because it is one static binary instead of a Python
dependency chain, and fast enough that a hook is free. Its formatter is
black-compatible, so this is the ecosystem's formatting rather than a dialect --
measured at 9,935 changed lines against black's 9,953, which is the same answer.
**Left entirely at the defaults**, which is the opposite of what `.clang-format`
does. That file argues with LLVM in five places because the C here was
hand-formatted into a house style worth keeping. The Python was not, and a
formatter of this kind is only worth having when there is nothing left to argue
about: every knob turned is an argument somebody has to have again later. 88
columns, double quotes, ruff's own line breaking.
**Import sorting is on, and C include sorting stays off.** Not an inconsistency: a
C include order can be load-bearing -- `_GNU_SOURCE` ahead of the system headers,
and a grouping somebody wrote by hand -- while Python import order is not. Where it
genuinely is, a statement between imports ends the block ruff will sort, and the
test files lean on exactly that:
import sys
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from harness import Session, check, report
`from harness import` cannot be hoisted above the line that makes it importable,
because the `sys.path.insert` between them is a barrier. Verified before touching
anything, since getting it wrong would have broken every test in the suite at once.
Two details in the hook worth naming. `--select I` is passed on the command line as
well as set in `ruff.toml`, so a stray `select` in somebody's own config cannot
turn a formatting hook into a linter that refuses a commit over an unused variable.
And the two tools are named together when either is missing, so setting up a
machine installs both at once instead of finding the second one on the next commit.
`set -e` and `[ -n "$c_files" ] && clang-format ...` do not mix: when the list is
empty the AND-list returns non-zero and the shell exits, so a Python-only commit
would have aborted with no message at all. They are `if` blocks. Found by testing
the case rather than by reading it.
Verified in a scratch clone, seven paths: Python alone, C alone, both in one
commit, imports sorted with the `sys.path` barrier intact, a `contrib/` script that
is Python with no extension on it, a partly staged Python file refused with `HEAD`
unchanged, and a commit with no code in it drawing no comment.
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
`make hooks` points `core.hooksPath` at `.githooks`, and from then on committing C
formats it first, so what lands in git is already formatted. `make fmt` does the
whole tree, `make fmt-check` only reports. The reformat itself is the next commit.
**The config was chosen by measuring, not by taste.** LLVM is the base because it
is what this code was already written in by hand -- two spaces, 80 columns,
`char *p`, braces attached -- but LLVM alone rewrote 38% of the lines. Three
disagreements account for most of that, and each is a real house idiom rather than
an accident:
- `if (!p) return false;` on one line, which is the guard idiom throughout. LLVM
splits every one of them in two, two thirds of the churn on its own.
`WithoutElse`, so an `if` that has an `else` still opens up: a one-line `if`
hiding an `else` is the arrangement that hides a bug.
- Comments are not reflowed. They are prose, wrapped by hand, and the breaks
sometimes carry meaning -- a paragraph, a list, a diagram of a pane. A comment
that runs long is a thing for a person to fix, not for a formatter to even out.
- Include order is left alone. It is grouped by hand here and is not alphabetical
within a group; sorting it would be churn at best and, at worst, a build that
depends on an order nobody wrote down.
Those three, plus one-line `case` labels and loops, bring the reformat from 38% to
17% -- and what is left is alignment and the hand-packed tables, which is the part
a formatter is *for*.
The vendored terminal core is excluded everywhere: `.githooks/pre-commit`, `make
fmt` and `make fmt-check` all filter `^vendor/`. It is pinned by commit (D12) and
reformatting it would turn every future re-vendor into a merge.
**The hook refuses a partly staged file rather than widening the commit.**
`clang-format -i` rewrites the working tree, so re-staging afterwards would commit
work somebody deliberately left out with `git add -p`. It says which files and what
to do instead. It also says when it formatted something, because a hook that edits
your commit silently is a hook people learn to distrust, and it fails with the
package name when `clang-format` is missing rather than letting unformatted C
through the one check meant to stop it. `--no-verify` still escapes.
Verified in a scratch clone, all four paths: a badly formatted file is committed
formatted; a partly staged one is refused with `HEAD` unchanged and the index
untouched; a commit with no C in it draws no comment; and `--no-verify` puts
unformatted code in, which is what asking for it means.
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
The build printed twenty-two zig command lines of about 160 characters
each, which is a screenful that says the same thing twenty-two times. Now
it says which file:
[CC] src/app.c
...
[LD] build/sl0ppty
`make V=1` prints the command lines instead. That is the whole reason for
the flag rather than just adding `@`: the moment one of those lines is
wrong -- a missing include path, a flag that is not doing what you
thought -- the command line is the only thing worth looking at, and it
should be one flag away rather than gone. `make help` says so, since that
is where somebody will look for it.
One label per kind, aligned with the ` ok test_x.py` the suite already
prints, so a full `make test` reads as one column: [CC] an object, [LD]
anything linked (the binary and the five test binaries), [SO] a shader
plugin, [COV] the separate gcc coverage build. `make docs` drops its
command line too and leaves the generator's own summary to speak.
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
contrib/gen-docs renders docs/ into a static site: one file of stdlib
Python and one stylesheet, no toolchain, no package manager, no npm. Same
argument as src/kdl.c being a hand-rolled KDL subset rather than a
library -- the input is ours, we know exactly which constructs it uses,
and a docs build that needs a lockfile is a docs build nobody runs.
What the Markdown subset understands is what docs/ actually contains:
headings (with anchors), paragraphs, fenced code, pipe tables, lists one
level deep, blockquotes, rules, `code`, bold, italic, links. Anything
else is escaped and passed through as text. A construct nobody uses is
not supported on purpose: it would be untested code pretending to be a
feature.
The layout is a sidebar of sections from docs/nav, the page, and its own
headings down the right; prev/next chains the nav order; a link to
another page is rewritten to its rendered page, so the same Markdown
reads correctly on a forge and on the site. The colours are the session's
own default theme, so the docs look like the thing they document, and the
light variant follows the reader's system preference rather than a toggle
nobody would find. Zero JavaScript -- the narrow-screen menu is a
checkbox.
Two things came out of looking at it in a browser rather than trusting
the HTML.
The hero diagram was hand-drawn and its lines were 78 and 79 columns
long, which is visible. It is generated from a real session now -- three
panes, a layout file, a text snapshot -- so it is exactly what the
program draws. Terminal art also needs tighter leading and a font whose
box drawing joins, so a fence with box characters in it and no language
is emitted as art rather than code and styled for that. Detected rather
than declared, because the alternative is a marker on every fence and one
of them getting forgotten. (Vertical rules still break between lines in
a browser: the glyph does not fill its em box, and no amount of CSS
changes that. Widths line up, which is the part that matters.)
The narrow-screen menu opened over a page that showed through underneath
it: as a grid child taken out of flow it kept its content height, so
`bottom: 0` did nothing. It has an explicit height now.
tests/test_docs.py checks both jobs. The site: every page in the nav is
rendered, carries the whole sidebar, is marked as current on its own
page, chains prev/next, and no internal link in the output points at a
file that is not there. The subset: each construct, and the one that
matters most -- markup inside a code span is not markup, because half of
these docs are shader expressions full of asterisks. A page missing from
docs/nav fails the build, and so does a nav entry with no file: an
unreachable page whose only symptom is its absence is the failure mode a
hand-written nav has.
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
Kitty graphics were green in the suite and a real program still drew
nothing. libghostty-vt ships no image codec: built as a library it sets
its `sys.decode_png` hook to null, and a null hook rejects every `f=100`
transmission outright -- no image stored, so no placement exists, so the
re-emission layer this project is mostly *about* is never reached. The
program gets no error and no picture.
Raw RGB worked throughout, which is why it looked done: every test here
sent raw pixels, because that is what is easy to write in a test. It is
the same lesson the last kitty graphics bug taught -- write the test the
way a program actually behaves -- and a program with a picture on disk
has a PNG. Found because ida-tui's startup logo never appeared, and its
detection probe answers `OK` first, so it fails silently rather than
falling back to its block art.
PNG is the whole of the gap and cannot grow: the protocol has exactly
three formats (`f=24`, `f=32`, `f=100`), the first two are raw pixels
lib-vt already handles, and `decode_png` is the only codec hook it has.
Decoding only, never encoding.
The decoder is the stb_image lib-vt already vendors and we already
commit -- upstream compiles that same header with the same
`STBI_ONLY_PNG` for this same purpose, it is just not in the
`-Demit-lib-vt` build. So nothing new is pulled in; a file we already
ship is finally compiled. src/png.c is the hook, a header pre-check and
a copy: dimensions are read before decoding because the buffer we return
is allocated through lib-vt's limited allocator but stb's own working
allocation is plain malloc and answers to nothing.
Tests cover a PNG placed at the size it asked for (RGB and RGBA sources,
both normalised to RGBA), the client being handed `f=32` rather than the
png it was given, and a malformed png placing nothing without taking the
pane with it. They fail on the previous binary.
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
`make coverage` builds a second binary with gcc's --coverage into build/cov,
runs every suite against it, and prints a per-file table from gcov. About a
minute. Nothing installed, nothing about the normal zig build changed.
gcc rather than clang because the counters need a gcov that matches the
compiler, and gcov is what this machine has -- no llvm-profdata, no llvm-cov,
no lcov, no gcovr. The aggregation is forty lines of python reading gcov's own
summaries, which is the whole reason this was cheap.
The suites drive it through $SL0PPTY_BIN, one line in the harness, so there is
no second copy of the test code and no risk of the coverage run testing
something else.
Two things about the result, both in the report itself:
- the C unit tests link the *same* instrumented objects, so input.c, kdl.c,
shader.c and expr.c are credited for the tests that exercise them. Built
separately, input.c read 54% while having a dedicated decoder test -- a lie
about where the tests are rather than about the code.
- server.c reads ~8% and is not untested. The session server is a daemonised
child ending in _exit(), which is right there and skips the flush gcov needs.
The counters are lost, not the tests. Said in the output, because an
unexplained low number invites someone to "fix" working code -- and changing
correct code to make a tool happier is the wrong trade.
Where it lands today: 85.4% of 6298 lines. The gaps that are real rather than
mechanical are jsonval.c (58%), screen.c (64%) and pty.c (59%) -- error paths
and the parts of the diff emitter that only a resize or a weird terminal
reaches.
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
The middle of the status line is empty unless the pointer is on something,
which is most of the time. It says which sl0ppty this is now.
That reads as decoration and is not. A session keeps the binary it started
with, so after a rebuild there are two versions in play: the one on disk and
the one you are attached to. This session has been dogfooding sl0ppty for a
while, and "which build is this?" has already been the answer to two bugs
that looked like something else -- once when a config reload appeared to do
nothing, and once when kitty graphics were fixed and still broken on screen.
The banner answers it without asking, and `sl0ppty --version` answers for the
binary, from the same string.
The version is baked in by the Makefile from `git describe --always --dirty`,
into a generated header that is only rewritten when the value changes -- so a
commit rebuilds and a second `make` does nothing.
It takes the hint's slot and yields to it: a hint is about what you are doing
right now, the banner is about always, and it comes back the moment the
pointer moves off. Both give way to the ends of the line, which was already
the rule for hints and is why a narrow terminal loses the middle rather than
its session name. `version_banner false` for anyone who finds it noisy; it is
a separate knob from `hints`, and the tests hold both directions of that.
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
You cannot see an expression by reading it. `(y % 2) * 40` is scanlines and
`(x > cols - 10) * 120` is a right margin, and neither is obvious until it is
on a screen -- so here is a screen. One HTML file, no build and no server:
open it and it works, offline, including from a checkout on a machine that
has never run sl0ppty.
It draws a fake pane with plausible contents, applies a chain of passes to
every cell, and prints the config line to paste. The mouse moves the cursor,
so `dist(x, y, curx, cury)` behaves the way it will in a pane rather than the
way it does at coordinates you imagined. Anything reading `t` animates. The
presets are every built-in written as an expression, which is the equivalence
the whole design rests on: `vignette` and `spotlight` are not special, they
are `dim` with an argument.
It also reports what each expression *reads* and whether that makes it
cacheable, because the dependency analysis is the interesting half of the
implementation and is otherwise invisible.
The hazard is that this is a second implementation of the language, in
another language, and it would disagree with the compiler quietly -- in the
third digit of a colour, on one preset, at one cursor position. So
tests/test_shadertoy.py lifts the evaluator straight out of the page (not a
copy of it, the page itself) and runs a few hundred expression/environment
pairs through both it and src/expr.c, including the corners: division by
zero, the ternary's untaken branch, `dist` counting a row double, and six
expressions that both sides must refuse. build/expr_eval is the real compiler
as a filter, which is what makes that comparison possible at all.
It found a bug on its first run, in the page: unary `-` and `!` did not
thread the environment through their closures, so `!x` threw instead of
evaluating. Which is the argument for the test, made better than I could have
made it.
Skipped with a message when node is missing -- a checker for a contrib toy is
not a reason the suite cannot run.
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
Adding an effect meant writing C -- in the binary, or since D15 in a shared
library. Both are a lot of ceremony for "dim every other row".
So: `amount` may be an expression, compiled to bytecode when the config is
read and evaluated per cell.
shaders {
dim amount="(y % 2) * 40" // scanlines
dim amount="(x > cols - 10) * 120" // a right margin
tint amount="255 - dist(x, y, curx, cury) * 12" color="#ff5fd7"
}
The scope is the argument, and that is the whole design. Every built-in here
is already colour_op(amount) with the amount a pure function of position --
vignette, zebra, ruler, margin and spotlight differ only in that function --
so the part worth handing to a config is the *number*, not the colour maths.
Three things follow at once: the mixing stays in C where it is fast and
correct; an expression cannot produce an invalid cell, because it produces
one byte; and that byte is cacheable.
The cache is what makes an interpreter affordable, and the numbers are why
this exists at all. Measured on a 200x50 pane, one pass:
native vignette 6.1 ns/cell
constant expression, folded 5.6 ns/cell
positional expression, mapped 5.2 ns/cell
animated (reads the clock) 35.0 ns/cell
A shader you wrote in your config costs what a compiled one does. A program
that does not read the clock is evaluated once per cell into a byte map and
reused until something it reads changes; one that does read the clock is
interpreted per cell, per frame, which is the honest price of animation and
is opt-in. (The earlier measurement of 56ns for interpretation was per
evaluation -- correct, and the reason the map exists rather than an argument
against the language.)
What a program reads is derived by the compiler rather than declared, and
that dependency set *is* the cache key. A stale map is therefore
unrepresentable rather than merely unlikely -- the same rule the layout
follows, arrived at from the other direction. A unit test caught the first
violation of it before anything else did: `cols` and `rows` carried no
dependency flag, on the theory that the key always contains the rect, and so
a program reading only the size looked constant and was folded at compile
time, where every size is zero.
The language is deliberately total. No loops, no recursion, no jumps: even
`?:` compiles to evaluating both sides and selecting, because nothing in it
has an effect. Evaluation is O(program length), so a config cannot spin, and
there is nothing to sandbox. Division by zero is 0 rather than a trap: an
expression that is wrong should look wrong, not take the session with it. One
that does not compile drops that one shader with a warning -- not the config,
and not the half-strength version of what you asked for, which is what the
number path did with garbage and is now also fixed.
shader_t gained the compiled program, so the plugin ABI goes to 2. A plugin
built against 1 is refused with "shader ABI 1, we speak 2" and needs nothing
but a rebuild -- the check earning its keep the first time it was asked to.
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
Adding an effect meant editing shader.c and rebuilding, which is fine for us
and useless to anyone else. Now any `*.so` in the shader directory that
exports a plugin table is loaded at startup, and what it declares is named in
the config exactly like a built-in -- same syntax, same three parameters,
same pass. Default location is a `shaders` directory beside the config file,
so dropping the .so next to config.kdl is the whole installation.
The interesting part is what a plugin can see, which is one header.
shader_abi.h holds cell_t, color_t, shade_ctx_t, shader_t and nothing else:
no libghostty-vt, no pty, no layout, no session. A shader is therefore
unable to reach anything but the cell it was handed, not by rule but because
nothing else is declared where it can find it. cell_t and color_t moved
there from sl0ppty.h for the same reason -- that is the one place they are a
contract with code compiled outside this tree rather than an internal type.
Loading is deliberately narrow:
- refused unless the library reports our ABI version *and* matching struct
sizes. The version says which contract; the sizes catch someone editing
their copy of the header, which a version number alone calls fine and a
running session calls garbled colours;
- refused if it redefines an existing name, because a config naming `dim`
means the `dim` it was written against;
- all-or-nothing per file, and per file: a bad library is a line on stderr
and everything else still loads (D9 fail-open);
- nothing is ever unloaded. A shade_fn may be sitting in a live config or on
a pane, so dlclose() is a jump into an unmapped page. A reload picks up
newly *added* plugins; replacing one needs a new session.
This is the single exception to D9's "a plugin is a subprocess", and it is
argued in D15 rather than drifted into: a shader is called per cell, per
pass, per frame, which no subprocess can be in the path of, and the measured
alternative -- interpreting bytecode per cell -- is 56ns against 8ns for the
compiled function. On trust, `shell` and a layout's `command=` already let a
file in the config directory run anything as you, so a .so beside them is
not a new boundary. The docs say so plainly rather than implying a sandbox.
contrib/shader-plugin is a working skeleton: Makefile, two commented effects
(one positional, one animated), and the three rules a shader has to keep. It
is built and loaded by the test suite, so a broken example is a failing test
rather than someone else's afternoon.
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
The reload existed but you had to ask for it, which is the wrong shape for the
thing it is mostly used for: changing a colour and looking at the result. The
server now watches the config and re-reads it when it changes.
It watches the config's *directory* and filters by name, rather than watching
the file. Editors overwhelmingly save by writing a temporary file and renaming
it over the target, which swaps the inode out from under a watch on the file
itself — it fires once and then never again, which is worse than not watching
at all because it looks like it works. The test writes in place *and* renames
over, because only the second one would have caught that.
Reloading goes through the same call the command does, so it keeps the
fail-open behaviour: a file that does not parse leaves the running config alone
and says so in a toast. That matters more here than it did for the command,
because a file caught halfway through a save is a normal thing for a watcher to
see rather than an operator mistake — and there is a test for it, and for the
recovery once the file is valid again.
Turning it off is a command-line flag rather than a setting, because a setting
would be circular: you would have to reload the config to stop it reloading the
config. --no-reload only stops the *watching*; asking explicitly still works,
which is the distinction the flag name is trying to make.
The test is a live one because inotify lives in the server's poll loop and the
headless driver has no server. It polls for the colour it expects rather than
sleeping a guess, so the passing cases cost milliseconds; the two cases that
assert nothing happened are the only ones that wait, because proving a negative
has to.
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
Measured before changing anything, because the obvious suspect was wrong: the
build is not slow. zig caches objects, so even a clean rebuild is ~0.15s and an
incremental one is 0.08s. The C unit tests are 0.00s. All eighty seconds were
in the python suite, and almost all of that was waiting on durations that were
guesses.
Four things, in order of what they bought.
Run the python tests in parallel. They are separate processes that share
nothing — script mode opens no socket — so each is now its own make target with
a stamp, and `make -j` runs them at once. 78s -> 21s for free. The stamps are
worth as much as the parallelism day to day: an untouched test whose binary has
not changed does not run again, so `make test` after editing one test file is
1.1s and after editing nothing is 0.02s. The binary is a prerequisite of every
stamp, so changing the code still re-runs everything.
Then stop guessing at durations. `settle N` is the only thing that reads a
pane's pty, so it is how you wait for a program's output — but `send` and
`snapshot` are already synchronous with the app, which means a settle after a
mouse event, waiting to assert on our own chrome, is waiting for nothing at
all. Those are removed. Where a program's output really is the thing being
waited for, harness.until()/until_text() polls in 10ms steps and returns the
instant it lands: measured, that is one poll, against the 200-300ms those calls
used to donate. The default settle drops 120ms -> 20ms on the same reasoning.
Three effects are gated on a real clock rather than on anything observable —
the hover dwell, and a toast's lifetime. Those genuinely have to wait, so the
tests that are not *about* the timing now configure the clock down instead
(hover_delay_ms 20, toast_ms 150) and the two tests that are about it keep the
real defaults, because a dwell test that cannot observe not-yet-elapsed proves
nothing.
Two tests turned out to be passing by accident, which is the useful kind of
thing a speedup shakes loose: both asserted the resize hint was gone after a
drop while the pointer was still sitting on the handle. It was only gone
because the dwell had not re-elapsed — resting on a handle *should* re-arm the
hint. They now move off the handle first and assert the thing they meant.
Unrelated but found on the way: the Makefile tracked no header dependencies, so
editing a .h rebuilt nothing and the only safe move was `make clean`. -MMD -MP
fixes it; touching shader.h now rebuilds exactly the six objects that include
it. That one was a correctness bug wearing a performance costume.
.fastfeedback/SPEED.md records the measurements and how to write a fast test
here, so the next person does not rediscover that settle-after-a-click is free
to delete.
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
The first two consumers of the shader pass, and between them they settle how a
pane gets one.
Dragging a pane by its title now greys every *other* pane, so the one that is
moving lifts off the page — which is the thing that made a colour pass worth
building. It engages only once the pointer has actually moved, because a press
on a title that turns out to be a click would otherwise flash the whole
session grey on its way to doing nothing. dim_unfocused fades everything but
the focused pane and is off by default: an always-on effect is the kind you
stop seeing and start fighting, so it should be asked for.
Both are derived every frame rather than attached and cleared. Focus and drags
move through far too many paths — hover, click, the finder, a close, a layout
apply, any keystroke ending a drag — to keep an attachment in sync with, and a
pane left grey by the one path that forgot is a bug that only turns up in
front of someone. The rect and the collapsed flag are recomputed every pass
for exactly this reason; this is the same rule applied to colour. It also
makes the awkward cases free: dropping into the margin, a keystroke ending the
drag, or the dragged pane being closed under it all un-grey without anywhere
having to say so, and there are tests for each because "it turns off again" is
the whole risk.
The two policies replace rather than stack. Dragging an unfocused pane while
dim_unfocused is on would otherwise give it two reasons to be grey, and they
compound into one muddy grey that reads as neither; the drag wins, so the
dragged pane is at full colour whatever the focus says.
shade_apply no longer caps at SHADE_MAX. That cap was right when the only
shaders were the ones a pane had attached, and wrong the moment the session
derives one on top of them: a full stack would have silently dropped it. The
count is the caller's to bound.
The chrome check that mattered was already there: test_drop_target_is_visible
samples a frame corner mid-drag, and it still passes, because the pass runs
between the contents and the chrome drawn over them.
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
A shader is a pure function from (cell, position, parameters) to that cell's
colours, applied to a pane's contents. Tinting, dimming and greying are the
first three; the point is the mechanism, because it is going to carry a lot of
UX later — drag a pane and grey the others, dim what is not focused, mark a
pane that wants attention — and all of those want to be a colour pass rather
than another special case in the drawing code.
Where it runs is the whole design. draw_cb already paints the frame, then the
contents, then the split guide, so the pass goes between the last two and
"contents, not chrome" stops being a rule anyone has to remember: the frame
was painted before and lies outside the content rect, and an affordance
painted after stays legible on top of a pane that has been dimmed underneath
it. node_t already carries the content rect, so the pass has its bounds.
Cells only, and colours and attributes only. Rewriting a cell's text would
desync selection and copy, which read the terminal rather than the screen, and
could break the wide-cell invariant; the tail half of a wide cell is skipped
for the same reason, since shading it computes a colour nothing displays.
Each cell is shaded independently — no neighbours — so anything positional
works and convolutions do not. That is deliberate: blur would need a second
buffer, and everything actually wanted here is a function of position and
colour. The context carries the content size and rect-relative coordinates so
gradients and vignettes are expressible, plus a timestamp for whatever ends up
animated.
The one real problem is that "terminal default" has no RGB. We never learn
what the client's default foreground is, and most terminal text is
default-coloured, so a shader that skipped those cells would leave nearly
everything at full brightness and read as broken. The pass materialises them
to a configured default_fg/default_bg (white on black) before running, which
means a shaded pane stops tracking the terminal's own theme for those cells —
so the config exists to make that right, and asking the terminal directly with
OSC 10/11 is the eventual fix. A pane with no shaders skips the pass entirely
and still defers to the terminal exactly as before.
Attachment is internal policy: app_shade_add/clear, callable from inside the
app and from nowhere else. Not over the control API and not in-band, so a
program in a pane cannot restyle itself. Nothing attaches one yet.
Kitty graphics are re-emitted as images rather than cells, so a shader cannot
touch them: a greyed pane keeps a full-colour image in it. Known and accepted.
Tested as a C unit test, since the pass is pure: the built-ins and their
strength scale, luma weighting, chaining order mattering, defaults being
materialised, wide-cell tails and text surviving, degenerate input, what the
context tells a shader, and — the one that protects the chrome — that the pass
touches nothing outside its rect.
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
The top row was both the frame's drag handle and its top edge: resting on it
armed the split guide, and a click that never moved split upward. That is
right for a border and wrong for the cells the title sits on, because it left
no gesture free for naming a pane — a double-click there split twice.
So the title text carves its own hit region out of the top row. It is
registered after the row's handle and after the title is painted, which is
what makes it correct: hit_test scans backwards, so the region that wins is
the one whose glyphs the user can actually see, and its width comes from
screen_text's return so a title of wide characters claims the cells it drew.
Resting there arms nothing (draw_split_guide matches "title:" exactly), and a
press takes no side, so the click-splits path — now guarded on a side being
set — does nothing. Dragging still moves the pane: that reads as grabbing it
by its name. Everything else on the top row still splits upward.
Renaming happens in the title cell itself. There is no dialog to place,
nothing to dismiss, and the pane it belongs to cannot be in doubt. It is the
finder's shape: state on app_t, a key handler that owns the keyboard while it
is open, drawn by the pass that draws the frame. Enter keeps the name, Escape
abandons it, clicking away keeps it the way leaving a field does.
A typed name shadows the program's title rather than overwriting it. The
program's OSC 2 keeps arriving underneath, so a shell that rewrites its title
cannot undo a rename, and clearing the name gives the pane back to what the
program calls itself *now* rather than to whatever it happened to say when the
rename began. That is the whole reason pane_t grew a second string instead of
a flag.
Backspace deletes a character rather than a byte — a title is whatever a
program could set, and half a UTF-8 sequence is not a name — and a name too
long for the frame scrolls its head off, because the caret is the one thing
that has to stay visible. double_click_ms (400) joins hover_delay_ms in the
config.
The tests keep a control beside each behaviour, since most of these could pass
by breaking something else: two slow clicks must open no editor, the top edge
must still split, keystrokes must not reach the shell, and a pane closing
mid-rename must give the keyboard back.
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
`--headless` is documented as "run once, print the screen", and it printed
nothing for any command that finished — which is most of them. `make smoke`
had been showing six blank rows.
settle() calls app_reap() every iteration, and app_reap closes a pane whose
child has exited; closing the last one takes the tab with it. A command like
`sh -c printf ...` exits immediately, so the pane and its content were gone
before app_compose ever ran, and the compose found an app with no tabs.
So settle() now takes an optional screen and composes into it before the reap
rather than after. This is exact rather than a narrowed race: pane_pump() only
marks a pane dead on read() returning 0, and it reaches EOF only after it has
drained everything the pty had, so a pane cannot be marked dead while its
output is still pending and the last snapshot always holds the finished
screen. --script passes NULL and is byte-for-byte unchanged.
smoke also asked for 6 rows, which after the outer margin, the tab strip and
two borders leaves about one row for the pane — under min_pane rows, so it
collapsed to an empty frame and would have shown nothing even with the fix.
It asks for 12 now.
Found while renaming; it predates the rename, reproduced on the commit before.
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
A pty is a pseudo-terminal, and that is what this multiplexes: the extra t
was never standing for anything. Renaming now, while the only users are the
people who typed the old name on purpose, is cheaper than renaming later.
Mechanical, but not only cosmetic. The name is also the runtime contract, so
this moves $SL0PTTY and $SL0PTTY_CONFIG, ~/.config/sl0ptty/config.kdl, the
socket directory ($XDG_RUNTIME_DIR/sl0ptty, /tmp/sl0ptty-<uid>), and the
identity string a program gets back from an OSC 5577 hello. A session left
running under the old binary keeps its old socket directory and is invisible
to the new one; there is no migration and it does not seem worth writing one.
The sl0ppi fork keeps its name: it is a different thing, and D1 still points
at it.
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
A program in a pane transmits an image and it appears. libghostty-vt parses
the protocol and tracks images and placements; what a multiplexer has to add
is re-emitting them to the client, which is exactly what tmux and zellij do
not do — and the reason tools fall back to sixel under them.
Per frame: walk the visible tab's panes, transmit each image once (a=t),
place every visible placement (a=p,C=1) after the cell diff so a repainted
cell cannot land on top of one, and delete placements that vanished. Image ids
are remapped into a high range, so two panes both using image 7 stay two
images and neither collides with what the client's terminal already had. A
reattaching client is told everything again, because it has seen nothing.
The cropping rule is the part worth remembering: a placement is N pixels drawn
across M cells, so the placement itself tells you the pixels per cell — the
client's font metrics never come into it. Clipping at a pane edge, or at the
top of the viewport when a tall image is partly scrolled off, moves the source
rectangle; asking for fewer columns or rows alone scales the image into them
instead of cropping it. The first version did exactly that, and dropped
partially-visible placements entirely rather than cropping them.
Tests cover the multiplexer-specific case first: two panes, both using image
id 7, must get different client ids. Then placement position in screen cells,
following the layout, cropping at edges and when taller than the pane, nothing
from collapsed panes, and removal when a pane closes or the image scrolls
away. A live check confirms the wire: transmitted once, re-placed per frame,
not re-transmitted.
416 checks green (389 headless + 27 live).
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
The per-pane '+' is gone. It was one glyph for a verb with four directions, so
it always split into columns; it taxed every frame three columns of title
forever; and there were N of them on screen for one verb.
Clicking a pane's border now splits toward that border: the side you click is
the side the new pane appears on. Hovering arms the edge (it goes heavy) and
draws a dashed line exactly where the new boundary would land, so the gesture
explains itself and an idle frame stays quiet.
╭──────────────────── shell ─────────────────────╮
┃ ╎ │
┃ ╎ │ hovering the left
╰────────────────────────────────────────────────╯ border
│╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌│ hovering the bottom
╰━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━╯ border
The top border keeps its other job: a click splits upward, a drag moves the
pane. A press that never moves is a click, which is a distinction the drag
machine already had to make.
The guide has to be painted after the pane's content, not with the frame: the
dashed line crosses the terminal's own cells, and the first version was drawn
underneath them and was invisible.
Two old tests changed rather than the code, both because the gesture set is
better now: 'beside the split button' is a drag handle, and 'dropping a pane
on itself does nothing' is a click, which splits. The replacement asserts what
actually needs asserting — a drag that wanders and returns changes nothing.
387 checks green (360 headless + 27 live).
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
Drag over text, release, and it is on your clipboard — no chord, no menu, the
selection is the copy. The clipboard lives on the client's machine, so the
copy travels as OSC 52 and therefore works over ssh; the session keeps its own
copy as the primary selection, which is what middle click pastes back.
Selected cells are inverted rather than recoloured, so a selection reads the
same over whatever theme the program inside is using. A program that is
tracking the mouse keeps its own clicks (shift is the escape hatch), which is
tested rather than assumed.
Toasts: transient announcements in the bottom-right that expire on their own,
stacked up to three. 'copied 13 chars', 'config reloaded', a pane's OSC 9
notification, or {"cmd":"notify","text":"..."} from an orchestrator — an agent
in a pane can now say 'build finished' and have it appear. OSC 52 from a pane
reaches the same clipboard.
Two real bugs found on the way:
- mouse events are hit-tested against what was painted, and nothing had been
painted before the first frame, so a click that arrived first was dropped.
Both front ends now compose once before accepting input, which is what
really happens in a session anyway.
- the plain leaf_new never installed the clipboard and notification
handlers; only panes built from a layout file had them. Every ordinary
pane was silently missing both.
The test escaping traps continue to earn their comments: a real newline inside
a driver argument terminates the *command* rather than reaching the shell, \a
is not a driver escape (\x07 is), and an interactive shell's line editor eats
a typed ESC — so a pane driven with escape sequences must not be one.
356 checks green (329 headless + 27 live).
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
libghostty-vt already keeps the scrollback, so the work was deciding whose
wheel it is:
- a program tracking the mouse gets the wheel events themselves;
- a full-screen program without mouse tracking (vim, less) has no scrollback
of ours to show, so the wheel becomes arrow keys, which is what it means
to that program;
- anything else scrolls our viewport.
All three are tested against real programs rather than asserted in principle.
C-a PageUp/PageDown/Home/End, and scroll_lines in the config for the wheel.
Typing snaps back to the live view, because reading scrollback and then typing
into a screen you are not looking at is a trap. New output does not yank the
viewport back — it just grows the gap, which the indicator shows.
The indicator is compact, right-aligned, budgeted after the split button and
before the title (a title is the thing you can most afford to lose), and
clicking it returns to the bottom.
Three test failures on the way, all mine and all instructive: the top of the
scrollback is the shell's first prompt rather than the first line of output;
typing without a newline types rather than runs; and two side-by-side panes
share a screen row, so asserting on line(y) reads both frames at once.
341 checks green.
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
Hovering a pane focuses it. The feature is three lines; the reason it is not
obnoxious is the guards, and each one is a test:
- not while the prefix is held, so a chord cannot be hijacked by a nudge of
the mouse;
- not through the finder, whose rows are on top of the hit list anyway;
- not during a drag, which owns the mouse until the button comes up;
- not onto a collapsed pane. Only 'pane:' and 'title:' targets count, never
the frame rect's 'focus:', so the pointer crossing a stack of headers does
not expand three panes on its way past.
Off with 'focus_follows_mouse false'; clicking still focuses either way.
This needed the client to ask for ?1003 (any motion) rather than ?1002 (motion
while a button is held), which fixes something that was quietly broken: a pane
requesting any-event tracking could never receive hover, because the outer
terminal was never asked to report it. It can now, and a pane that asked only
for clicks still gets none — both tested.
One real bug behind that: our decoder represents 'no button held' as
MBTN_UNKNOWN, and pane_send_mouse was passing it to the encoder as a concrete
button. lib-vt has ghostty_mouse_event_clear_button() for exactly this, and
without it bare motion encoded to nothing at all.
320 checks green.
|