mirror of
https://github.com/agessaman/meshcore-bot.git
synced 2026-04-01 13:05:38 +00:00
Add scripts/config_tui.py: browse, edit, save, validate, and migrate config.ini interactively. Key bindings: r rename key, a add key+value, d/Delete remove key with confirmation. Dynamic sections such as [Scheduled_Messages] suppress the unknown-key ? marker. Access via make config or CONFIG=path make config.
1249 lines
44 KiB
Python
Executable File
1249 lines
44 KiB
Python
Executable File
#!/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()
|