prole/knoe/ncurses_ui.py

342 lines
11 KiB
Python

"""
Ncurses UI primitives for Knoe Installer.
Provides curses-based equivalents to the Tk/Canvas UI helpers in screen.py.
"""
from __future__ import annotations
import curses
import threading
from typing import Callable, Optional
class CursesWindow:
"""Wrapper for curses window with helper methods."""
def __init__(self, win):
self.win = win
self.height, self.width = win.getmaxyx()
def clear(self):
"""Clear the window."""
self.win.clear()
def refresh(self):
"""Refresh the window."""
try:
self.win.refresh()
except curses.error:
pass
def render_title(self, text: str, y: int = 2, x: int = 4):
"""Render a section title."""
try:
self.win.addstr(y, x, text, curses.A_BOLD)
except curses.error:
pass
def render_paragraph(self, text: str, y: int, x: int = 4, wrap: int = 70):
"""Render a paragraph with basic wrapping."""
lines = self._wrap_text(text, wrap)
for i, line in enumerate(lines):
try:
self.win.addstr(y + i, x, line)
except curses.error:
pass
def render_text(self, y: int, x: int, text: str, attr: int = curses.A_NORMAL):
"""Render text at specific position."""
try:
self.win.addstr(y, x, text, attr)
except curses.error:
pass
def render_line(self, y: int, char: str = ""):
"""Render a horizontal line."""
try:
self.win.addstr(y, 0, char * self.width)
except curses.error:
pass
def render_box(self, y1: int, x1: int, y2: int, x2: int):
"""Render a box outline."""
try:
# Top and bottom
for x in range(x1 + 1, x2):
self.win.addch(y1, x, curses.ACS_HLINE)
self.win.addch(y2, x, curses.ACS_HLINE)
# Sides
for y in range(y1 + 1, y2):
self.win.addch(y, x1, curses.ACS_VLINE)
self.win.addch(y, x2, curses.ACS_VLINE)
# Corners
self.win.addch(y1, x1, curses.ACS_ULCORNER)
self.win.addch(y1, x2, curses.ACS_URCORNER)
self.win.addch(y2, x1, curses.ACS_LLCORNER)
self.win.addch(y2, x2, curses.ACS_LRCORNER)
except curses.error:
pass
def _wrap_text(self, text: str, width: int) -> list[str]:
"""Basic text wrapping."""
words = text.split()
lines = []
current_line = []
current_length = 0
for word in words:
word_length = len(word)
if current_length + word_length + len(current_line) > width:
if current_line:
lines.append(" ".join(current_line))
current_line = [word]
current_length = word_length
else:
lines.append(word[:width])
if len(word) > width:
current_line = [word[width:]]
current_length = len(word[width:])
else:
current_line.append(word)
current_length += word_length
if current_line:
lines.append(" ".join(current_line))
return lines
class TerminalConsole:
"""Scrollable terminal console for ncurses."""
def __init__(self, win, height: int, width: int, y: int, x: int):
self.win = win
self.height = height
self.width = width
self.y = y
self.x = x
self.lines = []
self.scroll_offset = 0
self.lock = threading.Lock()
def write(self, content: str):
"""Append text to console."""
with self.lock:
for line in content.split("\n"):
if line:
self.lines.append(line)
# Auto-scroll to bottom
if len(self.lines) > self.height:
self.scroll_offset = len(self.lines) - self.height
def clear(self):
"""Clear console."""
with self.lock:
self.lines = []
self.scroll_offset = 0
def render(self):
"""Render the console to the window."""
with self.lock:
start_idx = self.scroll_offset
end_idx = min(start_idx + self.height, len(self.lines))
for i, line in enumerate(self.lines[start_idx:end_idx]):
try:
# Truncate line if too long
display_line = line[: self.width - 2]
self.win.addstr(self.y + i, self.x, display_line)
except curses.error:
pass
def scroll_up(self):
"""Scroll console up."""
with self.lock:
if self.scroll_offset > 0:
self.scroll_offset -= 1
def scroll_down(self):
"""Scroll console down."""
with self.lock:
max_offset = max(0, len(self.lines) - self.height)
if self.scroll_offset < max_offset:
self.scroll_offset += 1
class NavFooter:
"""Navigation footer for ncurses UI."""
def __init__(self, win, height: int, width: int, y: int):
self.win = win
self.height = height
self.width = width
self.y = y
self.buttons = [] # List of (label, callback, enabled)
self.selected = 0
self.help_text = ""
def set_buttons(self, buttons: list[tuple[str, Optional[Callable], bool]]):
"""Set button configuration: [(label, callback, enabled), ...]"""
self.buttons = buttons
self.selected = 0
def set_help_text(self, text: str):
"""Set help instruction text."""
self.help_text = text
def render(self, focused: bool = False):
"""Render the footer."""
try:
# Draw separator line
self.win.addstr(self.y, 0, "" * self.width)
# Render help text left-aligned
if self.help_text:
self.win.addstr(
self.y + 1, 2, self.help_text[: self.width - 30], curses.A_BOLD
)
# Render buttons right-aligned
x_pos = self.width - 4
for i, (label, _, enabled) in enumerate(reversed(self.buttons)):
if not enabled:
continue
btn_text = f"[ {label} ]"
x_pos -= len(btn_text) + 2
is_selected = (len(self.buttons) - 1 - i) == self.selected
if is_selected:
if focused:
attr = curses.A_REVERSE | curses.A_BOLD
else:
attr = curses.A_BOLD
else:
attr = curses.A_NORMAL
if not enabled:
attr |= curses.A_DIM
self.win.addstr(self.y + 1, max(0, x_pos), btn_text, attr)
except curses.error:
pass
def move_selection(self, delta: int):
"""Move button selection left or right."""
enabled_indices = [
i for i, (_, _, enabled) in enumerate(self.buttons) if enabled
]
if not enabled_indices:
return
current_pos = (
enabled_indices.index(self.selected)
if self.selected in enabled_indices
else 0
)
new_pos = (current_pos + delta) % len(enabled_indices)
self.selected = enabled_indices[new_pos]
def activate_selected(self):
"""Activate the currently selected button."""
if 0 <= self.selected < len(self.buttons):
label, callback, enabled = self.buttons[self.selected]
if enabled and callback:
callback()
class InputField:
"""Text input field for ncurses."""
def __init__(self, win, y: int, x: int, width: int, password: bool = False):
self.win = win
self.y = y
self.x = x
self.width = width
self.password = password
self.value = ""
self.cursor = 0
def render(self, focused: bool = False):
"""Render the input field."""
try:
display = "*" * len(self.value) if self.password else self.value
# Pad to width
display = display[: self.width].ljust(self.width)
attr = curses.A_REVERSE if focused else curses.A_BOLD
self.win.addstr(self.y, self.x, display, attr)
except curses.error:
pass
def handle_key(self, key: int) -> bool:
"""Handle keyboard input. Returns True if key was handled."""
if key == curses.KEY_BACKSPACE or key == 127 or key == 8:
if self.cursor > 0:
self.value = self.value[: self.cursor - 1] + self.value[self.cursor :]
self.cursor -= 1
return True
elif key == curses.KEY_DC: # Delete
if self.cursor < len(self.value):
self.value = self.value[: self.cursor] + self.value[self.cursor + 1 :]
return True
elif key == curses.KEY_LEFT:
if self.cursor > 0:
self.cursor -= 1
return True
elif key == curses.KEY_RIGHT:
if self.cursor < len(self.value):
self.cursor += 1
return True
elif key == curses.KEY_HOME:
self.cursor = 0
return True
elif key == curses.KEY_END:
self.cursor = len(self.value)
return True
elif 32 <= key <= 126: # Printable characters
if len(self.value) < self.width:
self.value = (
self.value[: self.cursor] + chr(key) + self.value[self.cursor :]
)
self.cursor += 1
return True
return False
def get_value(self) -> str:
"""Get current input value."""
return self.value
def set_value(self, value: str):
"""Set input value."""
self.value = value[: self.width]
self.cursor = len(self.value)
class Checkbox:
"""Checkbox for ncurses."""
def __init__(self, win, y: int, x: int, label: str, checked: bool = False):
self.win = win
self.y = y
self.x = x
self.label = label
self.checked = checked
def render(self, selected: bool = False):
"""Render the checkbox."""
try:
checkbox = "[X]" if self.checked else "[ ]"
attr = curses.A_REVERSE if selected else curses.A_NORMAL
self.win.addstr(self.y, self.x, f"{checkbox} {self.label}", attr)
except curses.error:
pass
def toggle(self):
"""Toggle checkbox state."""
self.checked = not self.checked
def is_checked(self) -> bool:
"""Get checkbox state."""
return self.checked