aboutsummaryrefslogtreecommitdiffstats
path: root/contrib/shader-repl
diff options
context:
space:
mode:
authoruser <user@clank>2026-08-16 18:40:42 +0200
committeruser <user@clank>2026-08-16 18:40:42 +0200
commit80b98bbf4c6ffe1a95c389525af30374df21d8eb (patch)
treec9ac16b33c85465db06cfbfea374ffb232fbab05 /contrib/shader-repl
parentsl0ppty: six chrome presets that need the new operators (diff)
downloadslosh-80b98bbf4c6ffe1a95c389525af30374df21d8eb.tar.gz
slosh-80b98bbf4c6ffe1a95c389525af30374df21d8eb.tar.xz
slosh-80b98bbf4c6ffe1a95c389525af30374df21d8eb.zip
sl0ppty: `:load` a preset, without a second KDL reader
chrome> :load sine-comet sine-comet.kdl: 1 chrome, 0 content chrome> :paste // sine-comet.kdl include "/home/user/dev/sl0ppty/contrib/chrome/sine-comet.kdl" A path, or the bare name of anything in `chrome/` or `shaders/` beside the script, from any directory. Entries land wherever their own `where=` says, so one file can fill both chains and a preset goes where its author put it. Tab completes filenames after `:load` and vocabulary everywhere else. **The prompt does not parse the file.** I started writing the scanner -- find the `shaders {` block, count braces, strip `//` outside quoted strings so that `color="#ff0033"` and `t / 8` survive -- and stopped, because that is a second reader of a format that already has one, and the two would disagree exactly where comments meet strings. Instead the session gets the path and reads it with the parser it already has: `config_parse_chain_file`, built on the same `parse_shader_entry` a config file goes through. Anything that is not a top-level `shaders { }` block is ignored, so a whole `config.kdl` can be handed over and only its shaders apply. The alternative was regenerating all 45 presets into something easier to read from Python. That would have cost them what makes them useful: each one is a config fragment today -- pasteable, `include`-able, checkable with `sl0ppty --check`, and loaded as a config by `tests/test_chrome_presets.py`. A format only the prompt can read would be a fourth thing to keep in step. `:paste` after a load answers with an `include` line rather than the file's thirty: the file is on disk, it is already the block, and reprinting it scrolled the pane you were looking at -- which is how I noticed. Both chains are built beside the live ones and swapped in together, so a file that goes wrong half way leaves the pane as it was rather than wearing the half that parsed. Each compiled expression is matched to the chain whose shader points at it before either is installed; freeing a program the other chain still uses is the one bug here that would not appear until the next repaint. Verified: nine checks in `tests/test_shader_repl.py` -- a preset by bare name, a file with both rects going to both chains, the three ways to be wrong (no such file, no `shaders { }` block, no argument), what `:paste` says afterwards, and tab completion. Then all 45 shipped presets loaded by name in one session: none refused.
Diffstat (limited to 'contrib/shader-repl')
-rwxr-xr-xcontrib/shader-repl126
1 files changed, 118 insertions, 8 deletions
diff --git a/contrib/shader-repl b/contrib/shader-repl
index 4410a24..6b83e33 100755
--- a/contrib/shader-repl
+++ b/contrib/shader-repl
@@ -4,6 +4,8 @@
$ contrib/shader-repl
chrome> tint color="#ff5fd7" amount="abs(t / 8 % 510 - 255)"
ok
+ chrome> :load sine-comet
+ sine-comet.kdl: 1 chrome, 0 content
chrome> :paste
The line you type is a chain in exactly the syntax `config.kdl` uses, sent to the
@@ -19,21 +21,33 @@ of consent (D13).
Commands:
:chrome aim the next chain at the frame (the default)
:content aim it at the pane's contents
+ :load F a preset file: a path, or a name from `chrome/` or `shaders/`
+ beside this script. Its entries go wherever their own `where=`
+ says, so `:load sine-comet` fills the frame chain and
+ `:load built-ins-spotlight` fills the contents one
:clear take this pane's chain away
:both clear both chains
- :paste print what you have as a `shaders { }` block
+ :paste print what you have as a `shaders { }` block -- or, after a
+ `:load`, the one `include` line that keeps it
:help this list
:quit leave (the chain stays until the pane closes)
+`:load` hands the *path* to the session, which reads it with the parser that
+already knows this format. Nothing here parses KDL: a second reader of a config
+file is a second opinion about what it says, and comments and quoted strings are
+exactly where those two would differ.
+
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.
+shader names, the property keys and the expression language's own variables,
+constants and functions -- and filenames after `:load`, which is most of what
+there is to remember.
Python 3, standard library only.
"""
import atexit
+import glob
import os
import select
import sys
@@ -50,7 +64,8 @@ OSC = "\033]5577;1;"
ST = "\033\\"
TIMEOUT = 2.0
-COMMANDS = [":chrome", ":content", ":clear", ":both", ":paste", ":help", ":quit"]
+COMMANDS = [":chrome", ":content", ":load", ":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
@@ -75,10 +90,32 @@ HISTORY = os.path.join(
def complete(text, state):
- """One flat vocabulary. A shader name is only valid first and a variable only
+ """One flat vocabulary -- except after `:load`, where the answer is filenames.
+
+ A prompt that offers `amount=` where a path belongs is worse than one that
+ offers nothing, and readline hands over the whole line, so telling the two
+ apart costs one test. 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."""
+ line = readline.get_line_buffer() if readline else ""
+ if line.lstrip().startswith(":load"):
+ hits = []
+ for pat in (text + "*", os.path.join(PRESET_DIRS[0], text + "*"),
+ os.path.join(PRESET_DIRS[1], text + "*")):
+ for hit in sorted(glob.glob(pat)):
+ # Bare names for the presets that ship here, real paths otherwise:
+ # `:load sine-c<tab>` should finish the name, not spell out a
+ # directory nobody typed.
+ base = os.path.basename(hit)
+ hits.append(hit + "/" if os.path.isdir(hit)
+ else (base if os.path.dirname(pat) != "" else hit))
+ seen = []
+ for hit in hits:
+ if hit not in seen:
+ seen.append(hit)
+ return seen[state] if state < len(seen) else None
+
words = COMMANDS + SHADERS + PROPS + VALUES + VARS + CONSTS + FUNCS
hits = [w for w in words if w.startswith(text)]
return hits[state] if state < len(hits) else None
@@ -149,9 +186,55 @@ def ask(fd, where, chain):
return (answer.replace("error;", "", 1), False)
-def paste(chains):
+PRESET_DIRS = [os.path.join(os.path.dirname(os.path.abspath(__file__)), d)
+ for d in ("chrome", "shaders")]
+
+
+def resolve(arg):
+ """A path, or the name of a preset that ships with sl0ppty.
+
+ `:load contrib/chrome/sine-comet.kdl` and `:load sine-comet` are the same
+ thing, the second one from any directory: the preset folders sit next to this
+ script, so it can find them without being told where it was run from."""
+ tries = [arg, arg + ".kdl"]
+ for d in PRESET_DIRS:
+ tries += [os.path.join(d, arg), os.path.join(d, arg + ".kdl")]
+ for t in tries:
+ if os.path.isfile(t):
+ return os.path.abspath(t)
+ return None
+
+
+def load(fd, path):
+ """Hand the session a file and let it do the reading.
+
+ It has the parser -- comments, strings, `where=` routing, the lot -- and a
+ second reader here would be a second opinion about what a preset says. So this
+ resolves the name, makes the path absolute (the session's working directory is
+ not ours) and sends it."""
+ send("shader-load;%s" % path)
+ answer = reply(fd)
+ if answer is None:
+ termios.tcflush(fd, termios.TCIFLUSH)
+ return ("no answer -- is this pane inside sl0ppty?", False)
+ if answer.startswith("ok"):
+ _, _, detail = answer.partition(";")
+ return (detail or "ok", True)
+ return (answer.replace("error;", "", 1), False)
+
+
+def paste(chains, loaded=None):
"""What a config file would say. Two `where=` entries rather than two blocks,
- because one `shaders { }` holds both and that is the shape to copy."""
+ because one `shaders { }` holds both and that is the shape to copy.
+
+ After a `:load` the answer is one `include` line. The file is already on disk,
+ already a `shaders { }` block and already commented -- reprinting thirty lines
+ of it would scroll away the pane you are looking at, and rebuilding it from the
+ chains would lose the comments that say what the effect is. `include` is the
+ config's own way of saying "that file, as written".
+ """
+ if loaded:
+ return '// %s\ninclude "%s"' % (os.path.basename(loaded), loaded)
if not any(chains.values()):
return "shaders { } // nothing set"
lines = ["shaders {"]
@@ -174,6 +257,7 @@ def main():
saved = termios.tcgetattr(fd)
where = "chrome"
chains = {"chrome": "", "content": ""}
+ loaded = None # the preset file in force, for `:paste`
setup_readline()
print("shader-repl: a chain in config syntax, applied to this pane.")
@@ -202,7 +286,30 @@ def main():
where = line[1:]
continue
if line == ":paste":
- print(paste(chains))
+ print(paste(chains, loaded))
+ continue
+ if line.startswith(":load"):
+ arg = line[len(":load"):].strip()
+ if not arg:
+ print(" :load <file.kdl>, or the name of one in chrome/ or "
+ "shaders/ beside this script")
+ continue
+ path = resolve(arg)
+ if not path:
+ print(" no such file: %s" % arg)
+ continue
+ tty.setraw(fd)
+ try:
+ msg, ok = load(fd, path)
+ finally:
+ termios.tcsetattr(fd, termios.TCSADRAIN, saved)
+ if ok:
+ # The file replaces both chains, so the typed ones are gone
+ # and `:paste` should answer with the file rather than with
+ # something half true.
+ loaded = path
+ chains["chrome"] = chains["content"] = ""
+ print(" %s: %s" % (os.path.basename(path), msg))
continue
if line in (":help", ":h", "?"):
print(" " + " ".join(COMMANDS))
@@ -212,6 +319,7 @@ def main():
print(" consts: " + " ".join(CONSTS)
+ " (degrees, so PI is a half turn)")
print(" funcs: " + " ".join(f.rstrip("(") for f in FUNCS))
+ print(" :load takes a path, or a preset name from chrome/ or shaders/")
print(" tab completes all of it; up/down and ctrl-r are history")
continue
if line in (":clear", ":both"):
@@ -224,6 +332,7 @@ def main():
finally:
termios.tcsetattr(fd, termios.TCSADRAIN, saved)
if ok:
+ loaded = None
if target:
chains[target] = ""
else:
@@ -239,6 +348,7 @@ def main():
termios.tcsetattr(fd, termios.TCSADRAIN, saved)
if ok:
chains[where] = line
+ loaded = None # hand-typed now: `:paste` should say so
print(msg)
finally:
termios.tcsetattr(fd, termios.TCSADRAIN, saved)