This commit is contained in:
Joakim
2026-10-12 23:30:17 +02:00
parent 212b922629
commit e1836a38e1
5 changed files with 88 additions and 133 deletions

View File

@@ -1,9 +1,3 @@
## v2 - latest fix
- Fixed `Ctrl+C` exit handling so the Bash wrapper reliably changes to the directory where TUI was closed, including when Bash `set -e` is enabled.
- Action commands are evaluated as complete Bash commands, so `&&` and `||` chains work as they do directly in the terminal.
- Updated the example ZIP action to demonstrate success/failure prompts with `&&` / `||`.
# Changelog # Changelog
## v2.1 ## v2.1
@@ -30,17 +24,9 @@
- Added `ls --color`-style colors for directories, symlinks, executables, archives and common image files. - Added `ls --color`-style colors for directories, symlinks, executables, archives and common image files.
- Marked paths remain visible as marked when returning to their directory. - Marked paths remain visible as marked when returning to their directory.
### v2 – cursor and block selection update ## 2.0.7
- Cursor can now move to the empty line immediately below the last object.
- `Ctrl+K K` now behaves like joe: the block end is the row immediately above the cursor.
- On the bottom empty line, `Ctrl+K K` therefore selects through the last object.
## v2 update - Added `tui -v` / `tui --version` for verifying which installed version is running.
- The bottom empty row now visibly shows the cursor. - Fixed Ctrl+C exit so the parent Bash process changes to the directory where TUI was closed.
- When TUI exits normally, the shell integration changes the parent Bash shell 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.
## v2 fix – interactive shell commands and exit directory
- Fixed interactive action commands: the command loop no longer redirects stdin from the command file.
- Commands such as `unzip -l <path> && read -rp "Enter..." || read -rp "FAILED..."` now receive input from the real terminal.
- The final TUI directory is always applied to the parent Bash shell when TUI exits, including Ctrl+C/SIGINT.

View File

@@ -94,7 +94,8 @@ keys:
actions: actions:
list-zip: list-zip:
command: command:
command: 'unzip -l <path> && read -rp "Enter to continue.." || read -rp "FAILED - Enter to continue..."' - unzip -l <path>
- read -rp "Tryck Enter för att fortsätta..."
move: move:
command: mv -v <path> <dir> command: mv -v <path> <dir>
@@ -164,42 +165,44 @@ not clear the already marked paths.
When an action is triggered, TUI writes the generated commands to a temporary 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 file and exits with a private handoff status. The `tui()` Bash function then
executes them in the parent Bash process. executes them.
When TUI is closed with `Ctrl+C`, the wrapper changes the parent Bash shell to For example:
the directory where TUI was actually closed.
Action commands are handed to Bash as complete commands, so normal shell
operators such as `&&` and `||` work exactly as they do at the terminal. For
example:
```yaml ```yaml
actions: actions:
list-zip: list-zip:
command: 'unzip -l <path> && read -rp "Enter to continue.." || read -rp "FAILED - Enter to continue..."'
```
The complete command is evaluated once. If `unzip` succeeds, the success
`read` is executed. If `unzip` fails, the failure `read` is executed.
For actions containing several separate commands, use a YAML list:
```yaml
actions:
example:
command: command:
- command-one <path> - unzip -l <path>
- command-two <path> - read -rp "Tryck Enter för att fortsätta..."
``` ```
TUI is **not redrawn between commands belonging to the same action**. Each The flow is:
command is inserted into Bash history. If a separate command fails, the
remaining commands in that action are not executed; TUI prints the exit code
and waits before returning to TUI.
The shell wrapper uses `pushd`/`popd` so action commands run in the directory ```text
shown by TUI without permanently changing the user's Bash directory during TUI
the action. ↓
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 ## Security note
@@ -225,8 +228,7 @@ tui
The example configuration is in `tui/example.tui`. The example configuration is in `tui/example.tui`.
### Cursor and block selection ### Version
There is an empty cursor position immediately below the last object in a directory. This makes range selection behave like a text editor. Use `tui -v` or `tui --version` to verify the installed TUI version.
`Ctrl+K K` follows joe-style semantics: the end of the selection is the row immediately above the cursor. Thus, when the cursor is on the empty line below the final object, `Ctrl+K K` includes the final object.

View File

@@ -35,7 +35,9 @@ keys:
actions: actions:
list-zip: list-zip:
command: 'unzip -l <path> && read -rp "Enter to continue.." || read -rp "FAILED - Enter to continue..."' command:
- unzip -l <path>
- 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:

View File

@@ -17,6 +17,7 @@ tui() {
for arg in "$@"; do for arg in "$@"; do
case "$arg" in case "$arg" in
-a|--all) show_all=1 ;; -a|--all) show_all=1 ;;
-v|--version) python3 "$tui_python" --version; return $? ;;
*) tui_dir="$arg" ;; *) tui_dir="$arg" ;;
esac esac
done done
@@ -25,6 +26,7 @@ tui() {
local rc local rc
local cmd local cmd
local tui_cwd local tui_cwd
local -a tui_commands
command_file="$(mktemp)" command_file="$(mktemp)"
cwd_file="$(mktemp)" cwd_file="$(mktemp)"
@@ -33,59 +35,43 @@ tui() {
: > "$command_file" : > "$command_file"
: > "$cwd_file" : > "$cwd_file"
# Use an if/else wrapper so this remains safe when the caller has
# `set -e` enabled. A Ctrl+C from the child must still return control
# here so we can cd to the TUI's final directory.
if (( show_all )); then if (( show_all )); then
if TUI_COMMAND_FILE="$command_file" \ TUI_COMMAND_FILE="$command_file" \
TUI_CWD_FILE="$cwd_file" \ TUI_CWD_FILE="$cwd_file" \
python3 "$tui_python" -a "$tui_dir"; then python3 "$tui_python" -a "$tui_dir"
rc=0
else else
rc=$? TUI_COMMAND_FILE="$command_file" \
fi
else
if TUI_COMMAND_FILE="$command_file" \
TUI_CWD_FILE="$cwd_file" \ TUI_CWD_FILE="$cwd_file" \
python3 "$tui_python" "$tui_dir"; then python3 "$tui_python" "$tui_dir"
rc=0 fi
else
rc=$? rc=$?
fi
fi
tui_cwd="$(<"$cwd_file")" tui_cwd="$(<"$cwd_file")"
if [[ -z "$tui_cwd" || ! -d "$tui_cwd" ]]; then # 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"
return 1
}
fi
rm -f "$command_file" "$cwd_file"
return 0
fi
if (( rc != 42 )); then if (( rc != 42 )); then
rm -f "$command_file" "$cwd_file" rm -f "$command_file" "$cwd_file"
return "$rc" return "$rc"
fi fi
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"
return 1 return 1
fi fi
# Any exit other than SHELL_HANDOFF means TUI is finished. Always
# change the parent Bash shell to the directory where TUI was
# actually closed. This includes Ctrl+C (status 130).
if (( rc != 42 )); then
# The TUI writes its actual final directory in cwd_file in its
# finally block. Always apply it to the parent Bash shell before
# handling the exit status, including SIGINT/Ctrl+C.
if cd "$tui_cwd"; then
rm -f "$command_file" "$cwd_file"
else
printf 'tui: cannot cd to final directory: %s\n' "$tui_cwd" >&2
rm -f "$command_file" "$cwd_file"
return 1
fi
if (( rc == 130 )); then
return 0
fi
return "$rc"
fi
# Execute all commands belonging to one TUI action before starting # Execute all commands belonging to one TUI action before starting
# TUI again. Each command remains a normal Bash command and gets its # 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 # own history entry. If a command fails, stop the action and pause so
@@ -95,10 +81,6 @@ tui() {
return 1 return 1
} }
# Do not redirect stdin while executing commands: commands may need
# interactive input (for example `read`, `joe`, or `less`). Read the
# command list into an array first, then let each command inherit the
# real terminal stdin.
mapfile -t tui_commands < "$command_file" mapfile -t tui_commands < "$command_file"
for cmd in "${tui_commands[@]}"; do for cmd in "${tui_commands[@]}"; do
[[ -z "$cmd" ]] && continue [[ -z "$cmd" ]] && continue

View File

@@ -6,7 +6,9 @@ import os
import subprocess import subprocess
from pathlib import Path from pathlib import Path
TUI_VERSION = "2.0.7"
SHELL_HANDOFF = 42 SHELL_HANDOFF = 42
TUI_EXIT = 43
try: try:
import yaml import yaml
@@ -179,7 +181,7 @@ class FileBrowser:
self.entries = entries self.entries = entries
# Cursor 0 is always the synthetic parent entry. # Cursor 0 is always the synthetic parent entry.
self.cursor = min(self.cursor, len(self.entries) + 1) self.cursor = min(self.cursor, len(self.entries))
# Marks are global absolute paths, so they survive directory changes. # Marks are global absolute paths, so they survive directory changes.
# Drop marks only for objects that no longer exist. # Drop marks only for objects that no longer exist.
@@ -237,20 +239,15 @@ class FileBrowser:
if not self.entries: if not self.entries:
return return
# Like joe's Ctrl-K K, the end of the block is the line immediately if self.cursor == 0:
# 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 return
# K sets/replaces only the end endpoint. The start endpoint is kept. # K sets/replaces only the end endpoint. The start endpoint is kept.
self.selection_end = end self.selection_end = self.current_entry_index()
self._update_selection() self._update_selection()
def move(self, delta): def move(self, delta):
self.cursor = max(0, min(len(self.entries) + 1, self.cursor + delta)) self.cursor = max(0, min(len(self.entries), self.cursor + delta))
def enter(self): def enter(self):
if self.cursor == 0: if self.cursor == 0:
@@ -426,7 +423,7 @@ def draw(stdscr, browser, status=""):
for row in range(visible): for row in range(visible):
index = first + row index = first + row
if index > len(browser.entries) + 1: if index > len(browser.entries):
break break
if index == 0: if index == 0:
@@ -434,21 +431,13 @@ def draw(stdscr, browser, status=""):
text = "../" if entry is not None else "./" text = "../" if entry is not None else "./"
attrs = entry_color(entry) | curses.A_BOLD attrs = entry_color(entry) | curses.A_BOLD
is_marked = False is_marked = False
elif index <= len(browser.entries): else:
entry = browser.entries[index - 1] entry = browser.entries[index - 1]
is_marked = entry.resolve() in browser.marked is_marked = entry.resolve() in browser.marked
attrs = entry_color(entry) attrs = entry_color(entry)
if is_marked: if is_marked:
attrs |= curses.A_REVERSE attrs |= curses.A_REVERSE
text = entry.name + ("/" if entry.is_dir() else "") text = entry.name + ("/" if entry.is_dir() else "")
else:
# One empty line after the final object, matching the cursor range.
# A literal space is needed here: curses does not draw attributes
# for an empty string, so the cursor would otherwise be invisible.
entry = None
is_marked = False
attrs = curses.A_NORMAL
text = " "
if index == browser.cursor: if index == browser.cursor:
attrs |= curses.A_UNDERLINE attrs |= curses.A_UNDERLINE
@@ -504,18 +493,6 @@ def run(stdscr, start_dir=".", show_all=False):
status = "" status = ""
def save_cwd():
cwd_file = os.environ.get("TUI_CWD_FILE")
if cwd_file:
try:
Path(cwd_file).write_text(str(browser.path), encoding="utf-8")
except OSError:
pass
# Always leave the shell wrapper with the directory in which TUI was
# actually closed, not merely the directory of the last action.
save_cwd()
def configured_action(key): def configured_action(key):
if key == curses.KEY_UP: if key == curses.KEY_UP:
return browser.resolve_key_action(["up"], browser.selected()) return browser.resolve_key_action(["up"], browser.selected())
@@ -593,12 +570,21 @@ def run(stdscr, start_dir=".", show_all=False):
return SHELL_HANDOFF return SHELL_HANDOFF
if result is False: if result is False:
return return
except KeyboardInterrupt:
return TUI_EXIT
finally: finally:
save_cwd() cwd_file = os.environ.get("TUI_CWD_FILE")
if cwd_file:
try:
Path(cwd_file).write_text(str(browser.path), encoding="utf-8")
except OSError:
pass
def main(): def main():
parser = argparse.ArgumentParser() parser = argparse.ArgumentParser()
parser.add_argument("-v", "--version", action="version",
version=f"tui {TUI_VERSION}")
parser.add_argument("-a", "--all", action="store_true", parser.add_argument("-a", "--all", action="store_true",
help="show hidden files and directories") help="show hidden files and directories")
parser.add_argument("directory", nargs="?", default=".") parser.add_argument("directory", nargs="?", default=".")
@@ -607,12 +593,9 @@ def main():
try: try:
result = curses.wrapper(run, args.directory, args.all) result = curses.wrapper(run, args.directory, args.all)
except KeyboardInterrupt: except KeyboardInterrupt:
# Keep a distinct status so the Bash wrapper can reliably distinguish return TUI_EXIT
# Ctrl+C from a normal action handoff. The current directory has
# already been written by run()'s finally block.
return 130
return SHELL_HANDOFF if result == SHELL_HANDOFF else 0 return result if result in (SHELL_HANDOFF, TUI_EXIT) else 0
if __name__ == "__main__": if __name__ == "__main__":