first commit
This commit is contained in:
12
CHANGELOG.md
Normal file
12
CHANGELOG.md
Normal file
@@ -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.
|
||||||
215
README.md
Normal file
215
README.md
Normal file
@@ -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 <path>
|
||||||
|
- read -rp "Tryck Enter för att fortsätta..."
|
||||||
|
|
||||||
|
move:
|
||||||
|
command: mv -v <path> <dir>
|
||||||
|
```
|
||||||
|
|
||||||
|
Rules are evaluated from top to bottom; the first matching glob wins.
|
||||||
|
|
||||||
|
### Substitutions
|
||||||
|
|
||||||
|
| Token | Meaning |
|
||||||
|
|---|---|
|
||||||
|
| `<name>` | basename of the target |
|
||||||
|
| `<path>` | full path of the target |
|
||||||
|
| `<dir>` | 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 <path>
|
||||||
|
- 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`.
|
||||||
61
install.sh
Executable file
61
install.sh
Executable file
@@ -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" <<EOF2
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
exec python3 "$LIBDIR/tui.py" "\$@"
|
||||||
|
EOF2
|
||||||
|
chmod 755 "$BINDIR/tui.py"
|
||||||
|
|
||||||
|
# Load the Bash integration for every interactive Bash session. On Debian,
|
||||||
|
# the normal login startup path loads ~/.bashrc for interactive Bash shells.
|
||||||
|
touch "$BASHRC"
|
||||||
|
if ! grep -Fqx "$MARKER" "$BASHRC"; then
|
||||||
|
{
|
||||||
|
printf '\n%s\n' "$MARKER"
|
||||||
|
printf 'source "%s"\n' "$LIBDIR/tui-shell.bash"
|
||||||
|
printf '%s\n' "$ENDMARKER"
|
||||||
|
} >> "$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'
|
||||||
50
tui/example.tui
Normal file
50
tui/example.tui
Normal file
@@ -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 <path>
|
||||||
|
- read -rp "Tryck Enter för att fortsätta..."
|
||||||
|
copy:
|
||||||
|
command: cp -v <path> <dir>copy_<name>
|
||||||
|
move:
|
||||||
|
command: mv -v <path> <dir>
|
||||||
|
delete:
|
||||||
|
command: rm -v <path>
|
||||||
|
edit:
|
||||||
|
command: joe <path>
|
||||||
|
view:
|
||||||
|
command: less <path>
|
||||||
74
tui/tui-shell.bash
Normal file
74
tui/tui-shell.bash
Normal file
@@ -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
|
||||||
|
}
|
||||||
535
tui/tui.py
Executable file
535
tui/tui.py
Executable file
@@ -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):
|
||||||
|
# <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))
|
||||||
|
.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 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())
|
||||||
Reference in New Issue
Block a user