diff --git a/CHANGELOG.md b/CHANGELOG.md index 63343ca..4807d99 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 ## v2.1 @@ -30,17 +24,9 @@ - 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. -### v2 – cursor and block selection update -- 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. +## 2.0.7 -## v2 update -- The bottom empty row now visibly shows the cursor. -- When TUI exits normally, the shell integration changes the parent Bash shell to the directory where TUI was closed. - -## 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 && 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. +- Added `tui -v` / `tui --version` for verifying which installed version is running. +- 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. diff --git a/README.md b/README.md index 65bfbd2..bf43adf 100644 --- a/README.md +++ b/README.md @@ -94,7 +94,8 @@ keys: actions: list-zip: command: - command: 'unzip -l && read -rp "Enter to continue.." || read -rp "FAILED - Enter to continue..."' + - unzip -l + - read -rp "Tryck Enter för att fortsätta..." move: command: mv -v @@ -164,42 +165,44 @@ not clear the already marked paths. 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 in the parent Bash process. +executes them. -When TUI is closed with `Ctrl+C`, the wrapper changes the parent Bash shell to -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: +For example: ```yaml actions: list-zip: - command: 'unzip -l && 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-one - - command-two + - unzip -l + - read -rp "Tryck Enter för att fortsätta..." ``` -TUI is **not redrawn between commands belonging to the same action**. Each -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 flow is: -The shell wrapper uses `pushd`/`popd` so action commands run in the directory -shown by TUI without permanently changing the user's Bash directory during -the action. +```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 @@ -225,8 +228,7 @@ 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. diff --git a/tui/example.tui b/tui/example.tui index 9c6e070..8a66651 100644 --- a/tui/example.tui +++ b/tui/example.tui @@ -35,7 +35,9 @@ keys: actions: list-zip: - command: 'unzip -l && read -rp "Enter to continue.." || read -rp "FAILED - Enter to continue..."' + command: + - unzip -l + - read -rp "Tryck Enter för att fortsätta..." copy: command: cp -v copy_ move: diff --git a/tui/tui-shell.bash b/tui/tui-shell.bash index d0a76ca..ddecbaf 100644 --- a/tui/tui-shell.bash +++ b/tui/tui-shell.bash @@ -17,6 +17,7 @@ tui() { for arg in "$@"; do case "$arg" in -a|--all) show_all=1 ;; + -v|--version) python3 "$tui_python" --version; return $? ;; *) tui_dir="$arg" ;; esac done @@ -25,6 +26,7 @@ tui() { local rc local cmd local tui_cwd + local -a tui_commands command_file="$(mktemp)" cwd_file="$(mktemp)" @@ -33,59 +35,43 @@ tui() { : > "$command_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 TUI_COMMAND_FILE="$command_file" \ - TUI_CWD_FILE="$cwd_file" \ - python3 "$tui_python" -a "$tui_dir"; then - rc=0 - else - rc=$? - fi + TUI_COMMAND_FILE="$command_file" \ + TUI_CWD_FILE="$cwd_file" \ + python3 "$tui_python" -a "$tui_dir" else - if TUI_COMMAND_FILE="$command_file" \ - TUI_CWD_FILE="$cwd_file" \ - python3 "$tui_python" "$tui_dir"; then - rc=0 - else - rc=$? - fi + TUI_COMMAND_FILE="$command_file" \ + TUI_CWD_FILE="$cwd_file" \ + python3 "$tui_python" "$tui_dir" fi + rc=$? tui_cwd="$(<"$cwd_file")" - if [[ -z "$tui_cwd" || ! -d "$tui_cwd" ]]; then - if (( rc != 42 )); then - rm -f "$command_file" "$cwd_file" - return "$rc" + # 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 + rm -f "$command_file" "$cwd_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" return 1 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 # 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 @@ -95,10 +81,6 @@ tui() { 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" for cmd in "${tui_commands[@]}"; do [[ -z "$cmd" ]] && continue diff --git a/tui/tui.py b/tui/tui.py index 2880b7b..49c28b2 100755 --- a/tui/tui.py +++ b/tui/tui.py @@ -6,7 +6,9 @@ import os import subprocess from pathlib import Path +TUI_VERSION = "2.0.7" SHELL_HANDOFF = 42 +TUI_EXIT = 43 try: import yaml @@ -179,7 +181,7 @@ class FileBrowser: self.entries = entries # 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. # Drop marks only for objects that no longer exist. @@ -237,20 +239,15 @@ class FileBrowser: if not self.entries: return - # Like joe's Ctrl-K K, the end of the block is the line immediately - # 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: + if self.cursor == 0: return # 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() 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): if self.cursor == 0: @@ -426,7 +423,7 @@ def draw(stdscr, browser, status=""): for row in range(visible): index = first + row - if index > len(browser.entries) + 1: + if index > len(browser.entries): break if index == 0: @@ -434,21 +431,13 @@ def draw(stdscr, browser, status=""): text = "../" if entry is not None else "./" attrs = entry_color(entry) | curses.A_BOLD is_marked = False - elif index <= len(browser.entries): + else: entry = browser.entries[index - 1] is_marked = entry.resolve() in browser.marked attrs = entry_color(entry) if is_marked: attrs |= curses.A_REVERSE 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: attrs |= curses.A_UNDERLINE @@ -504,18 +493,6 @@ def run(stdscr, start_dir=".", show_all=False): 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): if key == curses.KEY_UP: return browser.resolve_key_action(["up"], browser.selected()) @@ -593,12 +570,21 @@ def run(stdscr, start_dir=".", show_all=False): return SHELL_HANDOFF if result is False: return + except KeyboardInterrupt: + return TUI_EXIT 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(): parser = argparse.ArgumentParser() + parser.add_argument("-v", "--version", action="version", + version=f"tui {TUI_VERSION}") parser.add_argument("-a", "--all", action="store_true", help="show hidden files and directories") parser.add_argument("directory", nargs="?", default=".") @@ -607,12 +593,9 @@ def main(): try: result = curses.wrapper(run, args.directory, args.all) except KeyboardInterrupt: - # Keep a distinct status so the Bash wrapper can reliably distinguish - # Ctrl+C from a normal action handoff. The current directory has - # already been written by run()'s finally block. - return 130 + return TUI_EXIT - return SHELL_HANDOFF if result == SHELL_HANDOFF else 0 + return result if result in (SHELL_HANDOFF, TUI_EXIT) else 0 if __name__ == "__main__":