release: 0.2.6 add advanced navigation and contextual open actions
This commit is contained in:
474
src/app/ui.py
474
src/app/ui.py
@@ -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()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user