from textual.app import ComposeResult from textual.screen import ModalScreen from textual.containers import Container, Horizontal, Vertical, ScrollableContainer from textual.widgets import Static, Button, Input, Label, ListView, ListItem, Rule, OptionList, Footer from textual import on from textual.message import Message from typing import Dict, Any, List, Optional import os import json from pathlib import Path from SYS.config import load_config, save_config from Store.registry import _discover_store_classes, _required_keys_for from ProviderCore.registry import list_providers from TUI.modalscreen.selection_modal import SelectionModal class ConfigModal(ModalScreen): """A modal for editing the configuration.""" CSS = """ ConfigModal { align: center middle; background: $boost; } #config-container { width: 90%; height: 90%; background: $panel; border: thick $primary; padding: 1; } .section-title { background: $accent; color: $text; padding: 0 1; margin-bottom: 1; text-align: center; text-style: bold; height: 3; content-align: center middle; } #config-sidebar { width: 25%; border-right: solid $surface; } #config-content { width: 75%; padding: 1; } .config-field { margin-bottom: 1; height: auto; } .config-label { width: 100%; text-style: bold; color: $accent; } .config-input { width: 100%; } #config-actions { height: 3; align: right middle; } .item-row { height: 3; margin-bottom: 1; padding: 0 1; border: solid $surface; } .item-label { width: 1fr; content-align: left middle; } Button { margin: 0 1; } """ def __init__(self) -> None: super().__init__() # Load config from the workspace root (parent of SYS) workspace_root = Path(__file__).resolve().parent.parent.parent self.config_data = load_config(config_dir=workspace_root) self.current_category = "globals" self.editing_item_type = None # 'store' or 'provider' self.editing_item_name = None self.workspace_root = workspace_root def compose(self) -> ComposeResult: with Container(id="config-container"): yield Static("CONFIGURATION EDITOR", classes="section-title") with Horizontal(): with Vertical(id="config-sidebar"): yield Label("Categories", classes="config-label") with ListView(id="category-list"): yield ListItem(Label("Global Settings"), id="cat-globals") yield ListItem(Label("Stores"), id="cat-stores") yield ListItem(Label("Providers"), id="cat-providers") with Vertical(id="config-content"): yield ScrollableContainer(id="fields-container") with Horizontal(id="config-actions"): yield Button("Save", variant="success", id="save-btn") yield Button("Add Store", variant="primary", id="add-store-btn") yield Button("Add Provider", variant="primary", id="add-provider-btn") yield Button("Back", id="back-btn") yield Button("Close", variant="error", id="cancel-btn") def on_mount(self) -> None: self.query_one("#add-store-btn", Button).display = False self.query_one("#add-provider-btn", Button).display = False self.query_one("#back-btn", Button).display = False self.refresh_view() def refresh_view(self) -> None: container = self.query_one("#fields-container", ScrollableContainer) # Clear existing synchronously for child in list(container.children): child.remove() # Update visibility of buttons try: self.query_one("#add-store-btn", Button).display = (self.current_category == "stores" and self.editing_item_name is None) self.query_one("#add-provider-btn", Button).display = (self.current_category == "providers" and self.editing_item_name is None) self.query_one("#back-btn", Button).display = (self.editing_item_name is not None) self.query_one("#save-btn", Button).display = (self.editing_item_name is not None or self.current_category == "globals") except Exception: pass # We mount using call_after_refresh to ensure the removals are processed by Textual # before we try to mount new widgets with potentially duplicate IDs. def do_mount(): if self.editing_item_name: self.render_item_editor(container) elif self.current_category == "globals": self.render_globals(container) elif self.current_category == "stores": self.render_stores(container) elif self.current_category == "providers": self.render_providers(container) self.call_after_refresh(do_mount) def render_globals(self, container: ScrollableContainer) -> None: container.mount(Label("General Configuration", classes="config-label")) for k, v in self.config_data.items(): if not isinstance(v, dict) and not k.startswith("_"): container.mount(Label(k)) container.mount(Input(value=str(v), id=f"global-{k}", classes="config-input")) def render_stores(self, container: ScrollableContainer) -> None: container.mount(Label("Configured Stores", classes="config-label")) stores = self.config_data.get("store", {}) if not stores: container.mount(Static("No stores configured.")) else: # stores is structured as: {type: {name: config}} for stype, instances in stores.items(): if isinstance(instances, dict): for name, conf in instances.items(): row = Horizontal( Static(f"{name} ({stype})", classes="item-label"), Button("Edit", id=f"edit-store-{stype}-{name}"), Button("Delete", variant="error", id=f"del-store-{stype}-{name}"), classes="item-row" ) container.mount(row) def render_providers(self, container: ScrollableContainer) -> None: container.mount(Label("Configured Providers", classes="config-label")) providers = self.config_data.get("provider", {}) if not providers: container.mount(Static("No providers configured.")) else: for name, _ in providers.items(): row = Horizontal( Static(name, classes="item-label"), Button("Edit", id=f"edit-provider-{name}"), Button("Delete", variant="error", id=f"del-provider-{name}"), classes="item-row" ) container.mount(row) def render_item_editor(self, container: ScrollableContainer) -> None: item_type = str(self.editing_item_type or "") item_name = str(self.editing_item_name or "") # Parse item_type for store-{stype} or just provider if item_type.startswith("store-"): stype = item_type.replace("store-", "") container.mount(Label(f"Editing Store: {item_name} ({stype})", classes="config-label")) section = self.config_data.get("store", {}).get(stype, {}).get(item_name, {}) else: container.mount(Label(f"Editing {item_type.capitalize()}: {item_name}", classes="config-label")) section = self.config_data.get(item_type, {}).get(item_name, {}) # Show all existing keys existing_keys_upper = set() for k, v in section.items(): if k.startswith("_"): continue # Deduplicate keys case-insensitively (e.g. name vs NAME vs Name) k_upper = k.upper() if k_upper in existing_keys_upper: continue existing_keys_upper.add(k_upper) container.mount(Label(k)) container.mount(Input(value=str(v), id=f"item-{k}", classes="config-input")) # If it's a store, we might have required keys if item_type.startswith("store-"): stype = item_type.replace("store-", "") classes = _discover_store_classes() if stype in classes: required_keys = _required_keys_for(classes[stype]) for rk in required_keys: # Case-insensitive deduplication (fix path vs PATH) if rk.upper() not in existing_keys_upper: container.mount(Label(rk)) container.mount(Input(value="", id=f"item-{rk}", classes="config-input")) # If it's a provider, we might have required keys if item_type == "provider": from ProviderCore.registry import get_provider_class try: pcls = get_provider_class(item_name) if pcls: required_keys = pcls.required_config_keys() for rk in required_keys: if rk.upper() not in existing_keys_upper: container.mount(Label(rk)) container.mount(Input(value="", id=f"item-{rk}", classes="config-input")) except Exception: pass def create_field(self, name: str, value: Any, id: str) -> Vertical: # This method is now unused - we mount labels and inputs directly v = Vertical(classes="config-field") return v def on_list_view_selected(self, event: ListView.Selected) -> None: if not event.item: return if event.item.id == "cat-globals": self.current_category = "globals" elif event.item.id == "cat-stores": self.current_category = "stores" elif event.item.id == "cat-providers": self.current_category = "providers" self.editing_item_name = None self.editing_item_type = None self.refresh_view() def on_button_pressed(self, event: Button.Pressed) -> None: bid = event.button.id if not bid: return if bid == "cancel-btn": self.dismiss() elif bid == "back-btn": self.editing_item_name = None self.editing_item_type = None self.refresh_view() elif bid == "save-btn": if not self.validate_current_editor(): return self.save_all() self.notify("Configuration saved!") # Return to the main list view within the current category self.editing_item_name = None self.editing_item_type = None self.refresh_view() elif bid.startswith("edit-store-"): parts = bid.replace("edit-store-", "").split("-", 1) if len(parts) == 2: stype, name = parts self.editing_item_type = f"store-{stype}" self.editing_item_name = name self.refresh_view() elif bid.startswith("edit-provider-"): self.editing_item_name = bid.replace("edit-provider-", "") self.editing_item_type = "provider" self.refresh_view() elif bid == "add-store-btn": options = list(_discover_store_classes().keys()) self.app.push_screen(SelectionModal("Select Store Type", options), callback=self.on_store_type_selected) elif bid == "add-provider-btn": options = list(list_providers().keys()) self.app.push_screen(SelectionModal("Select Provider Type", options), callback=self.on_provider_type_selected) elif bid.startswith("del-store-"): parts = bid.replace("del-store-", "").split("-", 1) if len(parts) == 2: stype, name = parts if "store" in self.config_data and stype in self.config_data["store"]: if name in self.config_data["store"][stype]: del self.config_data["store"][stype][name] self.refresh_view() elif bid.startswith("del-provider-"): name = bid.replace("del-provider-", "") if "provider" in self.config_data and name in self.config_data["provider"]: del self.config_data["provider"][name] self.refresh_view() def on_store_type_selected(self, stype: str) -> None: if not stype: return new_name = f"new_{stype}" if "store" not in self.config_data: self.config_data["store"] = {} if stype not in self.config_data["store"]: self.config_data["store"][stype] = {} # Default config for the new store new_config = {"NAME": new_name} classes = _discover_store_classes() if stype in classes: required = _required_keys_for(classes[stype]) for rk in required: if rk.upper() != "NAME": new_config[rk] = "" self.config_data["store"][stype][new_name] = new_config self.editing_item_type = f"store-{stype}" self.editing_item_name = new_name self.refresh_view() def on_provider_type_selected(self, ptype: str) -> None: if not ptype: return if "provider" not in self.config_data: self.config_data["provider"] = {} # For providers, they are usually top-level entries in 'provider' dict if ptype not in self.config_data["provider"]: # Get required keys if possible from ProviderCore.registry import get_provider_class try: pcls = get_provider_class(ptype) new_config = {} if pcls: required = pcls.required_config_keys() for rk in required: new_config[rk] = "" self.config_data["provider"][ptype] = new_config except Exception: self.config_data["provider"][ptype] = {} self.editing_item_type = "provider" self.editing_item_name = ptype self.refresh_view() @on(Input.Changed) def on_input_changed(self, event: Input.Changed) -> None: if not event.input.id: return if event.input.id.startswith("global-"): key = event.input.id.replace("global-", "") self.config_data[key] = event.value elif event.input.id.startswith("item-") and self.editing_item_name: key = event.input.id.replace("item-", "") it = str(self.editing_item_type or "") inm = str(self.editing_item_name or "") # Handle nested store structure if it.startswith("store-"): stype = it.replace("store-", "") if "store" not in self.config_data: self.config_data["store"] = {} if stype not in self.config_data["store"]: self.config_data["store"][stype] = {} if inm not in self.config_data["store"][stype]: self.config_data["store"][stype][inm] = {} self.config_data["store"][stype][inm][key] = event.value else: # Provider or other top-level sections if it not in self.config_data: self.config_data[it] = {} if inm not in self.config_data[it]: self.config_data[it][inm] = {} self.config_data[it][inm][key] = event.value def save_all(self) -> None: save_config(self.config_data, config_dir=self.workspace_root) def validate_current_editor(self) -> bool: """Ensure all required fields for the current item are filled.""" if not self.editing_item_name: return True item_type = str(self.editing_item_type or "") item_name = str(self.editing_item_name or "") required_keys = [] section = {} if item_type.startswith("store-"): stype = item_type.replace("store-", "") classes = _discover_store_classes() if stype in classes: required_keys = _required_keys_for(classes[stype]) section = self.config_data.get("store", {}).get(stype, {}).get(item_name, {}) elif item_type == "provider": from ProviderCore.registry import get_provider_class try: pcls = get_provider_class(item_name) if pcls: required_keys = pcls.required_config_keys() except Exception: pass section = self.config_data.get("provider", {}).get(item_name, {}) # Check required keys for rk in required_keys: # Case-insensitive lookup for the required key in the current section val = None rk_upper = rk.upper() for k, v in section.items(): if k.upper() == rk_upper: val = v break if not val or not str(val).strip(): self.notify(f"Required field '{rk}' cannot be blank", severity="error") return False return True