aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authoruser <user@clank>2026-08-16 11:12:20 +0200
committeruser <user@clank>2026-08-16 11:12:20 +0200
commita4e09657ca89fe3cc553ec894e3776abfd4a3e99 (patch)
treeacc330c10f38882e139731052c7964b259a3e826
parentsl0ppty: a pane can set its own shaders (diff)
downloadslosh-a4e09657ca89fe3cc553ec894e3776abfd4a3e99.tar.gz
slosh-a4e09657ca89fe3cc553ec894e3776abfd4a3e99.tar.xz
slosh-a4e09657ca89fe3cc553ec894e3776abfd4a3e99.zip
sl0ppty: a prompt with a memory
`contrib/shader-repl` imports readline, which is most of it: editing, up/down, ctrl-r, and history kept between runs in `$XDG_DATA_HOME/sl0ppty/shader-repl.history`. Chains only -- a `:` command is taken back out of the history it would otherwise fill, because pressing up four times past `:quit` and `:paste` to reach the line you were working on is the annoyance history exists to remove. Tab completes the commands, the nine shader names, the property keys, the two rects, the three channels, and the expression language's own variables and functions. `:help` prints the lot at once, which is the same list from the same place. That list is a claim about what exists, so `tests/test_shader_repl.py` checks it against `src/shader.c` and `src/expr.c` and fails if either grows a name it does not have -- verified by removing `spotlight` and watching it fail. Then it types every name it offers at a real prompt and requires `ok` from a real session, because a name that completes and does not parse is a lie with a keystroke attached. History across two runs, a tab completion, and `:help` are driven the same way, through a pty. Two things that cost me time and are now written down where they bite: keys go into a pane escaped (`\r`, `\t`, `\e[A`) because `raw` takes the rest of one command line and a literal newline ends the command instead of arriving; and Enter is CR, which is what a terminal sends and what readline waits for. Also: **a bare argument on a shader entry is now a complaint.** Every parameter is a property, so `tint color="#000000" spotlight` is not a pass with a spare word, it is a missing `;` -- KDL reads one node, the second name does nothing, and the session used to answer `ok`. It now says `unexpected argument for tint: spotlight (separate entries with ';')`. Found by watching the prompt get used.
-rwxr-xr-xcontrib/shader-repl92
-rw-r--r--docs/shaders.md9
-rw-r--r--src/config.c11
-rwxr-xr-xtests/test_inband_shaders.py3
-rwxr-xr-xtests/test_shader_repl.py193
5 files changed, 307 insertions, 1 deletions
diff --git a/contrib/shader-repl b/contrib/shader-repl
index 0fd9c39..d69f8e7 100755
--- a/contrib/shader-repl
+++ b/contrib/shader-repl
@@ -22,10 +22,18 @@ Commands:
:clear take this pane's chain away
:both clear both chains
:paste print what you have as a `shaders { }` block
+ :help this list
:quit leave (the chain stays until the pane closes)
+The prompt is readline's, so it has the editing and history you already know --
+up/down through what you tried, ctrl-r to search it, and history kept across runs
+in `$XDG_DATA_HOME/sl0ppty/shader-repl.history`. Tab completes the commands, the
+shader names, the property keys and the expression language's own variables and
+functions, which is most of what there is to remember.
+
Python 3, standard library only.
"""
+import atexit
import os
import select
import sys
@@ -33,10 +41,72 @@ import termios
import time
import tty
+try:
+ import readline
+except ImportError: # a python built without it: the prompt still works
+ readline = None
+
OSC = "\033]5577;1;"
ST = "\033\\"
TIMEOUT = 2.0
+COMMANDS = [":chrome", ":content", ":clear", ":both", ":paste", ":help", ":quit"]
+
+# What there is to complete. Written here rather than asked for, because there is
+# nothing to ask: the session has no verb that lists them. Kept honest by
+# `tests/test_shader_repl.py`, which fails if `src/shader.c` or `src/expr.c`
+# grows a name this list does not have -- a completion list that quietly goes
+# stale is worse than none, because it reads as "that is all there is".
+SHADERS = ["dim", "grayscale", "tint", "vignette", "gradient", "zebra", "ruler",
+ "margin", "spotlight"]
+PROPS = ["amount=", "color=", "channel=", "where=", "at=", "radius=", "band=",
+ "direction="]
+# Values worth offering: the two rects, the three channels.
+VALUES = ['"chrome"', '"content"', '"fg"', '"bg"', '"both"']
+VARS = ["x", "y", "cols", "rows", "curx", "cury", "cursor", "focused", "t",
+ "since"]
+FUNCS = ["min(", "max(", "abs(", "clamp(", "dist("]
+
+HISTORY = os.path.join(
+ os.environ.get("XDG_DATA_HOME") or os.path.expanduser("~/.local/share"),
+ "sl0ppty", "shader-repl.history")
+
+
+def complete(text, state):
+ """One flat vocabulary. A shader name is only valid first and a variable only
+ inside an expression, but a prompt that guesses which you meant and offers
+ nothing when it guesses wrong is worse company than one that offers
+ everything starting with what you typed."""
+ words = COMMANDS + SHADERS + PROPS + VALUES + VARS + FUNCS
+ hits = [w for w in words if w.startswith(text)]
+ return hits[state] if state < len(hits) else None
+
+
+def setup_readline():
+ """Editing, history across runs, and tab completion. Nothing here is load
+ bearing: without readline the prompt is still a prompt."""
+ if not readline:
+ return
+ os.makedirs(os.path.dirname(HISTORY), exist_ok=True)
+ try:
+ readline.read_history_file(HISTORY)
+ except OSError:
+ pass # first run, or a home directory we cannot read: not a reason to stop
+ readline.set_history_length(500)
+ atexit.register(write_history)
+ readline.set_completer(complete)
+ # `=`, `"` and `(` end a word: `color="#ff` should complete on `#ff`, and
+ # `clamp(23` should not look like one enormous token.
+ readline.set_completer_delims(' \t\n;=("')
+ readline.parse_and_bind("tab: complete")
+
+
+def write_history():
+ try:
+ readline.write_history_file(HISTORY)
+ except OSError:
+ pass # a read-only home is not worth a traceback on the way out
+
def send(payload):
sys.stdout.write(OSC + payload + ST)
@@ -68,6 +138,9 @@ def ask(fd, where, chain):
send("shader;%s;%s" % (where, chain))
answer = reply(fd)
if answer is None:
+ # Whatever half-arrived is not a line anybody typed: drop it, or those
+ # escape bytes become the front of the next thing readline reads.
+ termios.tcflush(fd, termios.TCIFLUSH)
return ("no answer -- is this pane inside sl0ppty?", False)
if answer == "ok":
return ("ok", True)
@@ -100,8 +173,9 @@ def main():
where = "chrome"
chains = {"chrome": "", "content": ""}
+ setup_readline()
print("shader-repl: a chain in config syntax, applied to this pane.")
- print(" :chrome :content :clear :both :paste :quit")
+ print(" " + " ".join(COMMANDS) + ("" if readline else " (no readline)"))
try:
while True:
try:
@@ -111,6 +185,14 @@ def main():
line = line.strip()
if not line:
continue
+ # History is for chains. `:quit` and `:paste` are how you drove the
+ # prompt, not what you were working on, and having to press up past
+ # four of them to reach the line you want to edit is the whole
+ # annoyance history exists to remove.
+ if line.startswith((":", "?")) and readline:
+ n = readline.get_current_history_length()
+ if n:
+ readline.remove_history_item(n - 1)
if line in (":quit", ":q"):
break
@@ -120,6 +202,14 @@ def main():
if line == ":paste":
print(paste(chains))
continue
+ if line in (":help", ":h", "?"):
+ print(" " + " ".join(COMMANDS))
+ print(" shaders: " + " ".join(SHADERS))
+ print(" keys: " + " ".join(p.rstrip("=") for p in PROPS))
+ print(" vars: " + " ".join(VARS))
+ print(" funcs: " + " ".join(f.rstrip("(") for f in FUNCS))
+ print(" tab completes all of it; up/down and ctrl-r are history")
+ continue
if line in (":clear", ":both"):
targets = ["chrome", "content"] if line == ":both" else [where]
for target in targets:
diff --git a/docs/shaders.md b/docs/shaders.md
index 95550f0..63801db 100644
--- a/docs/shaders.md
+++ b/docs/shaders.md
@@ -110,6 +110,15 @@ pane change, and `:paste` prints what you have as a `shaders { }` block for your
config. What you prototype and what you paste are parsed by the same code, which
is the point of using the config's syntax for a thing typed at a terminal.
+It is a readline prompt, so editing, up/down and ctrl-r work as they do in a
+shell, and history is kept between runs in
+`$XDG_DATA_HOME/sl0ppty/shader-repl.history` -- the chains only, since `:quit` is
+not something you want to press up past. Tab completes the commands, the shader
+names, the property keys and the expression language's own variables and
+functions; `:help` prints the same list at once. A test checks that list against
+`src/shader.c` and `src/expr.c`, because a completion list that has gone stale
+reads as "that is all there is".
+
**Off by default.** It needs `in_band_shaders true`, because a program that can
restyle the session it happens to be running in is a hazard first and a
convenience second: `cat` the wrong file and your panes go dark. With it on, a
diff --git a/src/config.c b/src/config.c
index 4d75658..70667ba 100644
--- a/src/config.c
+++ b/src/config.c
@@ -252,6 +252,17 @@ static bool parse_shader_entry(const kdl_node_t *k, color_t default_color,
expr_prog_t **expr, char *why, size_t whycap) {
*expr = NULL;
+ /* Every parameter a shader takes is a property, so a bare argument is not a
+ * thing we ignore -- it is a `;` somebody left out. `tint color="#000000"
+ * spotlight` reads to a person as two passes and to KDL as one node with a
+ * spare word, and answering "ok" to that is how you spend ten minutes
+ * wondering why the second one does nothing. */
+ if (k->nargs) {
+ snprintf(why, whycap, "unexpected argument for %s: %s (separate entries with `;`)",
+ k->name, kdl_arg(k, 0, ""));
+ return false;
+ }
+
/* Where this pass runs. Dropped rather than defaulted when the word is not
* one we know, for the same reason a bad `amount` is: the entry says what it
* wants and we cannot do it, and running it over the contents because "chrom"
diff --git a/tests/test_inband_shaders.py b/tests/test_inband_shaders.py
index 5fdf23d..860e618 100755
--- a/tests/test_inband_shaders.py
+++ b/tests/test_inband_shaders.py
@@ -188,6 +188,9 @@ def test_a_chain_that_does_not_read_is_refused_with_a_reason():
('1;shader;chrome;tint amount="y +"', "bad amount"),
('1;shader;chrome;tint channel="blue"', "bad channel"),
('1;shader;sideways;tint amount=200', "content or chrome"),
+ # A `;` left out: KDL reads the second name as a spare word on the first
+ # entry, which is the one mistake this syntax makes easy to make.
+ ('1;shader;chrome;tint color="#000000" spotlight', "unexpected argument"),
]
for payload, want in cases:
with Session(pane(osc(payload)), cols=76, rows=9, config=path) as s:
diff --git a/tests/test_shader_repl.py b/tests/test_shader_repl.py
new file mode 100755
index 0000000..c3c46cf
--- /dev/null
+++ b/tests/test_shader_repl.py
@@ -0,0 +1,193 @@
+#!/usr/bin/env python3
+"""`contrib/shader-repl`: the prompt for prototyping chains in a pane.
+
+Two things are worth testing here and one of them is not the prompt. A REPL that
+offers tab completion is making a claim about what exists, so the claim is checked
+against the code: every shader in `src/shader.c` and every name the expression
+language knows in `src/expr.c` has to be in the list, or completion quietly
+becomes a list of what was true once.
+
+The rest is the loop itself, driven the way a person drives it -- typed lines,
+history, a tab -- in a real session with a real pty.
+"""
+import os
+import re
+import subprocess
+import sys
+import tempfile
+
+from harness import Session, check, report
+
+# Keys go in escaped (`\\r`, `\\t`, `\\e[A`) rather than as real control characters:
+# `raw` takes the rest of one command line, so a literal newline in the argument
+# ends the command instead of reaching the pane. Enter is CR, which is what a
+# terminal sends and what readline is waiting for.
+
+ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
+REPL = os.path.join(ROOT, "contrib", "shader-repl")
+
+
+def repl_lists():
+ """The vocabulary the script offers, read out of the script."""
+ src = open(REPL).read()
+ def names(var):
+ body = re.search(r"^%s = (\[[^\]]*\])" % var, src, re.M).group(1)
+ return [w.strip().strip('"\'').rstrip("=(").strip('"')
+ for w in re.findall(r'["\'][^"\']+["\']', body)]
+ return {v: names(v) for v in ("COMMANDS", "SHADERS", "PROPS", "VARS", "FUNCS")}
+
+
+def source_names(path, pattern):
+ return set(re.findall(pattern, open(os.path.join(ROOT, "src", path)).read()))
+
+
+def last_reply(sess):
+ """The repl's answer to the last chain: `ok`, or why not."""
+ txt = sess.snapshot().pane_text(sess.pane())
+ for l in reversed([l.strip() for l in txt.split("\n") if l.strip()]):
+ if l == "ok" or l.startswith("bad ") or l.startswith("no answer"):
+ return l
+ return "(no reply)"
+
+
+def last_line(sess):
+ txt = sess.snapshot().pane_text(sess.pane())
+ lines = [l.strip() for l in txt.split("\n") if l.strip()]
+ return lines[-1] if lines else ""
+
+
+def cfg(text):
+ f = tempfile.NamedTemporaryFile("w", suffix=".kdl", delete=False)
+ f.write(text)
+ f.close()
+ return f.name
+
+
+def session(cfg_path, data_home):
+ """A pane running the repl, with its history in a directory we can inspect."""
+ env = {"XDG_DATA_HOME": data_home, "TERM": "xterm-256color"}
+ return Session(["/bin/sh", "-c", "exec python3 %s" % REPL],
+ cols=84, rows=16, config=cfg_path, env=env)
+
+
+def test_the_completion_list_matches_the_code():
+ lists = repl_lists()
+
+ # src/shader.c's REGISTRY: `{"dim", sh_dim, ...}`
+ shader_c = open(os.path.join(ROOT, "src", "shader.c")).read()
+ registry = shader_c[shader_c.index("REGISTRY[] = {"):]
+ registry = registry[:registry.index("\n};")]
+ builtin = set(re.findall(r'\{"([a-z_-]+)"', registry))
+ check("every built-in shader can be completed",
+ builtin <= set(lists["SHADERS"]),
+ "missing: %s" % sorted(builtin - set(lists["SHADERS"])))
+ check("and nothing is offered that is not one",
+ set(lists["SHADERS"]) <= builtin,
+ "not shaders: %s" % sorted(set(lists["SHADERS"]) - builtin))
+
+ variables = source_names("expr.c", r'\{"([a-z_]+)", V_[A-Z_]+')
+ check("every expression variable can be completed",
+ variables <= set(lists["VARS"]),
+ "missing: %s" % sorted(variables - set(lists["VARS"])))
+
+ funcs = source_names("expr.c", r'\{"([a-z]+)", \d, OP_[A-Z]+\}')
+ check("every expression function can be completed",
+ funcs <= set(lists["FUNCS"]),
+ "missing: %s" % sorted(funcs - set(lists["FUNCS"])))
+
+
+def test_every_offered_shader_is_accepted_by_a_session():
+ """The list is only true if the session agrees, so every name in it gets typed
+ at a real prompt. Checked one at a time as each reply lands: a 16-row pane
+ cannot hold nine exchanges, and counting `ok`s on a screen that has scrolled
+ measures the window, not the run."""
+ path = cfg("in_band_shaders true\n")
+ home = tempfile.mkdtemp()
+ names = repl_lists()["SHADERS"]
+ refused = []
+ with session(path, home) as s:
+ s.until_text("chrome>")
+ for name in names:
+ s.raw("%s amount=40\\r" % name)
+ s.settle(60)
+ reply = last_reply(s)
+ if reply != "ok":
+ refused.append((name, reply))
+ check("every completable shader parses in a real session", not refused,
+ str(refused))
+ os.unlink(path)
+
+
+def test_history_survives_the_next_run():
+ """The point of readline here: what you tried last time is still there. The
+ file is written on the way out, so this is two sessions and one directory.
+
+ One press of up reaches the chain even though `:quit` was typed after it,
+ because a `:` command is taken back out of the history it would otherwise
+ fill."""
+ path = cfg("in_band_shaders true\n")
+ home = tempfile.mkdtemp()
+ hist = os.path.join(home, "sl0ppty", "shader-repl.history")
+
+ with session(path, home) as s:
+ s.until_text("chrome>")
+ s.raw('tint color="#00ff88" amount=200\\r')
+ s.until_text("ok")
+ s.raw(":quit\\r")
+ s.settle(60)
+ check("a history file is written", os.path.exists(hist), hist)
+ saved = open(hist).read() if os.path.exists(hist) else ""
+ check("with the chain that was typed in it", "#00ff88" in saved, repr(saved))
+ check("and without the command that ended the run", ":quit" not in saved,
+ repr(saved))
+
+ with session(path, home) as s:
+ s.until_text("chrome>")
+ s.raw("\\e[A") # up: readline recalls the last line
+ s.settle(60)
+ line = last_line(s)
+ check("and the next run recalls it with one press of up",
+ "#00ff88" in line, repr(line))
+ os.unlink(path)
+
+
+def test_tab_completes():
+ path = cfg("in_band_shaders true\n")
+ home = tempfile.mkdtemp()
+ with session(path, home) as s:
+ s.until_text("chrome>")
+ s.raw("grays\\t") # one match: completed in place
+ s.settle(60)
+ out = s.snapshot().pane_text(s.pane())
+ check("a unique prefix completes to the shader name",
+ "grayscale" in out, repr(out[-120:]))
+
+ s.raw(" amount=40\\r")
+ s.until_text("ok")
+ s.raw(":cl\\t") # `:c` is ambiguous with :chrome/:content; `:cl` is not
+ s.settle(60)
+ out = s.snapshot().pane_text(s.pane())
+ check("and so does a command", ":clear" in out, repr(out[-120:]))
+ os.unlink(path)
+
+
+def test_help_lists_what_there_is():
+ path = cfg("in_band_shaders true\n")
+ home = tempfile.mkdtemp()
+ with session(path, home) as s:
+ s.until_text("chrome>")
+ s.raw(":help\\r")
+ s.settle(60)
+ out = s.snapshot().pane_text(s.pane())
+ for want in ("spotlight", "curx", "clamp", "ctrl-r"):
+ check("`:help` mentions %s" % want, want in out, repr(out[-200:]))
+ os.unlink(path)
+
+
+if __name__ == "__main__":
+ test_the_completion_list_matches_the_code()
+ test_every_offered_shader_is_accepted_by_a_session()
+ test_history_survives_the_next_run()
+ test_tab_completes()
+ test_help_lists_what_there_is()
+ sys.exit(report())