Initial release: cd-browser v0.1.0
This commit is contained in:
167
src/app/ui.py
Normal file
167
src/app/ui.py
Normal 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)
|
||||
Reference in New Issue
Block a user