This commit is contained in:
Joakim
2026-10-13 10:48:28 +02:00
parent e1836a38e1
commit 6e7b3cb7ed
5 changed files with 364 additions and 16 deletions

View File

@@ -30,3 +30,13 @@
- Fixed Ctrl+C exit so the parent Bash process changes to the directory where TUI was closed. - Fixed Ctrl+C exit so the parent Bash process changes to the directory where TUI was closed.
- TUI now uses a separate exit status for normal Ctrl+C exit versus shell-action handoff. - TUI now uses a separate exit status for normal Ctrl+C exit versus shell-action handoff.
- Shell actions are read into an array before execution, so commands containing `read`, `&&`, and `||` receive the real terminal stdin and behave like normal Bash commands. - Shell actions are read into an array before execution, so commands containing `read`, `&&`, and `||` receive the real terminal stdin and behave like normal Bash commands.
## 3.0.0
- Added data-driven action menu with `M`.
- Added `A` toggle for hidden files and `Q` to quit; these are defined in `.tui` as normal key bindings.
- Menu entries, labels, shortcuts, conditions and actions are defined in `.tui`.
- Added generic menu conditions including `git`, `file`, `folder`, `marked`, `multiple`, `exists:...`, `missing:...` and boolean `all`/`any`/`not` expressions.
- Added `target: none` actions for directory-level operations that do not operate on a selected file.
- Added Git menu examples: status, pull, diff, log, add, commit and push.
- `A` state is preserved when returning from a shell action.

View File

@@ -232,3 +232,44 @@ The example configuration is in `tui/example.tui`.
Use `tui -v` or `tui --version` to verify the installed TUI version. Use `tui -v` or `tui --version` to verify the installed TUI version.
## v3 menu
Press `M` to open the action menu. The menu is generated entirely from the current `.tui` file, so a directory can provide its own context-specific operations.
The example configuration defines ordinary file operations and Git operations that appear when the current directory is inside a Git worktree.
The top-level keys are also configurable in `.tui`:
```yaml
keys:
a: toggle-all
q: quit
m: menu
```
Menu entries support:
```yaml
menu:
- label: "Git: pull"
key: p
action: git-pull
selection: none
when: git
```
`selection` can be `auto`, `current`, `marked` or `none`.
Available generic `when` conditions include `always`, `git`, `not-git`, `file`, `folder`, `marked`, `multiple`, `exists:<path>`, `missing:<path>`, `glob:<pattern>`, plus `all`, `any` and `not` expressions.
Actions that operate on the current directory rather than a file can use:
```yaml
actions:
git-pull:
target: none
command: git pull
```
This keeps the Python code generic: menu presentation, labels, shortcuts, conditions and shell commands belong in `.tui`.

View File

@@ -1,7 +1,13 @@
# TUI configuration. The Python program only provides the generic UI engine;
# actions, labels, shortcuts and context-specific menu entries live here.
keys: keys:
up: up up: up
down: down down: down
left: parent left: parent
a: toggle-all
q: quit
m: menu
right: right:
folder: enter folder: enter
@@ -33,18 +39,126 @@ keys:
folder: folder:
- "*": delete - "*": delete
# M opens this menu. The menu is entirely data-driven.
#
# selection: auto -> marked objects if there are marks, otherwise current object
# selection: current -> always current object
# selection: marked -> only when there is a marked selection
# selection: none -> action runs in the current directory, without a target
#
# when: git means this directory is inside a Git worktree.
menu:
- label: "Visa"
key: v
action: view
when: file
- label: "Redigera"
key: e
action: edit
when: file
- separator: true
- label: "Kopiera"
key: c
action: copy
selection: auto
- label: "Flytta"
key: m
action: move
selection: auto
- label: "Ta bort"
key: d
action: delete
selection: auto
- separator: true
- label: "Git: status"
action: git-status
selection: none
when: git
- label: "Git: pull"
key: p
action: git-pull
selection: none
when: git
- label: "Git: diff"
action: git-diff
selection: none
when: git
- label: "Git: log"
action: git-log
selection: none
when: git
- label: "Git: add -A"
action: git-add
selection: none
when: git
- label: "Git: commit"
action: git-commit
selection: none
when: git
- label: "Git: push"
action: git-push
selection: none
when: git
actions: actions:
list-zip: list-zip:
command: command:
- unzip -l <path> - unzip -l <path>
- read -rp "Tryck Enter för att fortsätta..." - read -rp "Tryck Enter för att fortsätta..."
copy: copy:
command: cp -v <path> <dir>copy_<name> command: cp -v <path> <dir>copy_<name>
move: move:
command: mv -v <path> <dir> command: mv -v <path> <dir>
delete: delete:
command: rm -v <path> command: rm -v <path>
edit: edit:
command: joe <path> command: joe <path>
view: view:
command: less <path> command: less <path>
# target: none means that the action runs once in the current TUI folder.
git-status:
target: none
command: git status
git-pull:
target: none
command: git pull
git-diff:
target: none
command: git diff
git-log:
target: none
command: git log --oneline --decorate -20
git-add:
target: none
command: git add -A
git-commit:
target: none
command: git commit
git-push:
target: none
command: git push

View File

@@ -23,52 +23,65 @@ tui() {
done done
local command_file local command_file
local cwd_file local cwd_file
local all_file
local rc local rc
local cmd local cmd
local tui_cwd local tui_cwd
local tui_all
local -a tui_commands local -a tui_commands
command_file="$(mktemp)" command_file="$(mktemp)"
cwd_file="$(mktemp)" cwd_file="$(mktemp)"
all_file="$(mktemp)"
printf '%s\n' "$show_all" > "$all_file"
while :; do while :; do
: > "$command_file" : > "$command_file"
: > "$cwd_file" : > "$cwd_file"
: > "$all_file"
if (( show_all )); then if (( show_all )); then
TUI_COMMAND_FILE="$command_file" \ TUI_COMMAND_FILE="$command_file" \
TUI_CWD_FILE="$cwd_file" \ TUI_CWD_FILE="$cwd_file" \
TUI_ALL_FILE="$all_file" \
python3 "$tui_python" -a "$tui_dir" python3 "$tui_python" -a "$tui_dir"
else else
TUI_COMMAND_FILE="$command_file" \ TUI_COMMAND_FILE="$command_file" \
TUI_CWD_FILE="$cwd_file" \ TUI_CWD_FILE="$cwd_file" \
TUI_ALL_FILE="$all_file" \
python3 "$tui_python" "$tui_dir" python3 "$tui_python" "$tui_dir"
fi fi
rc=$? rc=$?
tui_cwd="$(<"$cwd_file")" tui_cwd="$(<"$cwd_file")"
tui_all="$(<"$all_file")"
if [[ "$tui_all" == "1" ]]; then
show_all=1
elif [[ "$tui_all" == "0" ]]; then
show_all=0
fi
# Ctrl+C exits TUI. Apply the last directory in the parent Bash # Ctrl+C exits TUI. Apply the last directory in the parent Bash
# process, then return to the caller. # process, then return to the caller.
if (( rc == 43 )); then if (( rc == 43 )); then
if [[ -n "$tui_cwd" && -d "$tui_cwd" ]]; then if [[ -n "$tui_cwd" && -d "$tui_cwd" ]]; then
cd -- "$tui_cwd" || { cd -- "$tui_cwd" || {
rm -f "$command_file" "$cwd_file" rm -f "$command_file" "$cwd_file" "$all_file"
return 1 return 1
} }
fi fi
rm -f "$command_file" "$cwd_file" rm -f "$command_file" "$cwd_file" "$all_file"
return 0 return 0
fi fi
if (( rc != 42 )); then if (( rc != 42 )); then
rm -f "$command_file" "$cwd_file" rm -f "$command_file" "$cwd_file" "$all_file"
return "$rc" return "$rc"
fi fi
if [[ -z "$tui_cwd" || ! -d "$tui_cwd" ]]; then if [[ -z "$tui_cwd" || ! -d "$tui_cwd" ]]; then
echo "tui: invalid TUI working directory" >&2 echo "tui: invalid TUI working directory" >&2
rm -f "$command_file" "$cwd_file" rm -f "$command_file" "$cwd_file" "$all_file"
return 1 return 1
fi fi
@@ -77,7 +90,7 @@ tui() {
# own history entry. If a command fails, stop the action and pause so # own history entry. If a command fails, stop the action and pause so
# the user has time to see the error. # the user has time to see the error.
pushd "$tui_cwd" >/dev/null || { pushd "$tui_cwd" >/dev/null || {
rm -f "$command_file" "$cwd_file" rm -f "$command_file" "$cwd_file" "$all_file"
return 1 return 1
} }

View File

@@ -6,7 +6,7 @@ import os
import subprocess import subprocess
from pathlib import Path from pathlib import Path
TUI_VERSION = "2.0.7" TUI_VERSION = "3.0.0"
SHELL_HANDOFF = 42 SHELL_HANDOFF = 42
TUI_EXIT = 43 TUI_EXIT = 43
@@ -109,12 +109,85 @@ class TuiConfig:
return self._match_rules(value, target.name) return self._match_rules(value, target.name)
def menu_items(self):
"""Return configured menu entries in display order."""
menus = self.data.get("menu", self.data.get("menus", []))
return menus if isinstance(menus, list) else []
def condition_matches(self, condition, browser):
"""Evaluate the small, generic set of conditions supported by .tui."""
if condition is None or condition == "always":
return True
if isinstance(condition, list):
return all(self.condition_matches(item, browser) for item in condition)
if isinstance(condition, dict):
if "all" in condition:
return all(self.condition_matches(item, browser) for item in condition["all"])
if "any" in condition:
return any(self.condition_matches(item, browser) for item in condition["any"])
if "not" in condition:
return not self.condition_matches(condition["not"], browser)
return False
condition = str(condition)
if condition in ("git", "not-git"):
current = browser.path
git_root_found = False
while True:
if (current / ".git").exists():
git_root_found = True
break
if current == browser.home or current.parent == current:
break
current = current.parent
return git_root_found if condition == "git" else not git_root_found
if condition == "file":
target = browser.selected()
return bool(target and target.is_file())
if condition == "folder":
target = browser.selected()
return bool(target and target.is_dir())
if condition == "marked":
return bool(browser.marked)
if condition == "multiple":
return len(browser.targets(marked_only=False)) > 1
if condition.startswith("exists:"):
return (browser.path / condition[7:]).exists()
if condition.startswith("missing:"):
return not (browser.path / condition[8:]).exists()
if condition.startswith("glob:"):
return any(browser.path.glob(condition[5:]))
return False
def menu_available(self, item, browser):
if not isinstance(item, dict):
return False
if item.get("separator"):
return True
if not item.get("label") or not item.get("action"):
return False
if not self.condition_matches(item.get("when", "always"), browser):
return False
definition = self.action_definition(str(item["action"]))
if definition is None:
return False
selection = item.get("selection", "auto")
if definition.get("target") == "none" or selection == "none":
return True
if selection == "marked":
return bool(browser.targets(marked_only=True))
if selection == "current":
return bool(browser.targets(marked_only=False))
return bool(browser.targets(marked_only=False))
def action_definition(self, action): def action_definition(self, action):
actions = self.data.get("actions", {}) actions = self.data.get("actions", {})
definition = actions.get(action) definition = actions.get(action)
if isinstance(definition, str): if isinstance(definition, str):
return {"commands": [definition], "pause": False} return {"commands": [definition], "pause": False, "target": "auto"}
if not isinstance(definition, dict): if not isinstance(definition, dict):
return None return None
@@ -129,9 +202,11 @@ class TuiConfig:
else: else:
commands = [] commands = []
target_mode = definition.get("target", "auto")
return { return {
"commands": commands, "commands": commands,
"pause": bool(definition.get("pause", False)), "pause": bool(definition.get("pause", False)),
"target": str(target_mode),
} }
@@ -309,14 +384,15 @@ class FileBrowser:
def execute_action(self, action, stdscr, marked_only=False): def execute_action(self, action, stdscr, marked_only=False):
"""Render normal shell commands and hand them to the parent shell wrapper.""" """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) definition = self.config.action_definition(action)
if definition is None or not definition["commands"]: if definition is None or not definition["commands"]:
return True return True
target_mode = definition.get("target", "auto")
targets = [] if target_mode == "none" else self.targets(marked_only=marked_only)
if target_mode != "none" and not targets:
return True
command_file = os.environ.get("TUI_COMMAND_FILE") command_file = os.environ.get("TUI_COMMAND_FILE")
cwd_file = os.environ.get("TUI_CWD_FILE") cwd_file = os.environ.get("TUI_CWD_FILE")
@@ -337,6 +413,9 @@ class FileBrowser:
return True return True
commands = [] commands = []
if target_mode == "none":
commands = list(definition["commands"])
else:
for target in targets: for target in targets:
for command_template in definition["commands"]: for command_template in definition["commands"]:
commands.append( commands.append(
@@ -475,6 +554,69 @@ def read_ctrl_k(stdscr, browser):
return None, "Unknown Ctrl-K command" return None, "Unknown Ctrl-K command"
def show_menu(stdscr, browser):
"""Display the .tui-defined action menu and return an action or None."""
items = [item for item in browser.config.menu_items()
if browser.config.menu_available(item, browser)]
if not items:
return None, "Inga tillgängliga alternativ"
height, width = stdscr.getmaxyx()
selected = 0
selectable = [i for i, item in enumerate(items) if not item.get("separator")]
selected = selectable[0]
while True:
stdscr.erase()
title = f" tui {browser.path} — MENY "
stdscr.addnstr(0, 0, title, max(0, width - 1), curses.A_BOLD)
max_rows = max(1, height - 3)
selected_pos = selectable.index(selected)
first_pos = max(0, selected_pos - max_rows + 1)
visible_indices = selectable[first_pos:first_pos + max_rows]
visible_set = set(visible_indices)
first = max(0, items.index(items[selected]) - max_rows + 1)
menu_width = min(width - 2, max(30, max(len(str(i.get("label", ""))) for i in items if not i.get("separator")) + 10))
left = max(0, (width - menu_width) // 2)
row = 1
for idx, item in enumerate(items):
if idx < first or row >= height - 1:
continue
if item.get("separator"):
text = "─" * max(1, menu_width - 2)
attr = curses.A_DIM
else:
shortcut = str(item.get("key", ""))
text = f" {shortcut:>3} {item['label']}" if shortcut else f" {item['label']}"
attr = curses.A_REVERSE if idx == selected else curses.A_NORMAL
stdscr.addnstr(row, left, text, max(0, menu_width - 1), attr)
row += 1
footer = " ↑/↓ välj Enter kör Esc/M stäng "
stdscr.addnstr(height - 1, 0, footer, max(0, width - 1), curses.A_DIM)
stdscr.refresh()
key = stdscr.getch()
if key in (27, ord("m"), ord("M")):
return None, ""
if key in (curses.KEY_UP, ord("k")):
pos = selectable.index(selected)
selected = selectable[(pos - 1) % len(selectable)]
continue
if key in (curses.KEY_DOWN, ord("j")):
pos = selectable.index(selected)
selected = selectable[(pos + 1) % len(selectable)]
continue
if key in (10, 13, curses.KEY_ENTER):
return str(items[selected]["action"]), str(items[selected]["label"])
if 0 <= key < 256:
char = chr(key).lower()
for idx in selectable:
if str(items[idx].get("key", "")).lower() == char:
return str(items[idx]["action"]), str(items[idx]["label"])
def run(stdscr, start_dir=".", show_all=False): def run(stdscr, start_dir=".", show_all=False):
curses.curs_set(0) curses.curs_set(0)
stdscr.keypad(True) stdscr.keypad(True)
@@ -504,6 +646,8 @@ def run(stdscr, start_dir=".", show_all=False):
return browser.resolve_key_action(["right"], browser.selected()) return browser.resolve_key_action(["right"], browser.selected())
if key in (curses.KEY_ENTER, 10, 13): if key in (curses.KEY_ENTER, 10, 13):
return browser.resolve_key_action(["enter"], browser.selected()) return browser.resolve_key_action(["enter"], browser.selected())
if 0 <= key < 256:
return browser.resolve_key_action([chr(key).lower()], browser.selected())
# Common Linux/curses key codes for Ctrl-Up / Ctrl-Down. # Common Linux/curses key codes for Ctrl-Up / Ctrl-Down.
if key == 566: if key == 566:
@@ -554,6 +698,26 @@ def run(stdscr, start_dir=".", show_all=False):
action = configured_action(key) action = configured_action(key)
if action == "quit":
return TUI_EXIT
elif action == "toggle-all":
browser.show_all = not browser.show_all
browser.load()
status = "Dolda filer: " + ("VISAS" if browser.show_all else "DÖLJS")
continue
elif action == "menu":
action, message = show_menu(stdscr, browser)
if action:
result = browser.execute_action(action, stdscr)
if result == SHELL_HANDOFF:
return SHELL_HANDOFF
if result is False:
return
status = message
else:
status = message
continue
if action == "up": if action == "up":
browser.move(-1) browser.move(-1)
elif action == "down": elif action == "down":
@@ -579,6 +743,12 @@ def run(stdscr, start_dir=".", show_all=False):
Path(cwd_file).write_text(str(browser.path), encoding="utf-8") Path(cwd_file).write_text(str(browser.path), encoding="utf-8")
except OSError: except OSError:
pass pass
all_file = os.environ.get("TUI_ALL_FILE")
if all_file:
try:
Path(all_file).write_text("1" if browser.show_all else "0", encoding="utf-8")
except OSError:
pass
def main(): def main():