menyer
This commit is contained in:
10
CHANGELOG.md
10
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.
|
||||
|
||||
41
README.md
41
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:<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`.
|
||||
|
||||
114
tui/example.tui
114
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 <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>
|
||||
|
||||
# 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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
192
tui/tui.py
192
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():
|
||||
|
||||
Reference in New Issue
Block a user