diff --git a/CHANGELOG.md b/CHANGELOG.md index 4807d99..8743745 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -30,3 +30,13 @@ - 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. - 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. diff --git a/README.md b/README.md index bf43adf..dec1869 100644 --- a/README.md +++ b/README.md @@ -232,3 +232,44 @@ The example configuration is in `tui/example.tui`. 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:`, `missing:`, `glob:`, 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`. diff --git a/tui/example.tui b/tui/example.tui index 8a66651..21f0373 100644 --- a/tui/example.tui +++ b/tui/example.tui @@ -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: up: up down: down left: parent + a: toggle-all + q: quit + m: menu right: folder: enter @@ -33,18 +39,126 @@ keys: folder: - "*": 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: 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 + + # 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 diff --git a/tui/tui-shell.bash b/tui/tui-shell.bash index ddecbaf..dcae80a 100644 --- a/tui/tui-shell.bash +++ b/tui/tui-shell.bash @@ -23,52 +23,65 @@ tui() { done local command_file local cwd_file + local all_file local rc local cmd local tui_cwd + local tui_all local -a tui_commands command_file="$(mktemp)" cwd_file="$(mktemp)" + all_file="$(mktemp)" + printf '%s\n' "$show_all" > "$all_file" while :; do : > "$command_file" : > "$cwd_file" + : > "$all_file" if (( show_all )); then TUI_COMMAND_FILE="$command_file" \ TUI_CWD_FILE="$cwd_file" \ + TUI_ALL_FILE="$all_file" \ python3 "$tui_python" -a "$tui_dir" else TUI_COMMAND_FILE="$command_file" \ TUI_CWD_FILE="$cwd_file" \ + TUI_ALL_FILE="$all_file" \ python3 "$tui_python" "$tui_dir" fi rc=$? 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 # process, then return to the caller. if (( rc == 43 )); then if [[ -n "$tui_cwd" && -d "$tui_cwd" ]]; then cd -- "$tui_cwd" || { - rm -f "$command_file" "$cwd_file" + rm -f "$command_file" "$cwd_file" "$all_file" return 1 } fi - rm -f "$command_file" "$cwd_file" + rm -f "$command_file" "$cwd_file" "$all_file" return 0 fi if (( rc != 42 )); then - rm -f "$command_file" "$cwd_file" + rm -f "$command_file" "$cwd_file" "$all_file" return "$rc" fi if [[ -z "$tui_cwd" || ! -d "$tui_cwd" ]]; then echo "tui: invalid TUI working directory" >&2 - rm -f "$command_file" "$cwd_file" + rm -f "$command_file" "$cwd_file" "$all_file" return 1 fi @@ -77,7 +90,7 @@ tui() { # 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" + rm -f "$command_file" "$cwd_file" "$all_file" return 1 } diff --git a/tui/tui.py b/tui/tui.py index 49c28b2..4ca7b34 100755 --- a/tui/tui.py +++ b/tui/tui.py @@ -6,7 +6,7 @@ import os import subprocess from pathlib import Path -TUI_VERSION = "2.0.7" +TUI_VERSION = "3.0.0" SHELL_HANDOFF = 42 TUI_EXIT = 43 @@ -109,12 +109,85 @@ class TuiConfig: 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): actions = self.data.get("actions", {}) definition = actions.get(action) if isinstance(definition, str): - return {"commands": [definition], "pause": False} + return {"commands": [definition], "pause": False, "target": "auto"} if not isinstance(definition, dict): return None @@ -129,9 +202,11 @@ class TuiConfig: else: commands = [] + target_mode = definition.get("target", "auto") return { "commands": commands, "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): """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 + 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") cwd_file = os.environ.get("TUI_CWD_FILE") @@ -337,11 +413,14 @@ class FileBrowser: return True commands = [] - for target in targets: - for command_template in definition["commands"]: - commands.append( - self._command_for_target(command_template, target) - ) + if target_mode == "none": + commands = list(definition["commands"]) + else: + for target in targets: + for command_template in definition["commands"]: + commands.append( + self._command_for_target(command_template, target) + ) if not commands: return True @@ -475,6 +554,69 @@ def read_ctrl_k(stdscr, browser): 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): curses.curs_set(0) stdscr.keypad(True) @@ -504,6 +646,8 @@ def run(stdscr, start_dir=".", show_all=False): return browser.resolve_key_action(["right"], browser.selected()) if key in (curses.KEY_ENTER, 10, 13): 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. if key == 566: @@ -554,6 +698,26 @@ def run(stdscr, start_dir=".", show_all=False): 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": browser.move(-1) 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") except OSError: 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():