diff --git a/CHANGELOG.md b/CHANGELOG.md index 03f9de4..6b17993 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,3 +10,12 @@ - Returning to a directory shows its previously marked entries again. - Moving marked entries uses the current TUI directory as the destination. - `.tui` lookup is limited to the user's home directory. + +## Unreleased + +- Hidden files and directories are hidden by default; use `tui -a` / `tui --all` to show them. +- Added a synthetic parent-directory entry at the top of every listing. +- Left arrow moves the cursor to the parent entry; pressing left again moves to the parent directory. +- Navigation is limited to the directory containing the applicable `.tui` file. +- 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. diff --git a/README.md b/README.md index cd66b60..778d95d 100644 --- a/README.md +++ b/README.md @@ -56,8 +56,17 @@ Then: tui ``` +Use `-a` to include hidden files and directories: + +```bash +tui -a +``` + The installer does not overwrite an existing `~/.tui`. +`-a` / `--all` is a command-line option, so the shell integration preserves it +when the TUI restarts after an action. + ## `.tui` configuration TUI searches for the nearest `.tui` starting in the current directory and @@ -108,7 +117,12 @@ The default example configuration uses: - `↑` / `↓` — move cursor - `Enter` / `→` — execute the configured action -- `←` — parent directory +- `../` — synthetic parent-directory entry at the top of every listing +- `←` — move the cursor to `../`; pressing `←` again enters the parent directory +- `Enter` on `../` — enter the parent directory +- navigation cannot move above the directory containing the applicable `.tui` file +- hidden files and directories are hidden by default; `tui -a` shows them +- file and directory colors follow the usual GNU `ls --color=yes` conventions for common file types - `Ctrl-K B` — set selection start - `Ctrl-K K` — set selection end - `Ctrl-K C` — copy marked files/folders diff --git a/tui/tui-shell.bash b/tui/tui-shell.bash index 9e1cd59..7c27876 100644 --- a/tui/tui-shell.bash +++ b/tui/tui-shell.bash @@ -11,7 +11,15 @@ tui() { return 127 fi - local tui_dir="${1:-.}" + local tui_dir="." + local show_all=0 + local arg + for arg in "$@"; do + case "$arg" in + -a|--all) show_all=1 ;; + *) tui_dir="$arg" ;; + esac + done local command_file local cwd_file local rc @@ -25,9 +33,15 @@ tui() { : > "$command_file" : > "$cwd_file" - TUI_COMMAND_FILE="$command_file" \ - TUI_CWD_FILE="$cwd_file" \ - python3 "$tui_python" "$tui_dir" + if (( show_all )); then + TUI_COMMAND_FILE="$command_file" \ + TUI_CWD_FILE="$cwd_file" \ + python3 "$tui_python" -a "$tui_dir" + else + TUI_COMMAND_FILE="$command_file" \ + TUI_CWD_FILE="$cwd_file" \ + python3 "$tui_python" "$tui_dir" + fi rc=$? if (( rc != 42 )); then diff --git a/tui/tui.py b/tui/tui.py index 63d010e..5c865d0 100755 --- a/tui/tui.py +++ b/tui/tui.py @@ -134,9 +134,10 @@ class TuiConfig: class FileBrowser: - def __init__(self, start_dir="."): + def __init__(self, start_dir=".", show_all=False): self.home = Path.home().resolve() self.path = Path(start_dir).expanduser().resolve() + self.show_all = show_all if not self.path.is_dir(): raise ValueError(f"Not a directory: {self.path}") @@ -160,20 +161,25 @@ class FileBrowser: self.load() + @property + def root(self): + """Highest directory TUI may visit for the current configuration.""" + return self.config.path.parent if self.config.path is not None else self.home + def load(self): try: - self.entries = list(self.path.iterdir()) + entries = list(self.path.iterdir()) except OSError: - self.entries = [] + entries = [] - self.entries.sort( - key=lambda p: (not p.is_dir(), p.name.casefold()) - ) + if not self.show_all: + entries = [p for p in entries if not p.name.startswith(".")] - self.cursor = min( - self.cursor, - max(0, len(self.entries) - 1), - ) + entries.sort(key=lambda p: (not p.is_dir(), p.name.casefold())) + self.entries = entries + + # Cursor 0 is always the synthetic parent entry. + 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. @@ -183,7 +189,13 @@ class FileBrowser: return self.config.path is not None def selected(self): - return self.entries[self.cursor] if self.entries else None + if self.cursor == 0: + return self.path.parent if self.path != self.root else None + index = self.cursor - 1 + return self.entries[index] if 0 <= index < len(self.entries) else None + + def current_entry_index(self): + return self.cursor - 1 def _update_selection(self): """Update the current directory's range without touching other marks.""" @@ -213,29 +225,33 @@ class FileBrowser: if not self.entries: return + if self.cursor == 0: + return + # B sets/replaces only the start endpoint. The end endpoint is kept, # so the existing selection can be expanded or reduced. - self.selection_start = self.cursor + self.selection_start = self.current_entry_index() self._update_selection() def mark_end(self): if not self.entries: return + if self.cursor == 0: + return + # K sets/replaces only the end endpoint. The start endpoint is kept. - self.selection_end = self.cursor + self.selection_end = self.current_entry_index() self._update_selection() def move(self, delta): - if self.entries: - 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): - entry = self.selected() + if self.cursor == 0: + return self.parent() + entry = self.selected() if entry and entry.is_dir(): self.path = entry.resolve() self.cursor = 0 @@ -246,8 +262,8 @@ class FileBrowser: return True def parent(self): - if self.path == self.home: - return False + if self.path == self.root: + return True self.path = self.path.parent self.cursor = 0 @@ -268,7 +284,8 @@ class FileBrowser: if p.resolve() in self.marked ] - return [self.selected()] if self.selected() else [] + selected = self.selected() + return [selected] if selected and selected != self.path.parent else [] def resolve_key_action(self, sequence, target=None): binding = self.config.key_binding(sequence) @@ -284,7 +301,7 @@ class FileBrowser: return ( command .replace("", target.name) - .replace("", str(target)) + .replace("", str(target.resolve())) .replace("", directory) ) @@ -349,64 +366,86 @@ class FileBrowser: +def init_colors(): + if not curses.has_colors(): + return + curses.start_color() + try: + curses.use_default_colors() + except curses.error: + pass + + # Close to the default GNU ls --color palette. + curses.init_pair(1, curses.COLOR_BLUE, -1) # directories + curses.init_pair(2, curses.COLOR_CYAN, -1) # symlinks + curses.init_pair(3, curses.COLOR_GREEN, -1) # executables + curses.init_pair(4, curses.COLOR_RED, -1) # archives + curses.init_pair(5, curses.COLOR_MAGENTA, -1) # images + curses.init_pair(6, curses.COLOR_YELLOW, -1) # devices / special + curses.init_pair(7, curses.COLOR_WHITE, -1) # parent + + +def entry_color(entry): + if entry is None: + return curses.color_pair(7) if curses.has_colors() else curses.A_NORMAL + try: + if entry.is_symlink(): + pair = 2 + elif entry.is_dir(): + pair = 1 + elif entry.is_file() and os.access(entry, os.X_OK): + pair = 3 + else: + suffix = entry.suffix.lower() + if suffix in {'.tar', '.tgz', '.gz', '.bz2', '.xz', '.zip', '.7z', '.rar', '.zst'}: + pair = 4 + elif suffix in {'.jpg', '.jpeg', '.png', '.gif', '.bmp', '.webp', '.svg', '.ico'}: + pair = 5 + else: + pair = 0 + except OSError: + pair = 0 + return curses.color_pair(pair) if pair and curses.has_colors() else curses.A_NORMAL + + def draw(stdscr, browser, status=""): stdscr.erase() - height, width = stdscr.getmaxyx() title = f" tui {browser.path} " - stdscr.addnstr( - 0, - 0, - title, - max(0, width - 1), - curses.A_BOLD, - ) + stdscr.addnstr(0, 0, title, max(0, width - 1), curses.A_BOLD) visible = max(1, height - 3) + # Position 0 is the synthetic parent entry. first = max(0, browser.cursor - visible + 1) - for row, entry in enumerate( - browser.entries[first:first + visible], - start=1, - ): - index = first + row - 1 + for row in range(visible): + index = first + row + if index > len(browser.entries): + break - is_marked = entry.resolve() in browser.marked - attrs = curses.A_BOLD if entry.is_dir() else curses.A_NORMAL + if index == 0: + entry = browser.path.parent if browser.path != browser.root else None + text = "../" if entry is not None else "./" + attrs = entry_color(entry) | curses.A_BOLD + is_marked = False + 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 "") - if is_marked: - # Marked items are visually filled/inverted. - attrs |= curses.A_REVERSE - - name = entry.name + ("/" if entry.is_dir() else "") - text = name - - # The cursor is an underline rather than a reverse-video row. This - # keeps the current item distinct from the selected/marked items. if index == browser.cursor: attrs |= curses.A_UNDERLINE - stdscr.addnstr( - row, - 0, - text, - max(0, width - 1), - attrs, - ) + stdscr.addnstr(row + 1, 0, text, max(0, width - 1), attrs) prompt = status or "> " - stdscr.addnstr( - height - 1, - 0, - prompt, - max(0, width - 1), - curses.A_DIM, - ) - + stdscr.addnstr(height - 1, 0, prompt, max(0, width - 1), curses.A_DIM) stdscr.refresh() - def read_ctrl_k(stdscr, browser): height, width = stdscr.getmaxyx() @@ -434,7 +473,7 @@ def read_ctrl_k(stdscr, browser): return None, "Unknown Ctrl-K command" -def run(stdscr, start_dir="."): +def run(stdscr, start_dir=".", show_all=False): curses.curs_set(0) stdscr.keypad(True) @@ -442,9 +481,10 @@ def run(stdscr, start_dir="."): curses.use_default_colors() except curses.error: pass + init_colors() try: - browser = FileBrowser(start_dir) + browser = FileBrowser(start_dir, show_all=show_all) except ValueError as exc: # Let curses.wrapper restore the terminal, then report the reason. raise SystemExit(str(exc)) @@ -498,6 +538,17 @@ def run(stdscr, start_dir="."): status = message continue + # Left is navigation, not an action: first move the cursor to the + # parent entry; pressing left again enters that parent. This also + # makes the parent entry visible at the top of every directory. + if key == curses.KEY_LEFT: + if browser.cursor == 0: + if browser.parent() is False: + return + else: + browser.cursor = 0 + continue + action = configured_action(key) if action == "up": @@ -520,11 +571,13 @@ def run(stdscr, start_dir="."): def main(): parser = argparse.ArgumentParser() + parser.add_argument("-a", "--all", action="store_true", + help="show hidden files and directories") parser.add_argument("directory", nargs="?", default=".") args = parser.parse_args() try: - result = curses.wrapper(run, args.directory) + result = curses.wrapper(run, args.directory, args.all) except KeyboardInterrupt: return 0