mirror of
https://github.com/anotherhadi/ilovetui.git
synced 2026-08-21 12:05:49 +02:00
init layout, notifications, tabs & more
Signed-off-by: Hadi <112569860+anotherhadi@users.noreply.github.com>
This commit is contained in:
@@ -15,6 +15,7 @@ in
|
||||
[
|
||||
go
|
||||
doctoc
|
||||
(python3.withPackages (ps: [ps.pyte]))
|
||||
]
|
||||
++ hooks.enabledPackages;
|
||||
|
||||
|
||||
@@ -0,0 +1,192 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Run a TUI binary in a virtual terminal (via pyte) and print its rendered
|
||||
screen as plain text, for visual verification of bubbletea/lipgloss output.
|
||||
|
||||
Dev tooling only, provided by the nix-shell. Not part of the Go module.
|
||||
|
||||
Usage:
|
||||
tui-snapshot.py [--width W] [--height H] [--wait SECONDS] [--keys 'k:delay,...'] -- <cmd> [args...]
|
||||
|
||||
Example:
|
||||
tui-snapshot.py --keys 'l:0.2,l:0.2,q:0' -- go run ./examples/tabs
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import fcntl
|
||||
import os
|
||||
import pty
|
||||
import select
|
||||
import struct
|
||||
import sys
|
||||
import termios
|
||||
import time
|
||||
|
||||
import pyte
|
||||
|
||||
class RepeatAwareScreen(pyte.Screen):
|
||||
"""pyte has no support for the ECMA-48 REP sequence (``CSI Ps b``,
|
||||
"repeat the preceding graphic character Ps times"), which bubbletea's
|
||||
renderer uses to compress runs of identical styled cells (e.g. long
|
||||
border/padding runs). Left unhandled, pyte silently drops it, corrupting
|
||||
the frame. This adds the missing handler."""
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self._last_drawn = " "
|
||||
|
||||
def draw(self, data):
|
||||
super().draw(data)
|
||||
if data:
|
||||
self._last_drawn = data[-1]
|
||||
|
||||
def repeat_last_character(self, count=1):
|
||||
self.draw(self._last_drawn * (count or 1))
|
||||
|
||||
|
||||
class RepeatAwareStream(pyte.Stream):
|
||||
"""Also works around a pyte bug: it maps ECMA-48 HPA (Character Position
|
||||
Absolute, "move cursor to column Ps") to "'" (apostrophe, 0x27) instead
|
||||
of the actual standard character "`" (backtick, 0x60) - see pyte's
|
||||
escape.py. Real terminal apps send the correct backtick, which pyte then
|
||||
silently no-ops on since it's not in its dispatch table, leaving the
|
||||
cursor stuck instead of moving it. bubbletea's renderer uses HPA (mixed
|
||||
with CUF) to jump the cursor to a border's column after erasing padding
|
||||
with ECH, so without this the trailing border character gets drawn right
|
||||
after the leading one instead of at the far edge."""
|
||||
|
||||
csi = {
|
||||
**pyte.Stream.csi,
|
||||
"b": "repeat_last_character",
|
||||
"`": "cursor_to_column",
|
||||
}
|
||||
|
||||
|
||||
KEYMAP = {
|
||||
"enter": "\r",
|
||||
"esc": "\x1b",
|
||||
"tab": "\t",
|
||||
"up": "\x1b[A",
|
||||
"down": "\x1b[B",
|
||||
"right": "\x1b[C",
|
||||
"left": "\x1b[D",
|
||||
"space": " ",
|
||||
}
|
||||
|
||||
|
||||
def encode_key(key):
|
||||
"""Resolve a --keys token to the bytes to write to the pty. Falls back to
|
||||
the literal string for a plain character (e.g. 'l'), but without this,
|
||||
something like 'ctrl+l' would be sent as the 6 literal characters
|
||||
'c','t','r','l','+','l' instead of the single 0x0C control byte a real
|
||||
terminal would send for Ctrl-L - silently testing the wrong thing."""
|
||||
if key in KEYMAP:
|
||||
return KEYMAP[key]
|
||||
if key.startswith("ctrl+") and len(key) == 6 and key[5].isalpha():
|
||||
return chr(ord(key[5].lower()) - ord("a") + 1)
|
||||
return key
|
||||
|
||||
|
||||
def set_size(fd, rows, cols):
|
||||
fcntl.ioctl(fd, termios.TIOCSWINSZ, struct.pack("HHHH", rows, cols, 0, 0))
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--width", type=int, default=80)
|
||||
parser.add_argument("--height", type=int, default=24)
|
||||
parser.add_argument(
|
||||
"--wait", type=float, default=1.0, help="seconds to let the app render before the snapshot"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--keys",
|
||||
default="",
|
||||
help="comma-separated key:delay pairs sent before the final snapshot, e.g. 'l:0.2,l:0.2,q:0'",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--colors",
|
||||
action="store_true",
|
||||
help="also print, per row, the foreground color of each non-blank cell (for border/color bugs "
|
||||
"that don't show up in the plain-text dump)",
|
||||
)
|
||||
parser.add_argument("cmd", nargs=argparse.REMAINDER)
|
||||
args = parser.parse_args()
|
||||
|
||||
cmd = args.cmd
|
||||
if cmd and cmd[0] == "--":
|
||||
cmd = cmd[1:]
|
||||
if not cmd:
|
||||
parser.error("missing command to run")
|
||||
|
||||
screen = RepeatAwareScreen(args.width, args.height)
|
||||
stream = RepeatAwareStream(screen)
|
||||
|
||||
# Open the pty and set its size *before* forking, so the child never
|
||||
# observes a stale/default size on its first render (pyte has no way to
|
||||
# recover from a corrupted initial frame drawn at the wrong width).
|
||||
master_fd, slave_fd = pty.openpty()
|
||||
set_size(slave_fd, args.height, args.width)
|
||||
|
||||
pid = os.fork()
|
||||
if pid == 0:
|
||||
os.close(master_fd)
|
||||
os.setsid()
|
||||
fcntl.ioctl(slave_fd, termios.TIOCSCTTY, 0)
|
||||
os.dup2(slave_fd, 0)
|
||||
os.dup2(slave_fd, 1)
|
||||
os.dup2(slave_fd, 2)
|
||||
os.close(slave_fd)
|
||||
os.execvp(cmd[0], cmd)
|
||||
os._exit(1)
|
||||
|
||||
os.close(slave_fd)
|
||||
fd = master_fd
|
||||
|
||||
def pump(duration):
|
||||
end = time.time() + duration
|
||||
while True:
|
||||
remaining = end - time.time()
|
||||
if remaining <= 0:
|
||||
break
|
||||
r, _, _ = select.select([fd], [], [], remaining)
|
||||
if fd not in r:
|
||||
continue
|
||||
try:
|
||||
data = os.read(fd, 65536)
|
||||
except OSError:
|
||||
break
|
||||
if not data:
|
||||
break
|
||||
stream.feed(data.decode(errors="ignore"))
|
||||
|
||||
pump(args.wait)
|
||||
|
||||
for pair in filter(None, args.keys.split(",")):
|
||||
key, _, delay = pair.partition(":")
|
||||
try:
|
||||
os.write(fd, encode_key(key).encode())
|
||||
except OSError:
|
||||
break
|
||||
pump(float(delay) if delay else 0.3)
|
||||
|
||||
try:
|
||||
os.kill(pid, 15)
|
||||
os.waitpid(pid, 0)
|
||||
except (ProcessLookupError, ChildProcessError):
|
||||
pass
|
||||
|
||||
for y, line in enumerate(screen.display):
|
||||
print(line.rstrip())
|
||||
if args.colors:
|
||||
row = screen.buffer[y]
|
||||
cells = []
|
||||
for x in sorted(row):
|
||||
ch = row[x]
|
||||
if ch.data.strip():
|
||||
fg = ch.fg if ch.fg != "default" else "-"
|
||||
cells.append(f"{x}:{ch.data!r}:{fg}")
|
||||
if cells:
|
||||
print(" " + " ".join(cells))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user