600 lines
18 KiB
Python
Executable File
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())
|