colors, ../ och -a
This commit is contained in:
@@ -10,3 +10,12 @@
|
|||||||
- Returning to a directory shows its previously marked entries again.
|
- Returning to a directory shows its previously marked entries again.
|
||||||
- Moving marked entries uses the current TUI directory as the destination.
|
- Moving marked entries uses the current TUI directory as the destination.
|
||||||
- `.tui` lookup is limited to the user's home directory.
|
- `.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.
|
||||||
|
|||||||
16
README.md
16
README.md
@@ -56,8 +56,17 @@ Then:
|
|||||||
tui
|
tui
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Use `-a` to include hidden files and directories:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
tui -a
|
||||||
|
```
|
||||||
|
|
||||||
The installer does not overwrite an existing `~/.tui`.
|
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` configuration
|
||||||
|
|
||||||
TUI searches for the nearest `.tui` starting in the current directory and
|
TUI searches for the nearest `.tui` starting in the current directory and
|
||||||
@@ -108,7 +117,12 @@ The default example configuration uses:
|
|||||||
|
|
||||||
- `↑` / `↓` — move cursor
|
- `↑` / `↓` — move cursor
|
||||||
- `Enter` / `→` — execute the configured action
|
- `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 B` — set selection start
|
||||||
- `Ctrl-K K` — set selection end
|
- `Ctrl-K K` — set selection end
|
||||||
- `Ctrl-K C` — copy marked files/folders
|
- `Ctrl-K C` — copy marked files/folders
|
||||||
|
|||||||
@@ -11,7 +11,15 @@ tui() {
|
|||||||
return 127
|
return 127
|
||||||
fi
|
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 command_file
|
||||||
local cwd_file
|
local cwd_file
|
||||||
local rc
|
local rc
|
||||||
@@ -25,9 +33,15 @@ tui() {
|
|||||||
: > "$command_file"
|
: > "$command_file"
|
||||||
: > "$cwd_file"
|
: > "$cwd_file"
|
||||||
|
|
||||||
|
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_COMMAND_FILE="$command_file" \
|
||||||
TUI_CWD_FILE="$cwd_file" \
|
TUI_CWD_FILE="$cwd_file" \
|
||||||
python3 "$tui_python" "$tui_dir"
|
python3 "$tui_python" "$tui_dir"
|
||||||
|
fi
|
||||||
rc=$?
|
rc=$?
|
||||||
|
|
||||||
if (( rc != 42 )); then
|
if (( rc != 42 )); then
|
||||||
|
|||||||
179
tui/tui.py
179
tui/tui.py
@@ -134,9 +134,10 @@ class TuiConfig:
|
|||||||
|
|
||||||
|
|
||||||
class FileBrowser:
|
class FileBrowser:
|
||||||
def __init__(self, start_dir="."):
|
def __init__(self, start_dir=".", show_all=False):
|
||||||
self.home = Path.home().resolve()
|
self.home = Path.home().resolve()
|
||||||
self.path = Path(start_dir).expanduser().resolve()
|
self.path = Path(start_dir).expanduser().resolve()
|
||||||
|
self.show_all = show_all
|
||||||
|
|
||||||
if not self.path.is_dir():
|
if not self.path.is_dir():
|
||||||
raise ValueError(f"Not a directory: {self.path}")
|
raise ValueError(f"Not a directory: {self.path}")
|
||||||
@@ -160,20 +161,25 @@ class FileBrowser:
|
|||||||
|
|
||||||
self.load()
|
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):
|
def load(self):
|
||||||
try:
|
try:
|
||||||
self.entries = list(self.path.iterdir())
|
entries = list(self.path.iterdir())
|
||||||
except OSError:
|
except OSError:
|
||||||
self.entries = []
|
entries = []
|
||||||
|
|
||||||
self.entries.sort(
|
if not self.show_all:
|
||||||
key=lambda p: (not p.is_dir(), p.name.casefold())
|
entries = [p for p in entries if not p.name.startswith(".")]
|
||||||
)
|
|
||||||
|
|
||||||
self.cursor = min(
|
entries.sort(key=lambda p: (not p.is_dir(), p.name.casefold()))
|
||||||
self.cursor,
|
self.entries = entries
|
||||||
max(0, len(self.entries) - 1),
|
|
||||||
)
|
# 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.
|
# 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.
|
||||||
@@ -183,7 +189,13 @@ class FileBrowser:
|
|||||||
return self.config.path is not None
|
return self.config.path is not None
|
||||||
|
|
||||||
def selected(self):
|
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):
|
def _update_selection(self):
|
||||||
"""Update the current directory's range without touching other marks."""
|
"""Update the current directory's range without touching other marks."""
|
||||||
@@ -213,29 +225,33 @@ class FileBrowser:
|
|||||||
if not self.entries:
|
if not self.entries:
|
||||||
return
|
return
|
||||||
|
|
||||||
|
if self.cursor == 0:
|
||||||
|
return
|
||||||
|
|
||||||
# B sets/replaces only the start endpoint. The end endpoint is kept,
|
# B sets/replaces only the start endpoint. The end endpoint is kept,
|
||||||
# so the existing selection can be expanded or reduced.
|
# so the existing selection can be expanded or reduced.
|
||||||
self.selection_start = self.cursor
|
self.selection_start = self.current_entry_index()
|
||||||
self._update_selection()
|
self._update_selection()
|
||||||
|
|
||||||
def mark_end(self):
|
def mark_end(self):
|
||||||
if not self.entries:
|
if not self.entries:
|
||||||
return
|
return
|
||||||
|
|
||||||
|
if self.cursor == 0:
|
||||||
|
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 = self.cursor
|
self.selection_end = self.current_entry_index()
|
||||||
self._update_selection()
|
self._update_selection()
|
||||||
|
|
||||||
def move(self, delta):
|
def move(self, delta):
|
||||||
if self.entries:
|
self.cursor = max(0, min(len(self.entries), self.cursor + delta))
|
||||||
self.cursor = max(
|
|
||||||
0,
|
|
||||||
min(len(self.entries) - 1, self.cursor + delta),
|
|
||||||
)
|
|
||||||
|
|
||||||
def enter(self):
|
def enter(self):
|
||||||
entry = self.selected()
|
if self.cursor == 0:
|
||||||
|
return self.parent()
|
||||||
|
|
||||||
|
entry = self.selected()
|
||||||
if entry and entry.is_dir():
|
if entry and entry.is_dir():
|
||||||
self.path = entry.resolve()
|
self.path = entry.resolve()
|
||||||
self.cursor = 0
|
self.cursor = 0
|
||||||
@@ -246,8 +262,8 @@ class FileBrowser:
|
|||||||
return True
|
return True
|
||||||
|
|
||||||
def parent(self):
|
def parent(self):
|
||||||
if self.path == self.home:
|
if self.path == self.root:
|
||||||
return False
|
return True
|
||||||
|
|
||||||
self.path = self.path.parent
|
self.path = self.path.parent
|
||||||
self.cursor = 0
|
self.cursor = 0
|
||||||
@@ -268,7 +284,8 @@ class FileBrowser:
|
|||||||
if p.resolve() in self.marked
|
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):
|
def resolve_key_action(self, sequence, target=None):
|
||||||
binding = self.config.key_binding(sequence)
|
binding = self.config.key_binding(sequence)
|
||||||
@@ -284,7 +301,7 @@ class FileBrowser:
|
|||||||
return (
|
return (
|
||||||
command
|
command
|
||||||
.replace("<name>", target.name)
|
.replace("<name>", target.name)
|
||||||
.replace("<path>", str(target))
|
.replace("<path>", str(target.resolve()))
|
||||||
.replace("<dir>", directory)
|
.replace("<dir>", 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=""):
|
def draw(stdscr, browser, status=""):
|
||||||
stdscr.erase()
|
stdscr.erase()
|
||||||
|
|
||||||
height, width = stdscr.getmaxyx()
|
height, width = stdscr.getmaxyx()
|
||||||
|
|
||||||
title = f" tui {browser.path} "
|
title = f" tui {browser.path} "
|
||||||
stdscr.addnstr(
|
stdscr.addnstr(0, 0, title, max(0, width - 1), curses.A_BOLD)
|
||||||
0,
|
|
||||||
0,
|
|
||||||
title,
|
|
||||||
max(0, width - 1),
|
|
||||||
curses.A_BOLD,
|
|
||||||
)
|
|
||||||
|
|
||||||
visible = max(1, height - 3)
|
visible = max(1, height - 3)
|
||||||
|
# Position 0 is the synthetic parent entry.
|
||||||
first = max(0, browser.cursor - visible + 1)
|
first = max(0, browser.cursor - visible + 1)
|
||||||
|
|
||||||
for row, entry in enumerate(
|
for row in range(visible):
|
||||||
browser.entries[first:first + visible],
|
index = first + row
|
||||||
start=1,
|
if index > len(browser.entries):
|
||||||
):
|
break
|
||||||
index = first + row - 1
|
|
||||||
|
|
||||||
|
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
|
is_marked = entry.resolve() in browser.marked
|
||||||
attrs = curses.A_BOLD if entry.is_dir() else curses.A_NORMAL
|
attrs = entry_color(entry)
|
||||||
|
|
||||||
if is_marked:
|
if is_marked:
|
||||||
# Marked items are visually filled/inverted.
|
|
||||||
attrs |= curses.A_REVERSE
|
attrs |= curses.A_REVERSE
|
||||||
|
text = entry.name + ("/" if entry.is_dir() else "")
|
||||||
|
|
||||||
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:
|
if index == browser.cursor:
|
||||||
attrs |= curses.A_UNDERLINE
|
attrs |= curses.A_UNDERLINE
|
||||||
|
|
||||||
stdscr.addnstr(
|
stdscr.addnstr(row + 1, 0, text, max(0, width - 1), attrs)
|
||||||
row,
|
|
||||||
0,
|
|
||||||
text,
|
|
||||||
max(0, width - 1),
|
|
||||||
attrs,
|
|
||||||
)
|
|
||||||
|
|
||||||
prompt = status or "> "
|
prompt = status or "> "
|
||||||
stdscr.addnstr(
|
stdscr.addnstr(height - 1, 0, prompt, max(0, width - 1), curses.A_DIM)
|
||||||
height - 1,
|
|
||||||
0,
|
|
||||||
prompt,
|
|
||||||
max(0, width - 1),
|
|
||||||
curses.A_DIM,
|
|
||||||
)
|
|
||||||
|
|
||||||
stdscr.refresh()
|
stdscr.refresh()
|
||||||
|
|
||||||
|
|
||||||
def read_ctrl_k(stdscr, browser):
|
def read_ctrl_k(stdscr, browser):
|
||||||
height, width = stdscr.getmaxyx()
|
height, width = stdscr.getmaxyx()
|
||||||
|
|
||||||
@@ -434,7 +473,7 @@ def read_ctrl_k(stdscr, browser):
|
|||||||
return None, "Unknown Ctrl-K command"
|
return None, "Unknown Ctrl-K command"
|
||||||
|
|
||||||
|
|
||||||
def run(stdscr, start_dir="."):
|
def run(stdscr, start_dir=".", show_all=False):
|
||||||
curses.curs_set(0)
|
curses.curs_set(0)
|
||||||
stdscr.keypad(True)
|
stdscr.keypad(True)
|
||||||
|
|
||||||
@@ -442,9 +481,10 @@ def run(stdscr, start_dir="."):
|
|||||||
curses.use_default_colors()
|
curses.use_default_colors()
|
||||||
except curses.error:
|
except curses.error:
|
||||||
pass
|
pass
|
||||||
|
init_colors()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
browser = FileBrowser(start_dir)
|
browser = FileBrowser(start_dir, show_all=show_all)
|
||||||
except ValueError as exc:
|
except ValueError as exc:
|
||||||
# Let curses.wrapper restore the terminal, then report the reason.
|
# Let curses.wrapper restore the terminal, then report the reason.
|
||||||
raise SystemExit(str(exc))
|
raise SystemExit(str(exc))
|
||||||
@@ -498,6 +538,17 @@ def run(stdscr, start_dir="."):
|
|||||||
status = message
|
status = message
|
||||||
continue
|
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)
|
action = configured_action(key)
|
||||||
|
|
||||||
if action == "up":
|
if action == "up":
|
||||||
@@ -520,11 +571,13 @@ def run(stdscr, start_dir="."):
|
|||||||
|
|
||||||
def main():
|
def main():
|
||||||
parser = argparse.ArgumentParser()
|
parser = argparse.ArgumentParser()
|
||||||
|
parser.add_argument("-a", "--all", action="store_true",
|
||||||
|
help="show hidden files and directories")
|
||||||
parser.add_argument("directory", nargs="?", default=".")
|
parser.add_argument("directory", nargs="?", default=".")
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
result = curses.wrapper(run, args.directory)
|
result = curses.wrapper(run, args.directory, args.all)
|
||||||
except KeyboardInterrupt:
|
except KeyboardInterrupt:
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user