From cdab8765f49f9f084dbbfb4305c1815cf7197804 Mon Sep 17 00:00:00 2001 From: Joakim Date: Thu, 24 Sep 2026 22:05:19 +0200 Subject: [PATCH] first commit --- CHANGELOG.md | 12 + README.md | 215 ++++++++++++++++++ install.sh | 61 ++++++ tui/example.tui | 50 +++++ tui/tui-shell.bash | 74 +++++++ tui/tui.py | 535 +++++++++++++++++++++++++++++++++++++++++++++ 6 files changed, 947 insertions(+) create mode 100644 CHANGELOG.md create mode 100644 README.md create mode 100755 install.sh create mode 100644 tui/example.tui create mode 100644 tui/tui-shell.bash create mode 100755 tui/tui.py diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..03f9de4 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,12 @@ +# Changelog + +## Current + +- Bash shell integration is installed automatically through `~/.bashrc`. +- Multi-command actions execute as one TUI handoff; TUI is not redrawn between commands. +- Each generated command is added to Bash history. +- Failed commands stop the current action and pause on a `read` before TUI resumes. +- Marks are stored as absolute paths and survive directory changes. +- Returning to a directory shows its previously marked entries again. +- Moving marked entries uses the current TUI directory as the destination. +- `.tui` lookup is limited to the user's home directory. diff --git a/README.md b/README.md new file mode 100644 index 0000000..cd66b60 --- /dev/null +++ b/README.md @@ -0,0 +1,215 @@ +# TUI + +A small keyboard-driven terminal UI for browsing directories and executing +ordinary shell commands defined in `.tui` files. + +The important design choice is that **TUI does not execute actions inside a +Python subprocess**. It hands the generated commands back to the Bash process +that started `tui`. This keeps the commands visible in Bash history and gives +actions the normal shell environment. + +## Requirements + +- Debian/Linux with Bash +- Python 3 +- PyYAML (`python3-yaml` on Debian) +- `curses` from the normal Python installation + +Install the Debian dependency if necessary: + +```bash +sudo apt install python3-yaml +``` + +## Installation + +Unpack the project and run: + +```bash +./install.sh +``` + +The installer installs TUI under: + +```text +~/.local/lib/tui/ +``` + +and adds the shell integration to: + +```text +~/.bashrc +``` + +The integration is therefore loaded automatically for interactive Bash +sessions, including the normal Debian login/terminal startup path. + +After installation, start a new terminal/login session, or run: + +```bash +source ~/.bashrc +``` + +Then: + +```bash +tui +``` + +The installer does not overwrite an existing `~/.tui`. + +## `.tui` configuration + +TUI searches for the nearest `.tui` starting in the current directory and +walking upwards, but never above `$HOME`. + +A configuration contains `keys` and `actions`: + +```yaml +keys: + up: up + down: down + left: parent + right: + folder: enter + file: + - "*.zip": list-zip + + ctrl-k: + b: mark-begin + k: mark-end + m: + file: + - "*": move + +actions: + list-zip: + command: + - unzip -l + - read -rp "Tryck Enter för att fortsätta..." + + move: + command: mv -v +``` + +Rules are evaluated from top to bottom; the first matching glob wins. + +### Substitutions + +| Token | Meaning | +|---|---| +| `` | basename of the target | +| `` | full path of the target | +| `` | directory currently displayed by TUI, with trailing `/` | + +## Navigation + +The default example configuration uses: + +- `↑` / `↓` — move cursor +- `Enter` / `→` — execute the configured action +- `←` — parent directory +- `Ctrl-K B` — set selection start +- `Ctrl-K K` — set selection end +- `Ctrl-K C` — copy marked files/folders +- `Ctrl-K M` — move marked files +- `Ctrl-K Y` — delete marked files/folders +- `Ctrl-C` — quit + +## Marking + +Marks are stored as absolute paths and belong to the TUI session, not to the +current directory. + +For example: + +```text +/home/joakim/notes/ + A.txt * + B.txt * + work/ +``` + +Entering `work/` does not clear the marks. Returning to the parent directory +shows `A.txt` and `B.txt` as marked again. + +Marks in different directories can coexist. If `A.txt` is marked, then TUI +is moved to `work/`, `Ctrl-K M` operates on the original absolute path while +using the **current directory** as the destination: + +```bash +mv -v /home/joakim/notes/A.txt . +``` + +A mark is removed automatically only when its target no longer exists. + +`Ctrl-K B` and `Ctrl-K K` define a contiguous selection in the directory being +viewed. Changing directory clears the temporary selection endpoints but does +not clear the already marked paths. + +## Shell integration + +When an action is triggered, TUI writes the generated commands to a temporary +file and exits with a private handoff status. The `tui()` Bash function then +executes them. + +For example: + +```yaml +actions: + list-zip: + command: + - unzip -l + - read -rp "Tryck Enter för att fortsätta..." +``` + +The flow is: + +```text +TUI + ↓ +unzip -l archive.zip + ↓ +read -rp "Tryck Enter..." + ↓ +TUI +``` + +TUI is **not redrawn between commands belonging to the same action**. + +Every command is also inserted into Bash history. If one command fails, the +remaining commands in that action are not executed. TUI prints the exit code +and waits for: + +```text +Tryck Enter för att fortsätta... +``` + +before returning to TUI, so an error cannot disappear immediately. + +The shell wrapper uses `pushd`/`popd` so the command runs in the directory +shown by TUI without permanently changing the user's Bash working directory. + +## Security note + +Action strings are intentionally ordinary Bash commands and are executed with +`eval` by the user's Bash shell. `.tui` files should therefore be treated as +executable configuration and only used from directories/configurations you +trust. + +## Development + +Run a syntax check with: + +```bash +python3 -m py_compile tui/tui.py +``` + +For development without installing: + +```bash +source tui/tui-shell.bash +tui +``` + +The example configuration is in `tui/example.tui`. diff --git a/install.sh b/install.sh new file mode 100755 index 0000000..8d7c8f0 --- /dev/null +++ b/install.sh @@ -0,0 +1,61 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PREFIX="${HOME}/.local" +LIBDIR="${PREFIX}/lib/tui" +BINDIR="${PREFIX}/bin" +BASHRC="${HOME}/.bashrc" +MARKER="# >>> tui shell integration >>>" +ENDMARKER="# <<< tui shell integration <<<" + +if ! command -v python3 >/dev/null 2>&1; then + echo "install.sh: python3 is required" >&2 + exit 1 +fi + +if ! python3 - <<'PY' +try: + import yaml +except ImportError: + raise SystemExit(1) +PY +then + echo "install.sh: PyYAML is required." >&2 + echo "Install it with: sudo apt install python3-yaml" >&2 + exit 1 +fi + +mkdir -p "$LIBDIR" "$BINDIR" +install -m 755 "$SCRIPT_DIR/tui/tui.py" "$LIBDIR/tui.py" +install -m 644 "$SCRIPT_DIR/tui/tui-shell.bash" "$LIBDIR/tui-shell.bash" + +# Optional standalone entry point. The normal command is the Bash function +# loaded from tui-shell.bash; this wrapper is useful for diagnostics. +cat > "$BINDIR/tui.py" <> "$BASHRC" +fi + +# Install an example configuration only if the user does not already have +# one in the current directory. .tui files are deliberately project-local. +if [[ ! -e "$HOME/.tui" && -f "$SCRIPT_DIR/tui/example.tui" ]]; then + cp "$SCRIPT_DIR/tui/example.tui" "$HOME/.tui" + echo "Installed example configuration: ~/.tui" +fi + +printf '\nTUI installed to %s\n' "$LIBDIR" +printf 'Bash integration installed in %s\n' "$BASHRC" +printf '\nStart a new login/terminal session, or run:\n source ~/.bashrc\n\nThen:\n tui\n' diff --git a/tui/example.tui b/tui/example.tui new file mode 100644 index 0000000..8a66651 --- /dev/null +++ b/tui/example.tui @@ -0,0 +1,50 @@ +keys: + up: up + down: down + left: parent + + right: + folder: enter + file: + - "*.zip": list-zip + - "*.md": view + + enter: + folder: enter + file: + - "*.md": edit + - "*.txt": edit + - "*": view + + ctrl-k: + b: mark-begin + k: mark-end + c: + file: + - "*": copy + folder: + - "*": copy + m: + file: + - "*": move + y: + file: + - "*": delete + folder: + - "*": delete + +actions: + list-zip: + command: + - unzip -l + - read -rp "Tryck Enter för att fortsätta..." + copy: + command: cp -v copy_ + move: + command: mv -v + delete: + command: rm -v + edit: + command: joe + view: + command: less diff --git a/tui/tui-shell.bash b/tui/tui-shell.bash new file mode 100644 index 0000000..9e1cd59 --- /dev/null +++ b/tui/tui-shell.bash @@ -0,0 +1,74 @@ +# Bash integration for TUI. +# This file is sourced automatically by the installer from ~/.bashrc. +# Actions are executed by this Bash process, not by a child shell in Python. + +tui() { + local tui_python + tui_python="${TUI_PYTHON:-$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/tui.py}" + + if [[ ! -f "$tui_python" ]]; then + echo "tui: cannot find $tui_python" >&2 + return 127 + fi + + local tui_dir="${1:-.}" + local command_file + local cwd_file + local rc + local cmd + local tui_cwd + + command_file="$(mktemp)" + cwd_file="$(mktemp)" + + while :; do + : > "$command_file" + : > "$cwd_file" + + TUI_COMMAND_FILE="$command_file" \ + TUI_CWD_FILE="$cwd_file" \ + python3 "$tui_python" "$tui_dir" + rc=$? + + if (( rc != 42 )); then + rm -f "$command_file" "$cwd_file" + return "$rc" + fi + + tui_cwd="$(<"$cwd_file")" + + if [[ -z "$tui_cwd" || ! -d "$tui_cwd" ]]; then + echo "tui: invalid TUI working directory" >&2 + rm -f "$command_file" "$cwd_file" + return 1 + fi + + # Execute all commands belonging to one TUI action before starting + # TUI again. Each command remains a normal Bash command and gets its + # own history entry. If a command fails, stop the action and pause so + # the user has time to see the error. + pushd "$tui_cwd" >/dev/null || { + rm -f "$command_file" "$cwd_file" + return 1 + } + + while IFS= read -r cmd; do + [[ -z "$cmd" ]] && continue + + history -s -- "$cmd" + eval "$cmd" + rc=$? + + if (( rc != 0 )); then + printf '\nTUI: command failed (exit %d)\n' "$rc" >&2 + read -rp 'Tryck Enter för att fortsätta...' _ + break + fi + done < "$command_file" + + popd >/dev/null || true + + # Re-enter TUI at the directory in which the action was created. + tui_dir="$tui_cwd" + done +} diff --git a/tui/tui.py b/tui/tui.py new file mode 100755 index 0000000..63d010e --- /dev/null +++ b/tui/tui.py @@ -0,0 +1,535 @@ +#!/usr/bin/env python3 +import argparse +import curses +import fnmatch +import os +import subprocess +from pathlib import Path + +SHELL_HANDOFF = 42 + +try: + import yaml +except ImportError: + yaml = None + + +class TuiConfig: + """Load the nearest .tui, but never above the user's home directory.""" + + def __init__(self, directory, home=None): + self.directory = Path(directory).expanduser().resolve() + self.home = Path(home or Path.home()).expanduser().resolve() + self.path = None + self.data = {} + self.load() + + def find_config(self): + current = self.directory + + try: + current.relative_to(self.home) + except ValueError: + return None + + while True: + candidate = current / ".tui" + if candidate.is_file(): + return candidate + + if current == self.home or current.parent == current: + return None + + current = current.parent + + def load(self): + self.path = self.find_config() + + if yaml is None or self.path is None: + self.data = {} + return + + try: + with self.path.open("r", encoding="utf-8") as f: + self.data = yaml.safe_load(f) or {} + except Exception: + self.data = {} + + def key_binding(self, sequence): + """Return the raw binding below keys: for a key sequence.""" + node = self.data.get("keys", {}) + + for key in sequence: + if not isinstance(node, dict) or key not in node: + return None + node = node[key] + + return node + + @staticmethod + def _match_rules(rules, name): + """Return the first matching action from an ordered rule list.""" + if not isinstance(rules, list): + return None + + for rule in rules: + if not isinstance(rule, dict) or len(rule) != 1: + continue + + pattern, action = next(iter(rule.items())) + if fnmatch.fnmatchcase(name, str(pattern)): + return str(action) + + return None + + def resolve_binding(self, binding, target): + """Resolve a key binding against the selected target. + + A binding may be: + action + {file: action, folder: action} + {file: [{pattern: action}, ...], folder: ...} + """ + if binding is None: + return None + + if isinstance(binding, str): + return binding + + if not isinstance(binding, dict) or target is None: + return None + + kind = "folder" if target.is_dir() else "file" + value = binding.get(kind) + + if isinstance(value, str): + return value + + return self._match_rules(value, target.name) + + def action_definition(self, action): + actions = self.data.get("actions", {}) + definition = actions.get(action) + + if isinstance(definition, str): + return {"commands": [definition], "pause": False} + + if not isinstance(definition, dict): + return None + + command = definition.get("command") + commands = definition.get("commands", command) + + if isinstance(commands, str): + commands = [commands] + elif isinstance(commands, list): + commands = [str(cmd) for cmd in commands] + else: + commands = [] + + return { + "commands": commands, + "pause": bool(definition.get("pause", False)), + } + + +class FileBrowser: + def __init__(self, start_dir="."): + self.home = Path.home().resolve() + self.path = Path(start_dir).expanduser().resolve() + + if not self.path.is_dir(): + raise ValueError(f"Not a directory: {self.path}") + + try: + self.path.relative_to(self.home) + except ValueError: + raise ValueError( + f"TUI can only be used inside the user's home directory: {self.home}" + ) + + self.entries = [] + self.cursor = 0 + self.marked = set() + self.selection_start = None + self.selection_end = None + self.config = TuiConfig(self.path, self.home) + + if self.config.path is None: + raise ValueError(f"No .tui found at or above: {self.path}") + + self.load() + + def load(self): + try: + self.entries = list(self.path.iterdir()) + except OSError: + self.entries = [] + + self.entries.sort( + key=lambda p: (not p.is_dir(), p.name.casefold()) + ) + + self.cursor = min( + self.cursor, + max(0, len(self.entries) - 1), + ) + + # Marks are global absolute paths, so they survive directory changes. + # Drop marks only for objects that no longer exist. + self.marked = {p for p in self.marked if p.exists()} + + self.config = TuiConfig(self.path, self.home) + return self.config.path is not None + + def selected(self): + return self.entries[self.cursor] if self.entries else None + + def _update_selection(self): + """Update the current directory's range without touching other marks.""" + if self.selection_start is None or self.selection_end is None: + return + + # Selection endpoints are local to the current directory, while + # ``marked`` is global to the TUI session. Update only the entries + # visible in this directory so marks in other directories survive. + current_paths = {entry.resolve() for entry in self.entries} + self.marked.difference_update(current_paths) + + start = min(self.selection_start, self.selection_end) + end = max(self.selection_start, self.selection_end) + + # An inverted range deliberately means no selection in this directory. + if self.selection_start > self.selection_end: + return + + self.marked.update( + entry.resolve() + for entry in self.entries[start:end + 1] + if entry.exists() + ) + + def mark_begin(self): + if not self.entries: + return + + # B sets/replaces only the start endpoint. The end endpoint is kept, + # so the existing selection can be expanded or reduced. + self.selection_start = self.cursor + self._update_selection() + + def mark_end(self): + if not self.entries: + return + + # K sets/replaces only the end endpoint. The start endpoint is kept. + self.selection_end = self.cursor + self._update_selection() + + def move(self, delta): + if self.entries: + self.cursor = max( + 0, + min(len(self.entries) - 1, self.cursor + delta), + ) + + def enter(self): + entry = self.selected() + + if entry and entry.is_dir(): + self.path = entry.resolve() + self.cursor = 0 + self.selection_start = None + self.selection_end = None + return self.load() + + return True + + def parent(self): + if self.path == self.home: + return False + + self.path = self.path.parent + self.cursor = 0 + self.selection_start = None + self.selection_end = None + return self.load() + + def targets(self, marked_only=False): + if marked_only: + return [ + p for p in self.marked + if p.exists() + ] + + if self.marked: + return [ + p for p in self.entries + if p.resolve() in self.marked + ] + + return [self.selected()] if self.selected() else [] + + def resolve_key_action(self, sequence, target=None): + binding = self.config.key_binding(sequence) + return self.config.resolve_binding(binding, target) + + def _command_for_target(self, command, target): + # deliberately includes a trailing separator so commands such + # as "copy_" work naturally in .tui files. + directory = str(self.path) + if not directory.endswith(os.sep): + directory += os.sep + + return ( + command + .replace("", target.name) + .replace("", str(target)) + .replace("", directory) + ) + + def execute_action(self, action, stdscr, marked_only=False): + """Render normal shell commands and hand them to the parent shell wrapper.""" + targets = self.targets(marked_only=marked_only) + if not targets: + return True + + definition = self.config.action_definition(action) + if definition is None or not definition["commands"]: + return True + + command_file = os.environ.get("TUI_COMMAND_FILE") + cwd_file = os.environ.get("TUI_CWD_FILE") + + if not command_file or not cwd_file: + # Direct invocation without the shell integration wrapper. + curses.def_prog_mode() + curses.endwin() + try: + print( + "TUI is not running through the shell integration wrapper.", + flush=True, + ) + print("Source tui-shell.bash and run: tui", flush=True) + finally: + curses.reset_prog_mode() + stdscr.clear() + stdscr.refresh() + return True + + commands = [] + for target in targets: + for command_template in definition["commands"]: + commands.append( + self._command_for_target(command_template, target) + ) + + if not commands: + return True + + # Persist the current TUI directory so the shell wrapper can execute + # commands in the same shell, but with the TUI's current directory. + Path(cwd_file).write_text(str(self.path), encoding="utf-8") + Path(command_file).write_text( + "\n".join(commands) + "\n", + encoding="utf-8", + ) + + # Show exactly what will be handed to the shell before leaving curses. + height, width = stdscr.getmaxyx() + stdscr.addnstr( + height - 1, + 0, + "> " + commands[0], + max(0, width - 1), + ) + stdscr.refresh() + + return SHELL_HANDOFF + + + +def draw(stdscr, browser, status=""): + stdscr.erase() + + height, width = stdscr.getmaxyx() + + title = f" tui {browser.path} " + stdscr.addnstr( + 0, + 0, + title, + max(0, width - 1), + curses.A_BOLD, + ) + + visible = max(1, height - 3) + first = max(0, browser.cursor - visible + 1) + + for row, entry in enumerate( + browser.entries[first:first + visible], + start=1, + ): + index = first + row - 1 + + is_marked = entry.resolve() in browser.marked + attrs = curses.A_BOLD if entry.is_dir() else curses.A_NORMAL + + if is_marked: + # Marked items are visually filled/inverted. + attrs |= curses.A_REVERSE + + name = entry.name + ("/" if entry.is_dir() else "") + text = name + + # The cursor is an underline rather than a reverse-video row. This + # keeps the current item distinct from the selected/marked items. + if index == browser.cursor: + attrs |= curses.A_UNDERLINE + + stdscr.addnstr( + row, + 0, + text, + max(0, width - 1), + attrs, + ) + + prompt = status or "> " + stdscr.addnstr( + height - 1, + 0, + prompt, + max(0, width - 1), + curses.A_DIM, + ) + + stdscr.refresh() + + +def read_ctrl_k(stdscr, browser): + height, width = stdscr.getmaxyx() + + stdscr.addnstr( + height - 1, + 0, + " Ctrl-K: ", + max(0, width - 1), + curses.A_DIM, + ) + stdscr.refresh() + + key = stdscr.getch() + + if 0 <= key < 256: + char = chr(key).lower() + action = browser.resolve_key_action( + ["ctrl-k", char], + browser.selected(), + ) + + if action: + return action, f"Ctrl-K {char.upper()} → {action}" + + return None, "Unknown Ctrl-K command" + + +def run(stdscr, start_dir="."): + curses.curs_set(0) + stdscr.keypad(True) + + try: + curses.use_default_colors() + except curses.error: + pass + + try: + browser = FileBrowser(start_dir) + except ValueError as exc: + # Let curses.wrapper restore the terminal, then report the reason. + raise SystemExit(str(exc)) + + status = "" + + def configured_action(key): + if key == curses.KEY_UP: + return browser.resolve_key_action(["up"], browser.selected()) + if key == curses.KEY_DOWN: + return browser.resolve_key_action(["down"], browser.selected()) + if key == curses.KEY_LEFT: + return browser.resolve_key_action(["left"], browser.selected()) + if key == curses.KEY_RIGHT: + return browser.resolve_key_action(["right"], browser.selected()) + if key in (curses.KEY_ENTER, 10, 13): + return browser.resolve_key_action(["enter"], browser.selected()) + + # Common Linux/curses key codes for Ctrl-Up / Ctrl-Down. + if key == 566: + return browser.resolve_key_action(["ctrl-up"], browser.selected()) + if key == 525: + return browser.resolve_key_action(["ctrl-down"], browser.selected()) + + return None + + while True: + draw(stdscr, browser, status) + status = "" + + key = stdscr.getch() + + if key == 11: # Ctrl-K + action, message = read_ctrl_k(stdscr, browser) + + if action == "mark-begin": + browser.mark_begin() + elif action == "mark-end": + browser.mark_end() + elif action: + result = browser.execute_action( + action, + stdscr, + marked_only=True, + ) + if result == SHELL_HANDOFF: + return SHELL_HANDOFF + if result is False: + return + else: + status = message + continue + + action = configured_action(key) + + if action == "up": + browser.move(-1) + elif action == "down": + browser.move(1) + elif action == "enter": + if browser.enter() is False: + return + elif action == "parent": + if browser.parent() is False: + return + elif action: + result = browser.execute_action(action, stdscr) + if result == SHELL_HANDOFF: + return SHELL_HANDOFF + if result is False: + return + + +def main(): + parser = argparse.ArgumentParser() + parser.add_argument("directory", nargs="?", default=".") + args = parser.parse_args() + + try: + result = curses.wrapper(run, args.directory) + except KeyboardInterrupt: + return 0 + + return SHELL_HANDOFF if result == SHELL_HANDOFF else 0 + + +if __name__ == "__main__": + raise SystemExit(main())