release: 0.2.6 add advanced navigation and contextual open actions

This commit is contained in:
2026-04-04 01:53:00 +02:00
parent 8259b2e132
commit 6f78f6d9ca
11 changed files with 1090 additions and 50 deletions

View File

@@ -10,8 +10,16 @@ from pathlib import Path
from typing import TextIO
from app.navigator import Navigator, VisibleEntry
from app.opener import (
OpenWithOption,
available_openers,
is_blocking_option,
launch_background_option,
launch_blocking_option,
)
ESCAPE_KEY = 27
BACKSPACE_KEYS = (curses.KEY_BACKSPACE, 127, 8)
@dataclass(frozen=True, slots=True)
@@ -46,9 +54,13 @@ def format_entry(entry: VisibleEntry) -> str:
return entry.name
indent = " " * entry.depth
indicator = " "
if entry.has_children:
if entry.is_file:
indicator = ""
elif entry.has_children:
indicator = "" if entry.is_expanded else ""
else:
indicator = " "
return f"{indent}{indicator} {entry.name}"
@@ -104,6 +116,11 @@ class TerminalUI:
self._history_selected_index = 0
self._tree_scroll_offset = 0
self._history_scroll_offset = 0
self._filter_mode = False
self._filter_query = ""
self._filter_selected_index = 0
self._filter_scroll_offset = 0
self._status_message: str | None = None
def run(self, navigator: Navigator) -> Path:
"""Start the interactive session and return the final directory path."""
@@ -121,6 +138,16 @@ class TerminalUI:
self._render(stdscr, navigator)
key = stdscr.getch()
if self._history_mode:
self._handle_history_key(key, navigator, stdscr=stdscr)
continue
if self._filter_mode:
filter_result = self._handle_filter_key(key, navigator, stdscr)
if filter_result is not None:
return filter_result
continue
if key == ESCAPE_KEY:
return original_path
@@ -128,11 +155,7 @@ class TerminalUI:
self._toggle_history_mode(navigator)
continue
if self._history_mode:
self._handle_history_key(key, navigator)
continue
tree_result = self._handle_tree_key(key, navigator)
tree_result = self._handle_tree_key(key, navigator, stdscr=stdscr)
if tree_result is not None:
return tree_result
@@ -146,30 +169,92 @@ class TerminalUI:
return
hidden_status = "on" if navigator.show_hidden else "off"
path_text = f"{navigator.current_path} [hidden: {hidden_status}]"
stdscr.addnstr(0, 0, path_text, width - 1)
lines = build_render_lines(navigator)
visible_count = max(0, height - 2)
self._tree_scroll_offset = clamp_scroll_offset(
navigator.selected_index,
self._tree_scroll_offset,
visible_count,
len(lines),
file_status = "on" if navigator.show_files else "off"
path_text = (
f"{navigator.current_path} [hidden: {hidden_status}] [files: {file_status}]"
)
stdscr.addnstr(0, 0, path_text, max(0, width - 1))
for row, line in enumerate(
lines[self._tree_scroll_offset : self._tree_scroll_offset + visible_count],
start=2,
):
if row >= height:
break
start_row = 2
if self._filter_mode:
stdscr.addnstr(
1,
0,
f"Filter: {self._filter_query}",
max(0, width - 1),
curses.A_BOLD,
)
start_row = 3
attributes = curses.A_REVERSE if line.is_selected else curses.A_NORMAL
stdscr.addnstr(row, 0, line.text, width - 1, attributes)
lines, selected_index = self._build_tree_lines_for_render(navigator)
footer_rows = 1 if self._status_message else 0
visible_count = max(0, height - start_row - footer_rows)
if self._filter_mode:
self._filter_scroll_offset = clamp_scroll_offset(
selected_index,
self._filter_scroll_offset,
visible_count,
len(lines),
)
scroll_offset = self._filter_scroll_offset
else:
self._tree_scroll_offset = clamp_scroll_offset(
selected_index,
self._tree_scroll_offset,
visible_count,
len(lines),
)
scroll_offset = self._tree_scroll_offset
if self._filter_mode and self._filter_query and not lines:
stdscr.addnstr(start_row, 0, "No matches", max(0, width - 1), curses.A_DIM)
else:
for row, line in enumerate(
lines[scroll_offset : scroll_offset + visible_count],
start=start_row,
):
if row >= height:
break
attributes = curses.A_REVERSE if line.is_selected else curses.A_NORMAL
stdscr.addnstr(row, 0, line.text, max(0, width - 1), attributes)
if self._status_message and height > 0:
stdscr.addnstr(
height - 1,
0,
self._status_message,
max(0, width - 1),
curses.A_DIM,
)
stdscr.refresh()
def _build_tree_lines_for_render(
self, navigator: Navigator
) -> tuple[list[RenderLine], int]:
if not self._filter_mode:
return build_render_lines(navigator), navigator.selected_index
entries = navigator.visible_entries
matches = self._matching_indices(entries)
if not matches:
self._filter_selected_index = 0
return [], 0
self._filter_selected_index = max(
0, min(self._filter_selected_index, len(matches) - 1)
)
return [
RenderLine(
text=format_entry(entries[entry_index]),
is_selected=match_index == self._filter_selected_index,
)
for match_index, entry_index in enumerate(matches)
], self._filter_selected_index
def _render_history_mode(
self, stdscr: curses.window, navigator: Navigator, width: int, height: int
) -> None:
@@ -177,7 +262,8 @@ class TerminalUI:
navigator.history.entries(), self._history_selected_index
)
start_row = 1
visible_count = max(0, height - start_row - 1)
footer_rows = 1 if self._status_message else 0
visible_count = max(0, height - start_row - 1 - footer_rows)
self._history_scroll_offset = clamp_scroll_offset(
self._history_selected_index,
self._history_scroll_offset,
@@ -185,7 +271,7 @@ class TerminalUI:
len(history_lines),
)
stdscr.addnstr(start_row, 0, "History:", width - 1, curses.A_BOLD)
stdscr.addnstr(start_row, 0, "History:", max(0, width - 1), curses.A_BOLD)
for index, line in enumerate(
history_lines[
@@ -199,17 +285,33 @@ class TerminalUI:
break
attributes = curses.A_REVERSE if line.is_selected else curses.A_NORMAL
stdscr.addnstr(row, 0, line.text, width - 1, attributes)
stdscr.addnstr(row, 0, line.text, max(0, width - 1), attributes)
if self._status_message and height > 0:
stdscr.addnstr(
height - 1,
0,
self._status_message,
max(0, width - 1),
curses.A_DIM,
)
def _toggle_history_mode(self, navigator: Navigator) -> None:
self._history_mode = not self._history_mode
self._status_message = None
if self._history_mode:
self._history_selected_index = self._get_current_history_index(navigator)
self._history_scroll_offset = 0
def _handle_history_key(self, key: int, navigator: Navigator) -> None:
def _handle_history_key(
self, key: int, navigator: Navigator, stdscr: curses.window | None = None
) -> None:
history_entries = navigator.history.entries()
if key in (ESCAPE_KEY, ord("h")):
self._history_mode = False
return
if key == curses.KEY_UP:
self._history_selected_index = move_selection(
self._history_selected_index,
@@ -226,12 +328,134 @@ class TerminalUI:
)
return
if key == curses.KEY_PPAGE:
step = -self._page_step(stdscr, start_row=1)
self._history_selected_index = move_selection(
self._history_selected_index,
step=step,
entry_count=len(history_entries),
)
return
if key == curses.KEY_NPAGE:
step = self._page_step(stdscr, start_row=1)
self._history_selected_index = move_selection(
self._history_selected_index,
step=step,
entry_count=len(history_entries),
)
return
if key in (curses.KEY_HOME, ord("g")):
self._history_selected_index = 0
return
if key in (curses.KEY_END, ord("G")) and history_entries:
self._history_selected_index = len(history_entries) - 1
return
if key in (curses.KEY_ENTER, 10, 13) and history_entries:
navigator.select_history_entry(self._history_selected_index)
self._history_mode = False
self._tree_scroll_offset = 0
def _handle_tree_key(self, key: int, navigator: Navigator) -> Path | None:
def _handle_filter_key(
self, key: int, navigator: Navigator, stdscr: curses.window | None = None
) -> Path | None:
entries = navigator.visible_entries
matches = self._matching_indices(entries)
if key == ESCAPE_KEY:
self._filter_mode = False
self._filter_query = ""
self._filter_selected_index = 0
self._filter_scroll_offset = 0
return None
if key in (curses.KEY_ENTER, 10, 13):
if not matches:
return None
selected_entry_index = matches[self._filter_selected_index]
navigator.set_selected_index(selected_entry_index)
selected = entries[selected_entry_index]
self._filter_mode = False
self._filter_query = ""
self._filter_selected_index = 0
self._filter_scroll_offset = 0
if selected.is_file:
self._open_with_selected(navigator, stdscr)
return None
navigator.enter_selected_directory()
self._tree_scroll_offset = 0
return None
if key == curses.KEY_UP:
self._filter_selected_index = move_selection(
self._filter_selected_index,
step=-1,
entry_count=len(matches),
)
return None
if key == curses.KEY_DOWN:
self._filter_selected_index = move_selection(
self._filter_selected_index,
step=1,
entry_count=len(matches),
)
return None
if key == curses.KEY_PPAGE:
step = -self._page_step(stdscr, start_row=3)
self._filter_selected_index = move_selection(
self._filter_selected_index,
step=step,
entry_count=len(matches),
)
return None
if key == curses.KEY_NPAGE:
step = self._page_step(stdscr, start_row=3)
self._filter_selected_index = move_selection(
self._filter_selected_index,
step=step,
entry_count=len(matches),
)
return None
if key == curses.KEY_HOME:
self._filter_selected_index = 0
return None
if key == curses.KEY_END and matches:
self._filter_selected_index = len(matches) - 1
return None
if key in BACKSPACE_KEYS:
self._filter_query = self._filter_query[:-1]
self._filter_selected_index = 0
self._filter_scroll_offset = 0
return None
if 32 <= key <= 126:
self._filter_query += chr(key)
self._filter_selected_index = 0
self._filter_scroll_offset = 0
return None
def _handle_tree_key(
self,
key: int,
navigator: Navigator,
stdscr: curses.window | None = None,
) -> Path | None:
self._status_message = None
if key == curses.KEY_UP:
navigator.set_selected_index(
move_selection(
@@ -252,8 +476,40 @@ class TerminalUI:
)
return None
if key == curses.KEY_PPAGE:
navigator.set_selected_index(
move_selection(
navigator.selected_index,
step=-self._page_step(stdscr, start_row=2),
entry_count=len(navigator.visible_entries),
)
)
return None
if key == curses.KEY_NPAGE:
navigator.set_selected_index(
move_selection(
navigator.selected_index,
step=self._page_step(stdscr, start_row=2),
entry_count=len(navigator.visible_entries),
)
)
return None
if key in (curses.KEY_HOME, ord("g")):
navigator.set_selected_index(0)
return None
if key in (curses.KEY_END, ord("G")):
navigator.set_selected_index(len(navigator.visible_entries) - 1)
return None
if key == curses.KEY_RIGHT:
was_expanded = navigator.selected_entry.is_expanded
selected = navigator.selected_entry
if selected.is_file:
return None
was_expanded = selected.is_expanded
if not navigator.expand_selected_directory():
navigator.enter_selected_directory()
@@ -288,11 +544,167 @@ class TerminalUI:
self._tree_scroll_offset = 0
return None
if key == ord("a"):
navigator.toggle_files()
self._tree_scroll_offset = 0
return None
if key == ord("/"):
self._filter_mode = True
self._filter_query = ""
self._filter_selected_index = 0
self._filter_scroll_offset = 0
return None
if key == ord("o"):
self._open_with_selected(navigator, stdscr)
return None
if key in (curses.KEY_ENTER, 10, 13):
return navigator.selected_entry.path
return None
def _matching_indices(self, entries: tuple[VisibleEntry, ...]) -> list[int]:
if not self._filter_query:
return list(range(len(entries)))
query = self._filter_query.casefold()
return [
index
for index, entry in enumerate(entries)
if query in entry.name.casefold()
]
def _page_step(self, stdscr: curses.window | None, start_row: int) -> int:
if stdscr is None:
return 10
height, _width = stdscr.getmaxyx()
footer_rows = 1 if self._status_message else 0
return max(1, height - start_row - footer_rows)
def _open_with_selected(
self, navigator: Navigator, stdscr: curses.window | None
) -> None:
selected = navigator.selected_entry
options = available_openers(selected.path)
if not options:
self._status_message = "No applications available for this entry"
return
option_index = 0
if stdscr is not None:
choice = self._prompt_open_with_choice(stdscr, options, selected.name)
if choice is None:
self._status_message = "Open cancelled"
return
option_index = choice
option = options[option_index]
if is_blocking_option(option):
success, message = self._launch_blocking_with_terminal_restore(
stdscr, selected.path, option
)
else:
success, message = launch_background_option(selected.path, option)
self._status_message = message
if not success:
self._status_message = message
def _prompt_open_with_choice(
self,
stdscr: curses.window,
options: list[OpenWithOption],
entry_name: str,
) -> int | None:
selected_index = 0
while True:
height, width = stdscr.getmaxyx()
title_row = max(0, height - len(options) - 2)
title = f"Open '{entry_name}' with (↑/↓, Enter, Esc):"
stdscr.addnstr(
title_row,
0,
" " * max(0, width - 1),
max(0, width - 1),
)
stdscr.addnstr(
title_row,
0,
title,
max(0, width - 1),
curses.A_BOLD,
)
for index, option in enumerate(options):
row = title_row + index + 1
if row >= height:
break
attributes = (
curses.A_REVERSE if index == selected_index else curses.A_NORMAL
)
label = f" {option.label}"
stdscr.addnstr(
row,
0,
" " * max(0, width - 1),
max(0, width - 1),
)
stdscr.addnstr(row, 0, label, max(0, width - 1), attributes)
stdscr.refresh()
key = stdscr.getch()
if key == ESCAPE_KEY:
return None
if key == curses.KEY_UP:
selected_index = move_selection(
selected_index,
step=-1,
entry_count=len(options),
)
continue
if key == curses.KEY_DOWN:
selected_index = move_selection(
selected_index,
step=1,
entry_count=len(options),
)
continue
if key in (curses.KEY_ENTER, 10, 13):
return selected_index
def _launch_blocking_with_terminal_restore(
self,
stdscr: curses.window | None,
path: Path,
option: OpenWithOption,
) -> tuple[bool, str]:
if stdscr is None:
return launch_blocking_option(path, option)
curses.def_prog_mode()
curses.endwin()
try:
success, message = launch_blocking_option(path, option)
finally:
curses.reset_prog_mode()
stdscr.keypad(True)
stdscr.clear()
stdscr.refresh()
return success, message
def _get_current_history_index(self, navigator: Navigator) -> int:
history_entries = navigator.history.entries()