diff --git a/scripts/config_tui.py b/scripts/config_tui.py new file mode 100755 index 0000000..a75cb02 --- /dev/null +++ b/scripts/config_tui.py @@ -0,0 +1,1248 @@ +#!/usr/bin/env python3 +""" +config_tui.py — Interactive ncurses-based configuration editor for meshcore-bot. + +Features: + • Read an existing config.ini or create one from config.ini.example + • Resize-aware layout — redraws correctly when terminal is resized + • Browse sections and keys with arrow-key navigation + • Edit values in-place with a full-featured line editor + • Help overlay (?) shows description + example lines for the current key + • Validate config against known required/optional keys (v) + • Migrate config to newer schema — add missing keys from example (m) + • Save (s), quit with unsaved-changes guard (q / Esc) + +Usage: + python3 scripts/config_tui.py [config.ini] + python3 scripts/config_tui.py # auto-detects config.ini in project root + +Navigation: + ↑ / ↓ move between keys in current section + ← / → or Tab previous / next section + Enter edit selected value + ? help for current key (description + example from config.ini.example) + s save + v validate (show errors / warnings) + m migrate (add missing keys/sections from example) + q / Esc quit (asks to save if unsaved changes) +""" + +import configparser +import curses +import os +import re +import sys +import textwrap +from pathlib import Path +from typing import Dict, List, Optional, Tuple + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +def find_project_root() -> Path: + try: + here = Path(__file__).resolve().parent + for candidate in [here, here.parent, here.parent.parent]: + if (candidate / "pyproject.toml").exists() or (candidate / "meshcore_bot.py").exists(): + return candidate + return here.parent + except Exception: + return Path.cwd() + + +def load_config(path: Path) -> configparser.ConfigParser: + cfg = configparser.ConfigParser(allow_no_value=True, inline_comment_prefixes=None) + cfg.optionxform = str # preserve case + if path.exists(): + try: + cfg.read(str(path), encoding="utf-8") + except (configparser.Error, IOError, OSError, UnicodeDecodeError) as e: + # Return empty config rather than crash; caller may show a warning + cfg._load_error = str(e) # type: ignore[attr-defined] + return cfg + + +def load_example_comments(example_path: Path) -> Dict[str, Dict[str, str]]: + """Parse config.ini.example — return {section: {key: comment_text}}.""" + comments: Dict[str, Dict[str, str]] = {} + if not example_path.exists(): + return comments + try: + current_section = "" + pending: List[str] = [] + with open(example_path, encoding="utf-8", errors="replace") as fh: + for line in fh: + stripped = line.strip() + if stripped.startswith("[") and stripped.endswith("]"): + current_section = stripped[1:-1] + comments.setdefault(current_section, {}) + pending = [] + elif stripped.startswith("#"): + pending.append(stripped[1:].strip()) + elif "=" in stripped and not stripped.startswith("#"): + key = stripped.split("=", 1)[0].strip() + if current_section and pending: + comments.setdefault(current_section, {})[key] = " ".join(pending) + pending = [] + else: + pending = [] + except (IOError, OSError, UnicodeDecodeError): + pass + return comments + + +def load_example_lines(example_path: Path) -> Dict[str, Dict[str, str]]: + """Return {section: {key: 'full raw line from example'}}.""" + lines: Dict[str, Dict[str, str]] = {} + if not example_path.exists(): + return lines + try: + current_section = "" + with open(example_path, encoding="utf-8", errors="replace") as fh: + for line in fh: + stripped = line.strip() + if stripped.startswith("[") and stripped.endswith("]"): + current_section = stripped[1:-1] + lines.setdefault(current_section, {}) + elif "=" in stripped and not stripped.startswith("#"): + key = stripped.split("=", 1)[0].strip() + if current_section: + lines.setdefault(current_section, {})[key] = stripped + except (IOError, OSError, UnicodeDecodeError): + pass + return lines + + +def load_example_keys(example_path: Path) -> Dict[str, Dict[str, str]]: + """Return {section: {key: default_value}} from config.ini.example.""" + keys: Dict[str, Dict[str, str]] = {} + if not example_path.exists(): + return keys + try: + cfg = configparser.ConfigParser(allow_no_value=True) + cfg.optionxform = str + cfg.read(str(example_path), encoding="utf-8") + for section in cfg.sections(): + try: + keys[section] = dict(cfg.items(section)) + except configparser.Error: + keys[section] = {} + except (configparser.Error, IOError, OSError, UnicodeDecodeError): + pass + return keys + + +def validate_config( + cfg: configparser.ConfigParser, + example_keys: Dict[str, Dict[str, str]], +) -> List[Tuple[str, str, str]]: + """Return list of (severity, section, message) issues.""" + issues: List[Tuple[str, str, str]] = [] + try: + required = { + "Connection": {"connection_type"}, + "Bot": {"bot_name"}, + } + for section, req_keys in required.items(): + if not cfg.has_section(section): + issues.append(("ERROR", section, f"Section [{section}] is missing")) + continue + for key in req_keys: + if not cfg.has_option(section, key): + issues.append(("ERROR", section, f"Required key '{key}' is missing")) + for section in cfg.sections(): + if section not in example_keys: + continue + try: + section_opts = cfg.options(section) + except configparser.Error: + continue + for key in section_opts: + if key not in example_keys.get(section, {}): + issues.append(("WARNING", section, f"Unknown key '{key}' (not in example)")) + for section in example_keys: + if not cfg.has_section(section): + issues.append(("INFO", section, f"Optional section [{section}] not configured")) + except Exception as e: + issues.append(("ERROR", "", f"Validation error: {e}")) + if not issues: + issues.append(("INFO", "", "Config looks good — no errors found")) + return issues + + +def migrate_config( + cfg: configparser.ConfigParser, + example_keys: Dict[str, Dict[str, str]], + example_comments: Dict[str, Dict[str, str]], +) -> List[str]: + changes: List[str] = [] + for section, keys in example_keys.items(): + if not cfg.has_section(section): + try: + cfg.add_section(section) + changes.append(f"Added section [{section}]") + except configparser.DuplicateSectionError: + pass + except configparser.Error as e: + changes.append(f"Error adding [{section}]: {e}") + continue + for key, default in keys.items(): + if not cfg.has_option(section, key): + try: + cfg.set(section, key, default or "") + changes.append(f"Added [{section}] {key} = {default!r}") + except configparser.Error as e: + changes.append(f"Error setting [{section}] {key}: {e}") + return changes + + +def save_config(cfg: configparser.ConfigParser, path: Path) -> None: + try: + path.parent.mkdir(parents=True, exist_ok=True) + except (IOError, OSError, PermissionError) as e: + raise IOError(f"Cannot create directory {path.parent}: {e}") from e + try: + with open(path, "w", encoding="utf-8") as fh: + cfg.write(fh) + except (IOError, OSError, PermissionError) as e: + raise IOError(f"Cannot write {path}: {e}") from e + + +# --------------------------------------------------------------------------- +# TUI state +# --------------------------------------------------------------------------- + +class TUIState: + def __init__( + self, + cfg: configparser.ConfigParser, + path: Path, + example_keys: Dict[str, Dict[str, str]], + example_comments: Dict[str, Dict[str, str]], + example_lines: Dict[str, Dict[str, str]], + ) -> None: + self.cfg = cfg + self.path = path + self.example_keys = example_keys + self.example_comments = example_comments + self.example_lines = example_lines + self.dirty = False + self.sections: List[str] = [] + try: + self.sections = cfg.sections() + except Exception: + self.sections = [] + self.section_idx = 0 + self.key_idx = 0 + self.focus: str = "sections" # "sections" or "keys" + self.status_msg = "" + self.status_attr = 0 + + def current_section(self) -> str: + if not self.sections or self.section_idx >= len(self.sections): + return "" + return self.sections[self.section_idx] + + def current_keys(self) -> List[str]: + s = self.current_section() + if not s: + return [] + try: + if self.cfg.has_section(s): + return list(self.cfg.options(s)) + except configparser.Error: + pass + return [] + + def clamp(self) -> None: + if self.sections: + self.section_idx = max(0, min(self.section_idx, len(self.sections) - 1)) + else: + self.section_idx = 0 + keys = self.current_keys() + if keys: + self.key_idx = max(0, min(self.key_idx, len(keys) - 1)) + else: + self.key_idx = 0 + + def set_status(self, msg: str, error: bool = False) -> None: + self.status_msg = msg + try: + self.status_attr = curses.color_pair(3) if error else curses.color_pair(2) + except Exception: + self.status_attr = 0 + + +# --------------------------------------------------------------------------- +# Drawing helpers +# --------------------------------------------------------------------------- + +def _safe_addstr(win, row: int, col: int, text: str, attr: int = 0) -> None: + """addstr that silently ignores out-of-bounds writes.""" + try: + h, w = win.getmaxyx() + if row < 0 or row >= h or col < 0 or col >= w: + return + available = w - col - 1 + if available <= 0: + return + if attr: + win.addstr(row, col, text[:available], attr) + else: + win.addstr(row, col, text[:available]) + except curses.error: + pass + except Exception: + pass + + +def _color(pair: int, fallback: int = 0) -> int: + """Return color_pair(pair), falling back to fallback on terminals without color.""" + try: + return curses.color_pair(pair) + except Exception: + return fallback + + +def draw_header(win, path: Path, dirty: bool) -> None: + try: + h, w = win.getmaxyx() + flag = " [modified]" if dirty else "" + header = f" meshcore-bot config — {path.name}{flag}" + _safe_addstr(win, 0, 0, header.ljust(w - 1), _color(1) | curses.A_BOLD) + except Exception: + pass + + +def draw_footer(win, state: TUIState) -> None: + try: + h, w = win.getmaxyx() + if state.status_msg and h > 2: + _safe_addstr(win, h - 2, 0, f" {state.status_msg} ".ljust(w - 1), state.status_attr) + if state.focus == "sections": + help_line = " ↑↓:section Tab/→:keys pane PgUp/Dn:cycle sections Enter:select s:save q:quit" + else: + help_line = " ↑↓:key Tab/←:sections Enter:edit r:rename a:add d:delete ?:help s:save v:validate m:migrate q:quit" + if h > 1: + _safe_addstr(win, h - 1, 0, help_line.ljust(w - 1), _color(1)) + except Exception: + pass + + +def draw_sections_pane(win, state: TUIState, top: int, left: int, height: int, width: int, scroll: int) -> int: + """Draw sections list. Returns updated scroll offset.""" + try: + if state.section_idx < scroll: + scroll = state.section_idx + if state.section_idx >= scroll + height: + scroll = state.section_idx - height + 1 + + focused = (state.focus == "sections") + for i, section in enumerate(state.sections): + vis = i - scroll + if vis < 0 or vis >= height: + continue + row = top + vis + label = f" [{section}] " + if i == state.section_idx: + if focused: + _safe_addstr(win, row, left, label.ljust(width), _color(4) | curses.A_BOLD) + else: + # Dimmer selection indicator when pane is not focused + _safe_addstr(win, row, left, label.ljust(width), curses.A_BOLD) + else: + _safe_addstr(win, row, left, label.ljust(width)) + except Exception: + pass + return scroll + + +def draw_keys_pane( + win, state: TUIState, top: int, left: int, height: int, width: int, scroll: int +) -> int: + """Draw keys pane. Returns updated scroll offset.""" + try: + keys = state.current_keys() + if not keys: + _safe_addstr(win, top, left, " (no keys) ") + return 0 + + if state.key_idx < scroll: + scroll = state.key_idx + if state.key_idx >= scroll + height: + scroll = state.key_idx - height + 1 + + section = state.current_section() + ex_sec = state.example_keys.get(section, {}) + # Dynamic sections (no fixed keys in example) — never flag keys as unknown + dynamic_section = section in state.example_keys and not ex_sec + + for i, key in enumerate(keys): + vis = i - scroll + if vis < 0 or vis >= height: + continue + row = top + vis + try: + val = state.cfg.get(section, key, fallback="") + except configparser.Error: + val = "(error)" + in_example = dynamic_section or key in ex_sec + marker = " " if in_example else "?" + line = f"{marker} {key} = {val}" + + if i == state.key_idx: + _safe_addstr(win, row, left, line.ljust(width), _color(5) | curses.A_BOLD) + elif not in_example: + _safe_addstr(win, row, left, line, _color(3)) + else: + _safe_addstr(win, row, left, line) + except Exception: + pass + return scroll + + +def draw_status_bar(win, state: TUIState, top: int, left: int, width: int, attr: int = 0) -> None: + """Show section count / key count in the keys pane title bar.""" + try: + section = state.current_section() + keys = state.current_keys() + n_sec = len(state.sections) + n_key = len(keys) + focus_marker = ">" if state.focus == "keys" else " " + title = f"{focus_marker}[{section}] key {state.key_idx + 1}/{n_key} (section {state.section_idx + 1}/{n_sec}) " + _safe_addstr(win, top, left, title.ljust(width), attr or _color(1)) + except Exception: + pass + + +def draw_hint_line(win, state: TUIState, top: int, left: int, width: int) -> None: + """Show a one-line hint for the selected key below the keys pane.""" + try: + section = state.current_section() + keys = state.current_keys() + if not keys or state.key_idx >= len(keys): + return + key = keys[state.key_idx] + comment = state.example_comments.get(section, {}).get(key, "") + max_w = max(4, width - 3) + if comment and len(comment) > max_w: + hint = textwrap.shorten(comment, max_w, placeholder="…") + elif comment: + hint = comment + else: + hint = "(no description — press ? for full help)" + _safe_addstr(win, top, left, f" {hint}", _color(2)) + except Exception: + pass + + +# --------------------------------------------------------------------------- +# Overlays +# --------------------------------------------------------------------------- + +def show_overlay(stdscr, title: str, items: List[Tuple[str, str, str]]) -> None: + """Generic scrollable list overlay. Items: (tag, section, message).""" + try: + curses.curs_set(0) + except curses.error: + pass + scroll = 0 + while True: + try: + h, w = stdscr.getmaxyx() + except curses.error: + return + + if h < 6 or w < 20: + try: + stdscr.getch() + except curses.error: + pass + return + + oh = max(4, min(h - 4, max(10, len(items) + 4))) + ow = max(10, min(w - 4, 92)) + dy = max(0, (h - oh) // 2) + dx = max(0, (w - ow) // 2) + + # Ensure window fits within screen + if dy + oh > h: + oh = h - dy + if dx + ow > w: + ow = w - dx + if oh < 4 or ow < 10: + try: + stdscr.getch() + except curses.error: + pass + return + + try: + win = curses.newwin(oh, ow, dy, dx) + except curses.error: + try: + stdscr.getch() + except curses.error: + pass + return + + try: + win.keypad(True) + win.clear() + try: + win.border() + except curses.error: + pass + _safe_addstr(win, 0, 2, f" {title} (↑↓:scroll q/Esc/Enter:close) ", _color(1)) + visible = max(1, oh - 2) + for i, (tag, sec, msg) in enumerate(items): + vis = i - scroll + if vis < 0 or vis >= visible: + continue + row = 1 + vis + if tag == "ERROR": + attr = _color(3) + elif tag == "WARNING": + attr = _color(6) + else: + attr = _color(2) + label = f" [{tag}] {sec}: {msg}" if sec else f" [{tag}] {msg}" + _safe_addstr(win, row, 1, label, attr) + win.refresh() + except curses.error: + pass + + try: + ch = win.getch() + except curses.error: + ch = -1 + + try: + del win + stdscr.touchwin() + except curses.error: + pass + + if ch == curses.KEY_RESIZE: + scroll = 0 + continue + elif ch in (ord("q"), ord("Q"), 27, 10, 13, curses.KEY_ENTER): + break + elif ch == curses.KEY_DOWN: + scroll = min(scroll + 1, max(0, len(items) - visible)) + elif ch == curses.KEY_UP: + scroll = max(0, scroll - 1) + elif ch == curses.KEY_NPAGE: + scroll = min(scroll + visible, max(0, len(items) - visible)) + elif ch == curses.KEY_PPAGE: + scroll = max(0, scroll - visible) + + try: + stdscr.touchwin() + except curses.error: + pass + + +def show_key_help(stdscr, state: TUIState) -> None: + """Show full help for the currently selected key.""" + try: + section = state.current_section() + keys = state.current_keys() + if not keys or state.key_idx >= len(keys): + return + key = keys[state.key_idx] + comment = state.example_comments.get(section, {}).get(key, "(no description available)") + example = state.example_lines.get(section, {}).get(key, "") + try: + current_val = state.cfg.get(section, key, fallback="") + except configparser.Error: + current_val = "(error reading value)" + default_val = state.example_keys.get(section, {}).get(key, "") + + lines: List[Tuple[str, str, str]] = [ + ("INFO", "Section", section), + ("INFO", "Key", key), + ("INFO", "Current value", repr(current_val)), + ("INFO", "Default (example)", repr(default_val)), + ("INFO", "", ""), + ("INFO", "Description", ""), + ] + for para in textwrap.wrap(comment, 72) or ["(none)"]: + lines.append(("INFO", "", f" {para}")) + if example: + lines.append(("INFO", "", "")) + lines.append(("INFO", "Example line", "")) + lines.append(("INFO", "", f" {example}")) + + show_overlay(stdscr, f"Help: [{section}] {key}", lines) + except Exception as e: + state.set_status(f"Help error: {e}", error=True) + + +# --------------------------------------------------------------------------- +# Inline editor +# --------------------------------------------------------------------------- + +_EDIT_VALUE_DEPTH = 0 +_EDIT_VALUE_MAX_DEPTH = 20 + + +def edit_value(stdscr, prompt: str, current: str) -> Optional[str]: + """One-line editor dialog. Returns new value or None if cancelled.""" + global _EDIT_VALUE_DEPTH + _EDIT_VALUE_DEPTH += 1 + if _EDIT_VALUE_DEPTH > _EDIT_VALUE_MAX_DEPTH: + _EDIT_VALUE_DEPTH -= 1 + return None + + try: + return _edit_value_impl(stdscr, prompt, current) + finally: + _EDIT_VALUE_DEPTH -= 1 + + +def _edit_value_impl(stdscr, prompt: str, current: str) -> Optional[str]: + while True: + try: + h, w = stdscr.getmaxyx() + except curses.error: + return None + + dw = max(30, min(w - 4, 84)) + dh = 6 + dy = max(0, (h - dh) // 2) + dx = max(0, (w - dw) // 2) + + if dy + dh > h or dx + dw > w or dh < 4 or dw < 10: + return None + + try: + win = curses.newwin(dh, dw, dy, dx) + except curses.error: + return None + + try: + win.keypad(True) + except curses.error: + pass + + try: + try: + win.border() + except curses.error: + pass + _safe_addstr(win, 0, 2, " Edit Value ", _color(1) | curses.A_BOLD) + _safe_addstr(win, 1, 2, prompt[:dw - 4]) + _safe_addstr(win, 2, 2, "─" * max(0, dw - 4)) + _safe_addstr(win, 4, 2, "Enter=confirm Esc=cancel Ctrl-A=home Ctrl-E=end Ctrl-K=clear", _color(2)) + win.refresh() + except curses.error: + pass + + buf = list(current) + pos = len(buf) + inner_w = max(1, dw - 4) + result: Optional[str] = None + + try: + curses.curs_set(1) + except curses.error: + pass + + def redraw_input() -> None: + try: + win.move(3, 2) + win.clrtoeol() + start = max(0, pos - inner_w + 1) + display = "".join(buf)[start:start + inner_w] + cursor_col = pos - start + try: + win.addstr(3, 2, display) + win.move(3, 2 + cursor_col) + except curses.error: + pass + win.refresh() + except curses.error: + pass + + try: + redraw_input() + except Exception: + pass + + done = False + resize_requested = False + while not done: + try: + ch = win.getch() + except curses.error: + done = True + break + + if ch == curses.KEY_RESIZE: + resize_requested = True + done = True + elif ch in (curses.KEY_ENTER, 10, 13): + result = "".join(buf) + done = True + elif ch == 27: # Esc + done = True + elif ch in (curses.KEY_BACKSPACE, 127, 8): + if pos > 0: + buf.pop(pos - 1) + pos -= 1 + elif ch == curses.KEY_DC: + if pos < len(buf): + buf.pop(pos) + elif ch == curses.KEY_LEFT: + pos = max(0, pos - 1) + elif ch == curses.KEY_RIGHT: + pos = min(len(buf), pos + 1) + elif ch == curses.KEY_HOME or ch == 1: # Ctrl-A + pos = 0 + elif ch == curses.KEY_END or ch == 5: # Ctrl-E + pos = len(buf) + elif ch == 11: # Ctrl-K — clear to end + buf = buf[:pos] + elif ch == 21: # Ctrl-U — clear whole line + buf = [] + pos = 0 + elif 32 <= ch < 256: + try: + buf.insert(pos, chr(ch)) + pos += 1 + except Exception: + pass + if not done: + try: + redraw_input() + except Exception: + pass + + try: + curses.curs_set(0) + except curses.error: + pass + try: + del win + stdscr.touchwin() + stdscr.refresh() + except curses.error: + pass + + if resize_requested: + # Terminal resized during edit — restart dialog preserving buffer + return edit_value(stdscr, prompt, "".join(buf)) + + return result + + +# --------------------------------------------------------------------------- +# Key management helpers (add / rename / delete) +# --------------------------------------------------------------------------- + +def action_add_key(stdscr, state: TUIState) -> None: + """Prompt for a new key name and value, then add it to the current section.""" + section = state.current_section() + if not section: + return + new_key = edit_value(stdscr, f"[{section}] New key name", "") + if not new_key: + state.set_status("Add cancelled — no key name entered") + return + new_key = new_key.strip() + if not new_key: + state.set_status("Add cancelled — key name was blank") + return + if state.cfg.has_option(section, new_key): + state.set_status(f"Key '{new_key}' already exists", error=True) + return + new_val = edit_value(stdscr, f"[{section}] {new_key} (value)", "") + if new_val is None: + state.set_status("Add cancelled") + return + try: + state.cfg.set(section, new_key, new_val) + state.dirty = True + # Move selection to the newly added key + keys = list(state.cfg.options(section)) + try: + state.key_idx = keys.index(new_key) + except ValueError: + pass + state.set_status(f"Added [{section}] {new_key}") + except configparser.Error as e: + state.set_status(f"Cannot add key: {e}", error=True) + + +def action_rename_key(stdscr, state: TUIState) -> None: + """Rename the currently selected key (useful to change a scheduled-message time).""" + section = state.current_section() + keys = state.current_keys() + if not keys or state.key_idx >= len(keys): + return + old_key = keys[state.key_idx] + new_key = edit_value(stdscr, f"[{section}] Rename key (was: {old_key})", old_key) + if new_key is None: + state.set_status("Rename cancelled") + return + new_key = new_key.strip() + if not new_key or new_key == old_key: + state.set_status("Rename cancelled — unchanged") + return + if state.cfg.has_option(section, new_key): + state.set_status(f"Key '{new_key}' already exists", error=True) + return + try: + old_val = state.cfg.get(section, old_key, fallback="") + except configparser.Error: + old_val = "" + try: + state.cfg.remove_option(section, old_key) + state.cfg.set(section, new_key, old_val) + state.dirty = True + # Re-select the renamed key + new_keys = list(state.cfg.options(section)) + try: + state.key_idx = new_keys.index(new_key) + except ValueError: + state.key_idx = max(0, min(state.key_idx, len(new_keys) - 1)) + state.set_status(f"Renamed '{old_key}' → '{new_key}'") + except configparser.Error as e: + state.set_status(f"Cannot rename key: {e}", error=True) + + +def action_delete_key(stdscr, state: TUIState) -> None: + """Delete the currently selected key after confirmation.""" + section = state.current_section() + keys = state.current_keys() + if not keys or state.key_idx >= len(keys): + return + key = keys[state.key_idx] + if not confirm(stdscr, f"Delete [{section}] {key}?"): + state.set_status("Delete cancelled") + return + try: + state.cfg.remove_option(section, key) + state.dirty = True + new_keys = state.current_keys() + state.key_idx = max(0, min(state.key_idx, len(new_keys) - 1)) + state.set_status(f"Deleted [{section}] {key}") + except configparser.Error as e: + state.set_status(f"Cannot delete key: {e}", error=True) + + +# --------------------------------------------------------------------------- +# Confirmation dialog +# --------------------------------------------------------------------------- + +def confirm(stdscr, question: str) -> bool: + """Y/N prompt. Returns True if user presses y/Y.""" + try: + h, w = stdscr.getmaxyx() + dw = max(20, min(w - 4, 60)) + dh = 4 + dy = max(0, (h - dh) // 2) + dx = max(0, (w - dw) // 2) + + if dy + dh > h or dx + dw > w: + return False + + try: + win = curses.newwin(dh, dw, dy, dx) + except curses.error: + return False + + try: + try: + win.border() + except curses.error: + pass + _safe_addstr(win, 1, 2, question[:dw - 4], _color(6) | curses.A_BOLD) + _safe_addstr(win, 2, 2, "Press Y to confirm, any other key to cancel.", _color(2)) + win.refresh() + ch = win.getch() + except curses.error: + ch = -1 + finally: + try: + del win + stdscr.touchwin() + stdscr.refresh() + except curses.error: + pass + + return ch in (ord("y"), ord("Y")) + except Exception: + return False + + +# --------------------------------------------------------------------------- +# Main TUI loop +# --------------------------------------------------------------------------- + +def tui_main(stdscr, state: TUIState) -> None: + try: + curses.curs_set(0) + except curses.error: + pass + + try: + curses.start_color() + curses.use_default_colors() + curses.init_pair(1, curses.COLOR_WHITE, curses.COLOR_BLUE) # header / footer / titles + curses.init_pair(2, curses.COLOR_BLACK, curses.COLOR_GREEN) # ok / hint / status + curses.init_pair(3, curses.COLOR_WHITE, curses.COLOR_RED) # error / unknown key + curses.init_pair(4, curses.COLOR_BLACK, curses.COLOR_CYAN) # selected section + curses.init_pair(5, curses.COLOR_BLACK, curses.COLOR_YELLOW) # selected key + curses.init_pair(6, curses.COLOR_BLACK, curses.COLOR_YELLOW) # warning / confirm + except curses.error: + pass # monochrome terminal — continue without color + + try: + stdscr.keypad(True) + except curses.error: + pass + + # Per-pane scroll offsets + sec_scroll = 0 + key_scroll = 0 + + while True: + try: + stdscr.clear() + h, w = stdscr.getmaxyx() + except curses.error: + continue + + if h < 12 or w < 50: + _safe_addstr(stdscr, 0, 0, "Terminal too small — resize and press any key") + try: + stdscr.refresh() + stdscr.getch() + except curses.error: + pass + continue + + # ── Layout ────────────────────────────────────────────────────────── + # Row 0 : header + # Row 1 : section pane title | keys pane title bar + # Rows 2..body_h+1 : section list | keys list (body_h rows) + # body_h+2 : hint line + # h-2 : status message + # h-1 : key bindings footer + # Total: 1+1+body_h+1+1+1 = body_h+5 = h → body_h = h-5 + + section_w = max(16, min(26, w // 5)) + divider = section_w # column of the vertical divider + keys_left = divider + 1 + keys_w = max(1, w - keys_left - 1) + + body_h = max(1, h - 5) # rows of pane content + hint_row = body_h + 2 # one row below the last pane row + + # ── Draw phase — wrapped so a single bad render doesn't abort ─────── + try: + draw_header(stdscr, state.path, state.dirty) + sec_title_attr = (_color(1) | curses.A_BOLD) if state.focus == "sections" else _color(1) + keys_title_attr = (_color(1) | curses.A_BOLD) if state.focus == "keys" else _color(1) + focus_marker = ">" if state.focus == "sections" else " " + _safe_addstr(stdscr, 1, 0, f"{focus_marker}Sections ({len(state.sections)}) ".ljust(section_w), sec_title_attr) + draw_status_bar(stdscr, state, 1, keys_left, keys_w, keys_title_attr) + + for row in range(2, body_h + 2): + _safe_addstr(stdscr, row, divider, "│") + + sec_scroll = draw_sections_pane(stdscr, state, 2, 0, body_h, section_w, sec_scroll) + key_scroll = draw_keys_pane(stdscr, state, 2, keys_left, body_h, keys_w, key_scroll) + draw_hint_line(stdscr, state, hint_row, 0, w) + draw_footer(stdscr, state) + except curses.error: + pass + except Exception: + pass + + try: + stdscr.refresh() + except curses.error: + pass + + # ── Input ───────────────────────────────────────────────────────────── + try: + ch = stdscr.getch() + except curses.error: + continue + except KeyboardInterrupt: + if state.dirty: + try: + if confirm(stdscr, "Unsaved changes — quit without saving?"): + break + except Exception: + break + else: + break + continue + + if ch == curses.KEY_RESIZE: + key_scroll = 0 + sec_scroll = 0 + state.status_msg = "" + + # ── Tab: toggle focus between panes ───────────────────────────────── + elif ch in (ord("\t"), curses.KEY_BTAB): + if ch == curses.KEY_BTAB or state.focus == "keys": + state.focus = "sections" + else: + state.focus = "keys" + state.status_msg = "" + + # ── Arrow up/down: navigate within the focused pane ───────────────── + elif ch == curses.KEY_UP: + if state.focus == "sections": + state.section_idx = max(0, state.section_idx - 1) + state.key_idx = 0 + key_scroll = 0 + else: + state.key_idx = max(0, state.key_idx - 1) + state.status_msg = "" + + elif ch == curses.KEY_DOWN: + if state.focus == "sections": + state.section_idx = min(max(0, len(state.sections) - 1), state.section_idx + 1) + state.key_idx = 0 + key_scroll = 0 + else: + keys = state.current_keys() + if keys: + state.key_idx = min(len(keys) - 1, state.key_idx + 1) + state.status_msg = "" + + # ── Left: from keys pane → focus sections pane ────────────────────── + elif ch == curses.KEY_LEFT: + if state.focus == "keys": + state.focus = "sections" + else: + # Already in sections pane — cycle to previous section + state.section_idx = (state.section_idx - 1) % max(1, len(state.sections)) + state.key_idx = 0 + key_scroll = 0 + state.status_msg = "" + + # ── Right: from sections pane → focus keys pane ───────────────────── + elif ch == curses.KEY_RIGHT: + if state.focus == "sections": + state.focus = "keys" + else: + # Already in keys pane — cycle to next section + state.section_idx = (state.section_idx + 1) % max(1, len(state.sections)) + state.key_idx = 0 + key_scroll = 0 + state.status_msg = "" + + # ── PgUp/PgDn: cycle sections from either pane ────────────────────── + elif ch == curses.KEY_PPAGE: + state.section_idx = (state.section_idx - 1) % max(1, len(state.sections)) + state.key_idx = 0 + key_scroll = 0 + state.status_msg = "" + + elif ch == curses.KEY_NPAGE: + state.section_idx = (state.section_idx + 1) % max(1, len(state.sections)) + state.key_idx = 0 + key_scroll = 0 + state.status_msg = "" + + elif ch in (curses.KEY_HOME,): + state.section_idx = 0 + state.key_idx = 0 + key_scroll = sec_scroll = 0 + + # ── Enter: on sections pane → move focus to keys; on keys → edit ──── + elif ch in (curses.KEY_ENTER, 10, 13): + if state.focus == "sections": + state.focus = "keys" + state.status_msg = "" + else: + try: + section = state.current_section() + keys = state.current_keys() + if keys and state.key_idx < len(keys): + key = keys[state.key_idx] + try: + current_val = state.cfg.get(section, key, fallback="") + except configparser.Error: + current_val = "" + new_val = edit_value(stdscr, f"[{section}] {key}", current_val) + if new_val is not None and new_val != current_val: + try: + state.cfg.set(section, key, new_val) + state.dirty = True + state.set_status(f"Updated [{section}] {key}") + except configparser.Error as e: + state.set_status(f"Cannot set value: {e}", error=True) + except Exception as e: + state.set_status(f"Edit error: {e}", error=True) + + elif ch in (ord("?"),): + try: + show_key_help(stdscr, state) + except Exception as e: + state.set_status(f"Help error: {e}", error=True) + + elif ch in (ord("a"), ord("A")): + if state.focus == "keys": + try: + action_add_key(stdscr, state) + except Exception as e: + state.set_status(f"Add error: {e}", error=True) + + elif ch in (ord("r"), ord("R")): + if state.focus == "keys": + try: + action_rename_key(stdscr, state) + except Exception as e: + state.set_status(f"Rename error: {e}", error=True) + + elif ch in (ord("d"), ord("D"), curses.KEY_DC): + if state.focus == "keys": + try: + action_delete_key(stdscr, state) + except Exception as e: + state.set_status(f"Delete error: {e}", error=True) + + elif ch in (ord("s"), ord("S")): + try: + save_config(state.cfg, state.path) + state.dirty = False + state.set_status(f"Saved → {state.path}") + except Exception as e: + state.set_status(f"Save failed: {e}", error=True) + + elif ch in (ord("v"), ord("V")): + try: + issues = validate_config(state.cfg, state.example_keys) + show_overlay(stdscr, "Validation Results", issues) + except Exception as e: + state.set_status(f"Validate error: {e}", error=True) + + elif ch in (ord("m"), ord("M")): + try: + changes = migrate_config(state.cfg, state.example_keys, state.example_comments) + try: + state.sections = state.cfg.sections() + except Exception: + pass + if changes: + state.dirty = True + show_overlay( + stdscr, + f"Migration — {len(changes)} change(s)", + [("INFO", "", c) for c in changes], + ) + state.set_status(f"Migrated {len(changes)} item(s) — press s to save") + else: + state.set_status("Config is up-to-date — nothing to migrate") + except Exception as e: + state.set_status(f"Migrate error: {e}", error=True) + + elif ch in (ord("q"), ord("Q"), 27): + try: + if state.dirty: + if confirm(stdscr, "Unsaved changes — quit without saving?"): + break + else: + break + except Exception: + break + + try: + state.clamp() + except Exception: + pass + + +# --------------------------------------------------------------------------- +# Entry point +# --------------------------------------------------------------------------- + +def main() -> None: + try: + project_root = find_project_root() + except Exception: + project_root = Path.cwd() + + config_path = Path(sys.argv[1]).resolve() if len(sys.argv) > 1 else project_root / "config.ini" + example_path = project_root / "config.ini.example" + + if not config_path.exists(): + if not example_path.exists(): + print( + f"ERROR: Neither {config_path} nor {example_path} found.\n" + "Run this from the meshcore-bot project root.", + file=sys.stderr, + ) + sys.exit(1) + try: + import shutil + print(f"config.ini not found — creating from {example_path.name}…") + shutil.copy(str(example_path), str(config_path)) + print(f"Created {config_path}") + except (IOError, OSError, shutil.Error) as e: + print(f"ERROR: Could not create config from example: {e}", file=sys.stderr) + sys.exit(1) + + try: + cfg = load_config(config_path) + except Exception as e: + print(f"ERROR: Could not load config: {e}", file=sys.stderr) + sys.exit(1) + + # Warn if the config had a parse error but continue with whatever was loaded + load_error = getattr(cfg, "_load_error", None) + if load_error: + print(f"WARNING: Config parse error (partial load): {load_error}", file=sys.stderr) + + try: + example_keys = load_example_keys(example_path) + example_comments = load_example_comments(example_path) + example_lines = load_example_lines(example_path) + except Exception as e: + print(f"WARNING: Could not load example file ({e}); help/migrate features disabled.", file=sys.stderr) + example_keys = {} + example_comments = {} + example_lines = {} + + try: + state = TUIState(cfg, config_path, example_keys, example_comments, example_lines) + except Exception as e: + print(f"ERROR: Could not initialise TUI state: {e}", file=sys.stderr) + sys.exit(1) + + try: + curses.wrapper(tui_main, state) + except KeyboardInterrupt: + pass + except curses.error as e: + print(f"ERROR: Terminal error: {e}", file=sys.stderr) + sys.exit(1) + except Exception as e: + print(f"ERROR: Unexpected error: {e}", file=sys.stderr) + sys.exit(1) + + print("Exited config editor.") + if state.dirty: + try: + answer = input("Unsaved changes. Save now? [y/N] ").strip().lower() + if answer in ("y", "yes"): + save_config(state.cfg, config_path) + print(f"Saved to {config_path}") + except (KeyboardInterrupt, EOFError): + print() # clean newline + except Exception as e: + print(f"Save failed: {e}", file=sys.stderr) + + +if __name__ == "__main__": + main()