FuckingChat/views/config_tui.py

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()