Files
tui/tui/tui.py
2026-10-12 22:07:53 +02:00

600 lines
18 KiB
Python
Executable File

#!/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=".", show_all=False):
self.home = Path.home().resolve()
self.path = Path(start_dir).expanduser().resolve()
self.show_all = show_all
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()
@property
def root(self):
"""Highest directory TUI may visit for the current configuration."""
return self.config.path.parent if self.config.path is not None else self.home
def load(self):
try:
entries = list(self.path.iterdir())
except OSError:
entries = []
if not self.show_all:
entries = [p for p in entries if not p.name.startswith(".")]
entries.sort(key=lambda p: (not p.is_dir(), p.name.casefold()))
self.entries = entries
# Cursor 0 is always the synthetic parent entry.
self.cursor = min(self.cursor, 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):
if self.cursor == 0:
return self.path.parent if self.path != self.root else None
index = self.cursor - 1
return self.entries[index] if 0 <= index < len(self.entries) else None
def current_entry_index(self):
return self.cursor - 1
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
if self.cursor == 0:
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.current_entry_index()
self._update_selection()
def mark_end(self):
if not self.entries:
return
# Like joe's Ctrl-K K, the end of the block is the line immediately
# above the cursor. The bottom empty line therefore selects the last
# object in the directory, while standing on an object selects the
# object immediately above it.
end = self.cursor - 2
if end < 0:
return
# K sets/replaces only the end endpoint. The start endpoint is kept.
self.selection_end = end
self._update_selection()
def move(self, delta):
self.cursor = max(0, min(len(self.entries) + 1, self.cursor + delta))
def enter(self):
if self.cursor == 0:
return self.parent()
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.root:
return True
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
]
selected = self.selected()
return [selected] if selected and selected != self.path.parent 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):
# <dir> deliberately includes a trailing separator so commands such
# as "<dir>copy_<name>" work naturally in .tui files.
directory = str(self.path)
if not directory.endswith(os.sep):
directory += os.sep
return (
command
.replace("<name>", target.name)
.replace("<path>", str(target.resolve()))
.replace("<dir>", 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 init_colors():
if not curses.has_colors():
return
curses.start_color()
try:
curses.use_default_colors()
except curses.error:
pass
# Close to the default GNU ls --color palette.
curses.init_pair(1, curses.COLOR_BLUE, -1) # directories
curses.init_pair(2, curses.COLOR_CYAN, -1) # symlinks
curses.init_pair(3, curses.COLOR_GREEN, -1) # executables
curses.init_pair(4, curses.COLOR_RED, -1) # archives
curses.init_pair(5, curses.COLOR_MAGENTA, -1) # images
curses.init_pair(6, curses.COLOR_YELLOW, -1) # devices / special
curses.init_pair(7, curses.COLOR_WHITE, -1) # parent
def entry_color(entry):
if entry is None:
return curses.color_pair(7) if curses.has_colors() else curses.A_NORMAL
try:
if entry.is_symlink():
pair = 2
elif entry.is_dir():
pair = 1
elif entry.is_file() and os.access(entry, os.X_OK):
pair = 3
else:
suffix = entry.suffix.lower()
if suffix in {'.tar', '.tgz', '.gz', '.bz2', '.xz', '.zip', '.7z', '.rar', '.zst'}:
pair = 4
elif suffix in {'.jpg', '.jpeg', '.png', '.gif', '.bmp', '.webp', '.svg', '.ico'}:
pair = 5
else:
pair = 0
except OSError:
pair = 0
return curses.color_pair(pair) if pair and curses.has_colors() else curses.A_NORMAL
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)
# Position 0 is the synthetic parent entry.
first = max(0, browser.cursor - visible + 1)
for row in range(visible):
index = first + row
if index > len(browser.entries) + 1:
break
if index == 0:
entry = browser.path.parent if browser.path != browser.root else None
text = "../" if entry is not None else "./"
attrs = entry_color(entry) | curses.A_BOLD
is_marked = False
elif index <= len(browser.entries):
entry = browser.entries[index - 1]
is_marked = entry.resolve() in browser.marked
attrs = entry_color(entry)
if is_marked:
attrs |= curses.A_REVERSE
text = entry.name + ("/" if entry.is_dir() else "")
else:
# One empty line after the final object, matching the cursor range.
entry = None
is_marked = False
attrs = curses.A_NORMAL
text = ""
if index == browser.cursor:
attrs |= curses.A_UNDERLINE
stdscr.addnstr(row + 1, 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=".", show_all=False):
curses.curs_set(0)
stdscr.keypad(True)
try:
curses.use_default_colors()
except curses.error:
pass
init_colors()
try:
browser = FileBrowser(start_dir, show_all=show_all)
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
# Left is navigation, not an action: first move the cursor to the
# parent entry; pressing left again enters that parent. This also
# makes the parent entry visible at the top of every directory.
if key == curses.KEY_LEFT:
if browser.cursor == 0:
if browser.parent() is False:
return
else:
browser.cursor = 0
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("-a", "--all", action="store_true",
help="show hidden files and directories")
parser.add_argument("directory", nargs="?", default=".")
args = parser.parse_args()
try:
result = curses.wrapper(run, args.directory, args.all)
except KeyboardInterrupt:
return 0
return SHELL_HANDOFF if result == SHELL_HANDOFF else 0
if __name__ == "__main__":
raise SystemExit(main())