194 lines
6.2 KiB
Python
194 lines
6.2 KiB
Python
"""
|
|
views/config_tui.py — curses-based configuration wizard.
|
|
|
|
Run: python -m views.config_tui
|
|
Or: python views/config_tui.py
|
|
"""
|
|
|
|
import curses
|
|
import sys
|
|
import os
|
|
|
|
# Ensure project root is on path when run directly
|
|
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
|
|
|
|
from config.schema import AppConfig, DatabaseConfig, SecurityConfig, ServerConfig, TenorConfig
|
|
from config.config import load, save
|
|
|
|
# ── field descriptors ──────────────────────────────────────
|
|
SECTION_FIELDS = [
|
|
("server", "Server", [
|
|
("host", "Host", str, "0.0.0.0"),
|
|
("port", "Port", int, 8080),
|
|
("debug", "Debug mode", bool, False),
|
|
]),
|
|
("security", "Security", [
|
|
("secret", "Encryption secret", str, "default_secret_change_me"),
|
|
("salt", "Encryption salt", str, "default_salt_change_me"),
|
|
("admin_token", "Admin token", str, ""),
|
|
("rate_limit", "Rate limit (msg)", int, 5),
|
|
("rate_window", "Rate window (sec)", int, 10),
|
|
("max_message_length", "Max msg length", int, 2000),
|
|
]),
|
|
("db", "Database", [
|
|
("engine", "Engine (sqlite|mariadb)", str, "sqlite"),
|
|
("sqlite_path", "SQLite path", str, "chat.db"),
|
|
("mariadb_host", "MariaDB host", str, "localhost"),
|
|
("mariadb_port", "MariaDB port", int, 3306),
|
|
("mariadb_user", "MariaDB user", str, "chat"),
|
|
("mariadb_password", "MariaDB password", str, "chat"),
|
|
("mariadb_database", "MariaDB database", str, "chat"),
|
|
]),
|
|
("tenor", "Tenor API", [
|
|
("api_key", "API key", str, ""),
|
|
]),
|
|
]
|
|
|
|
# ── helpers ────────────────────────────────────────────────
|
|
def draw_menu(std, items, selected, top, height, width, title):
|
|
"""Draw a scrollable list and return the new top index."""
|
|
std.attron(curses.A_REVERSE)
|
|
std.addstr(0, 0, f" {title} ".center(width, "─"))
|
|
std.attroff(curses.A_REVERSE)
|
|
|
|
n = len(items)
|
|
visible = height - 2 # header + status line
|
|
# clamp top
|
|
if selected < top:
|
|
top = selected
|
|
if selected >= top + visible:
|
|
top = selected - visible + 1
|
|
top = max(0, min(top, n - visible))
|
|
|
|
for i in range(visible):
|
|
idx = top + i
|
|
if idx >= n:
|
|
break
|
|
line = f" {items[idx]}"
|
|
if idx == selected:
|
|
std.attron(curses.A_REVERSE)
|
|
std.addstr(1 + i, 0, line.ljust(width))
|
|
std.attroff(curses.A_REVERSE)
|
|
else:
|
|
std.addstr(1 + i, 0, line.ljust(width))
|
|
|
|
# status bar
|
|
std.attron(curses.A_REVERSE)
|
|
std.addstr(height - 1, 0, f" {selected+1}/{n} ↑↓ scroll ← back ".ljust(width))
|
|
std.attroff(curses.A_REVERSE)
|
|
|
|
return top
|
|
|
|
|
|
def edit_field(std, label, value, field_type):
|
|
"""Inline edit a field value. Returns new value or None if cancelled."""
|
|
h, w = std.getmaxyx()
|
|
prompt = f" {label}: "
|
|
# clear a couple lines
|
|
for y in range(2):
|
|
std.addstr(h // 2 + y, 0, " " * w)
|
|
|
|
std.addstr(h // 2, 0, prompt)
|
|
# pre-fill with current value
|
|
start = len(prompt)
|
|
curses.echo()
|
|
curses.curs_set(1)
|
|
raw = std.getstr(h // 2, start, w - start - 2).decode("utf-8", errors="replace").strip()
|
|
curses.noecho()
|
|
curses.curs_set(0)
|
|
if not raw:
|
|
# keep old value
|
|
return value
|
|
if field_type == bool:
|
|
return raw.lower() in ("1", "true", "yes", "y")
|
|
if field_type == int:
|
|
try:
|
|
return int(raw)
|
|
except ValueError:
|
|
return value
|
|
return raw
|
|
|
|
|
|
def run_config_tui(std, cfg: AppConfig) -> AppConfig | None:
|
|
curses.curs_set(0)
|
|
curses.use_default_colors()
|
|
h, w = std.getmaxyx()
|
|
if h < 12 or w < 50:
|
|
std.addstr(0, 0, "Terminal too small (min 50x12)")
|
|
std.refresh()
|
|
std.getch()
|
|
return None
|
|
|
|
# section list on the left
|
|
section_names = [s[1] for s in SECTION_FIELDS] # display names
|
|
section_data = [(s[0], s[2]) for s in SECTION_FIELDS] # (attr_name, fields)
|
|
|
|
sel_section = 0
|
|
sel_field = 0
|
|
top_section = 0
|
|
|
|
while True:
|
|
std.erase()
|
|
h, w = std.getmaxyx()
|
|
left_w = min(20, w // 3)
|
|
|
|
# draw sections
|
|
sname = section_names[sel_section]
|
|
draw_menu(std, section_names, sel_section, top_section, h, left_w, "Sections")
|
|
|
|
# draw field list on the right
|
|
sec_attr, fields = section_data[sel_section]
|
|
current_obj = getattr(cfg, sec_attr)
|
|
field_labels = [f"{f[1]}: {getattr(current_obj, f[0])}" for f in fields]
|
|
draw_menu(std, field_labels, sel_field, 0, h, w - left_w - 1, sname)
|
|
|
|
key = std.getch()
|
|
|
|
if key == curses.KEY_UP:
|
|
sel_field = max(0, sel_field - 1)
|
|
elif key == curses.KEY_DOWN:
|
|
sel_field = min(len(fields) - 1, sel_field + 1)
|
|
elif key == curses.KEY_LEFT:
|
|
sel_section = max(0, sel_section - 1)
|
|
sel_field = 0
|
|
top_section = 0
|
|
elif key == curses.KEY_RIGHT:
|
|
sel_section = min(len(section_names) - 1, sel_section + 1)
|
|
sel_field = 0
|
|
top_section = 0
|
|
elif key == ord("\n") or key == ord(" "):
|
|
# edit selected field
|
|
field_name, field_label, field_type, _ = fields[sel_field]
|
|
old = getattr(current_obj, field_name)
|
|
new = edit_field(std, field_label, old, field_type)
|
|
if new is not None:
|
|
setattr(current_obj, field_name, new)
|
|
elif key == ord("\t") or key == ord("s") or key == ord("S"):
|
|
# Save
|
|
save(cfg)
|
|
return cfg
|
|
elif key == ord("q") or key == 27: # ESC
|
|
return None
|
|
|
|
|
|
def main_std(std):
|
|
cfg = load()
|
|
result = run_config_tui(std, cfg)
|
|
if result:
|
|
save(result)
|
|
msg = " ✓ Config saved!"
|
|
else:
|
|
msg = " ✗ Cancelled."
|
|
h, w = std.getmaxyx()
|
|
std.addstr(h - 1, 0, msg.ljust(w - 1))
|
|
std.refresh()
|
|
std.getch()
|
|
|
|
|
|
def main():
|
|
curses.wrapper(main_std)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|