mirror of
https://github.com/dredx/prole.git
synced 2026-09-24 17:44:33 +00:00
Major feature additions and infrastructure improvements for the Prole Database Installer, enabling command-line operation and packaged binary distribution. ## Ncurses Terminal Interface - Add installer/ncurses_ui.py: UI primitives (CursesWindow, TerminalConsole, NavFooter, InputField, Checkbox) - Add installer/ncurses_installer.py: Complete terminal UI with all 11 screens - Implement same screen flow as GUI (welcome, deps, network scan, env setup, kerberos, password, build, cluster, scripts, deploy, installer creation) - Add keyboard navigation (arrows, hjkl, vim-style) - Support both GUI and ncurses modes in single binary ## Automatic Display Detection - Add has_display() function to detect GUI availability - Auto-select GUI if display available, ncurses otherwise - Add --gui and --no-gui command-line flags - Fallback to ncurses on GUI failure ## Build System and Packaging - Add Makefile with targets: build, package, clean, test, install - Add scripts/generate_spec.py: PyInstaller spec generator - Add installer.spec: PyInstaller configuration - Automatic PNG to ICNS icon conversion - Create self-contained macOS .app bundle with embedded icon - Support both Intel (x86_64) and Apple Silicon (arm64) ## Embedded Resources - Add get_resource_path() helper for PyInstaller compatibility - Embed all images (proleIcon.png, proleLogo.png, proleLogoSepia.png) - Embed prole-net/prole-scan binary (6.8 MB universal binary) - Embed prole-app/dist/Prole Tools.app (12 MB app bundle) - Embed prole-db/ Docker build context ## Writable Directory Fixes - Create ~/.prole/build/prole-db/ for Docker builds (fixes read-only _MEIPASS) - Create ~/.prole/scan/ for network scan output (fixes API call failures) - Copy build context to writable location before Docker operations - Run prole-scan from writable working directory ## Documentation - docs/build-system.md: Complete build system guide - docs/ncurses-installer.md: Ncurses interface documentation - docs/RELEASE-NOTES.md: Feature overview and release notes - docs/IMAGE-RESOURCES.md: Image resource management - docs/EMBEDDED-RESOURCES.md: Binary and app bundle embedding - docs/DOCKER-BUILD-FIX.md: Docker build hang solution - docs/PROLE-HOME-DIRECTORY.md: ~/.prole directory structure - BUILD.md: Quick build reference ## Key Changes install.py: - Add get_resource_path() for embedded resource resolution - Update image paths to use get_resource_path() - Update Docker build to use ~/.prole/build/prole-db/ - Update network scan to use ~/.prole/scan/ - Add display detection and mode selection - Add --gui and --no-gui argument parsing ## Testing All features tested and verified: - Ncurses interface navigation - Display auto-detection - Resource path resolution - Docker build from package - Network scan from package - Icon conversion and embedding Package size: ~50-100 MB (includes Python runtime, all resources) Disk usage: ~/.prole/ uses ~2-6 MB 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
313 lines
9.9 KiB
Python
313 lines
9.9 KiB
Python
"""
|
|
Ncurses UI primitives for Prole 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
|
|
|
|
def set_buttons(self, buttons: list[tuple[str, Optional[Callable], bool]]):
|
|
"""Set button configuration: [(label, callback, enabled), ...]"""
|
|
self.buttons = buttons
|
|
self.selected = 0
|
|
|
|
def render(self):
|
|
"""Render the footer."""
|
|
try:
|
|
# Draw separator line
|
|
self.win.addstr(self.y, 0, '─' * self.width)
|
|
|
|
# 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
|
|
|
|
attr = curses.A_REVERSE if (len(self.buttons) - 1 - i) == self.selected else 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):
|
|
"""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)
|
|
self.win.addstr(self.y, self.x, display, curses.A_REVERSE)
|
|
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
|