Initial release: cd-browser v0.1.0

This commit is contained in:
2026-03-14 17:21:49 +01:00
commit f03417f2f2
39 changed files with 2351 additions and 0 deletions

167
src/app/ui.py Normal file
View File

@@ -0,0 +1,167 @@
from __future__ import annotations
import curses
import os
import sys
from collections.abc import Iterator
from contextlib import contextmanager
from dataclasses import dataclass
from pathlib import Path
from typing import TextIO
from app.navigator import Navigator, VisibleEntry
ESCAPE_KEY = 27
@dataclass(frozen=True, slots=True)
class RenderLine:
"""A rendered navigator row ready for terminal output."""
text: str
is_selected: bool
def move_selection(current_index: int, step: int, entry_count: int) -> int:
"""Move the selected index within the available entry range."""
if entry_count <= 0:
return 0
return max(0, min(current_index + step, entry_count - 1))
def format_entry(entry: VisibleEntry) -> str:
"""Format a visible entry for terminal display."""
if entry.is_parent:
return entry.name
indent = " " * entry.depth
indicator = " "
if entry.has_children:
indicator = "" if entry.is_expanded else ""
return f"{indent}{indicator} {entry.name}"
def build_render_lines(navigator: Navigator) -> list[RenderLine]:
"""Build all visible lines for the current navigator state."""
return [
RenderLine(
text=format_entry(entry),
is_selected=index == navigator.selected_index,
)
for index, entry in enumerate(navigator.visible_entries)
]
class TerminalUI:
"""Minimal curses UI for the directory navigator MVP."""
def run(self, navigator: Navigator) -> Path:
"""Start the interactive session and return the final directory path."""
with open_terminal_streams() as (input_stream, output_stream):
with redirect_standard_streams(input_stream, output_stream):
return curses.wrapper(self._run_session, navigator)
def _run_session(self, stdscr: curses.window, navigator: Navigator) -> Path:
curses.curs_set(0)
stdscr.keypad(True)
while True:
self._render(stdscr, navigator)
key = stdscr.getch()
if key == ESCAPE_KEY:
return navigator.current_path
if key == curses.KEY_UP:
navigator.set_selected_index(
move_selection(
navigator.selected_index,
step=-1,
entry_count=len(navigator.visible_entries),
)
)
continue
if key == curses.KEY_DOWN:
navigator.set_selected_index(
move_selection(
navigator.selected_index,
step=1,
entry_count=len(navigator.visible_entries),
)
)
continue
if key == curses.KEY_RIGHT:
navigator.expand_selected_directory()
continue
if key == curses.KEY_LEFT:
navigator.collapse_selected_directory()
continue
if key in (curses.KEY_ENTER, 10, 13):
navigator.enter_selected_directory()
def _render(self, stdscr: curses.window, navigator: Navigator) -> None:
stdscr.erase()
height, width = stdscr.getmaxyx()
path_text = str(navigator.current_path)
stdscr.addnstr(0, 0, path_text, width - 1)
for row, line in enumerate(build_render_lines(navigator), start=2):
if row >= height:
break
attributes = curses.A_REVERSE if line.is_selected else curses.A_NORMAL
stdscr.addnstr(row, 0, line.text, width - 1, attributes)
stdscr.refresh()
@contextmanager
def open_terminal_streams() -> Iterator[tuple[TextIO, TextIO]]:
"""Open terminal streams suitable for interactive rendering."""
try:
with (
open("/dev/tty", encoding="utf-8") as input_stream,
open("/dev/tty", "w", encoding="utf-8") as output_stream,
):
yield input_stream, output_stream
return
except OSError:
if not sys.stdin.isatty() or not sys.stderr.isatty():
raise RuntimeError("Interactive terminal is required") from None
yield sys.stdin, sys.stderr
@contextmanager
def redirect_standard_streams(
input_stream: TextIO, output_stream: TextIO
) -> Iterator[None]:
"""Temporarily redirect stdin and stdout to terminal-backed streams."""
sys.stdout.flush()
sys.stderr.flush()
original_stdin = os.dup(0)
original_stdout = os.dup(1)
try:
os.dup2(input_stream.fileno(), 0)
os.dup2(output_stream.fileno(), 1)
yield
finally:
os.dup2(original_stdin, 0)
os.dup2(original_stdout, 1)
os.close(original_stdin)
os.close(original_stdout)