k
This commit is contained in:
327
TUI/modalscreen/config_modal.py
Normal file
327
TUI/modalscreen/config_modal.py
Normal file
@@ -0,0 +1,327 @@
|
||||
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
|
||||
|
||||
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("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("#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("#back-btn", Button).display = (self.editing_item_name is not None)
|
||||
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}"),
|
||||
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"))
|
||||
|
||||
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":
|
||||
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":
|
||||
# Create a simple dialog or default folder store
|
||||
new_name = "new_folder"
|
||||
if "store" not in self.config_data:
|
||||
self.config_data["store"] = {}
|
||||
if "folder" not in self.config_data["store"]:
|
||||
self.config_data["store"]["folder"] = {}
|
||||
self.config_data["store"]["folder"][new_name] = {"NAME": new_name, "path": ""}
|
||||
self.editing_item_type = "store-folder"
|
||||
self.editing_item_name = new_name
|
||||
self.refresh_view()
|
||||
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()
|
||||
|
||||
@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)
|
||||
Reference in New Issue
Block a user