This commit is contained in:
2026-01-30 16:24:08 -08:00
parent e57dcf2190
commit 20045b8de8
5 changed files with 989 additions and 79 deletions

View File

@@ -1,17 +1,18 @@
import re
from copy import deepcopy
from typing import Any, Dict, List, Optional
from typing import Any, Dict, List, Optional, Iterable
import traceback
from textual import on, work
from textual.app import ComposeResult
from textual.containers import Container, Horizontal, Vertical, ScrollableContainer
from textual.screen import ModalScreen
from textual.widgets import Static, Button, Input, Label, ListView, ListItem, Rule, Select
from textual.widgets import Static, Button, Input, Label, ListView, ListItem, Rule, Select, Checkbox
from pathlib import Path
from SYS.config import load_config, save_config, reload_config, global_config, count_changed_entries, ConfigSaveConflict
from SYS.database import db
from SYS.logger import log
from SYS.logger import log, debug
from Store.registry import _discover_store_classes, _required_keys_for
from ProviderCore.registry import list_providers
from TUI.modalscreen.matrix_room_picker import MatrixRoomPicker
@@ -81,11 +82,6 @@ class ConfigModal(ModalScreen):
width: 1fr;
}
.paste-btn {
width: 10;
margin-left: 1;
}
#config-actions {
height: 3;
align: right middle;
@@ -112,6 +108,20 @@ class ConfigModal(ModalScreen):
Button {
margin: 0 1;
}
/* Inline matrix rooms list sizing & style (larger, scrollable) */
#matrix-rooms-inline {
height: 16;
border: solid $surface;
padding: 1;
margin-bottom: 1;
}
.matrix-room-row {
border-bottom: solid $surface;
padding: 1 0;
align: left middle;
}
"""
def __init__(self) -> None:
@@ -126,6 +136,9 @@ class ConfigModal(ModalScreen):
self._matrix_status: Optional[Static] = None
self._matrix_test_running = False
self._editor_snapshot: Optional[Dict[str, Any]] = None
# Inline matrix rooms controls
self._matrix_inline_list: Optional[ListView] = None
self._matrix_inline_checkbox_map: Dict[str, str] = {}
# Path to the database file used by this process (for diagnostics)
self._db_path = str(db.db_path)
@@ -277,18 +290,30 @@ class ConfigModal(ModalScreen):
container.mount(Label(label_text))
inp_id = f"global-{idx}"
self._input_id_map[inp_id] = found_key
if choices:
select_options = [(str(c), str(c)) for c in choices]
if current_val not in [str(c) for c in choices]:
select_options.insert(0, (current_val, current_val))
sel = Select(select_options, value=current_val, id=inp_id)
# Normalize boolean-like choices to lowercase ('true'/'false') to avoid duplicate choices
normalized_choices = []
for c in choices:
s = str(c)
if s.lower() in ("true", "false"):
normalized_choices.append(s.lower())
else:
normalized_choices.append(s)
select_options = [(str(c), str(c)) for c in normalized_choices]
# Normalize current value as well
cur_val = str(current_val) if current_val is not None else ""
if cur_val.lower() in ("true", "false"):
cur_val = cur_val.lower()
if cur_val not in normalized_choices:
select_options.insert(0, (cur_val, cur_val))
sel = Select(select_options, value=cur_val, id=inp_id)
container.mount(sel)
else:
row = Horizontal(classes="field-row")
container.mount(row)
row.mount(Input(value=current_val, id=inp_id, classes="config-input"))
row.mount(Button("Paste", id=f"paste-{inp_id}", classes="paste-btn"))
idx += 1
# Show any other top-level keys not in schema
@@ -444,13 +469,23 @@ class ConfigModal(ModalScreen):
idx = 0
for k, v in section.items():
if k.startswith("_"): continue
# Skip low-level keys that shouldn't be editable via the form UI
if (
item_type == "provider"
and isinstance(item_name, str)
and item_name.strip().lower() == "matrix"
and str(k or "").strip().lower() in ("rooms", "cached_rooms")
):
# These are managed by the inline UI and should not be edited directly.
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)
# Determine display props from schema
label_text = k
is_secret = False
@@ -469,22 +504,29 @@ class ConfigModal(ModalScreen):
self._input_id_map[inp_id] = k
if choices:
# Select takes a list of (label, value) tuples
# Select takes a list of (label, value) tuples; normalize boolean-like values
select_options = []
choice_values = []
for c in choices:
if isinstance(c, tuple) and len(c) == 2:
select_options.append((str(c[0]), str(c[1])))
choice_values.append(str(c[1]))
label = str(c[0])
val = str(c[1])
else:
select_options.append((str(c), str(c)))
choice_values.append(str(c))
label = str(c)
val = str(c)
if val.lower() in ("true", "false"):
val = val.lower()
label = val
select_options.append((label, val))
choice_values.append(val)
# If current value not in choices, add it or stay blank
current_val = str(v)
if current_val.lower() in ("true", "false"):
current_val = current_val.lower()
if current_val not in choice_values:
select_options.insert(0, (current_val, current_val))
sel = Select(select_options, value=current_val, id=inp_id)
container.mount(sel)
else:
@@ -494,7 +536,6 @@ class ConfigModal(ModalScreen):
if is_secret:
inp.password = True
row.mount(inp)
row.mount(Button("Paste", id=f"paste-{inp_id}", classes="paste-btn"))
idx += 1
# Add required/optional fields from schema that are missing
@@ -518,16 +559,25 @@ class ConfigModal(ModalScreen):
choice_values = []
for c in choices:
if isinstance(c, tuple) and len(c) == 2:
select_options.append((str(c[0]), str(c[1])))
choice_values.append(str(c[1]))
label = str(c[0])
val = str(c[1])
else:
select_options.append((str(c), str(c)))
choice_values.append(str(c))
if default_val not in choice_values:
select_options.insert(0, (default_val, default_val))
label = str(c)
val = str(c)
if val.lower() in ("true", "false"):
val = val.lower()
label = val
select_options.append((label, val))
choice_values.append(val)
sel = Select(select_options, value=default_val, id=inp_id)
# Normalize default/current value
current_val = str(default_val) if default_val is not None else ""
if current_val.lower() in ("true", "false"):
current_val = current_val.lower()
if current_val not in choice_values:
select_options.insert(0, (current_val, current_val))
sel = Select(select_options, value=current_val, id=inp_id)
container.mount(sel)
else:
row = Horizontal(classes="field-row")
@@ -556,7 +606,6 @@ class ConfigModal(ModalScreen):
row = Horizontal(classes="field-row")
container.mount(row)
row.mount(Input(value="", id=inp_id, classes="config-input"))
row.mount(Button("Paste", id=f"paste-{inp_id}", classes="paste-btn"))
idx += 1
# If it's a provider, we might have required keys (legacy check fallback)
@@ -592,25 +641,101 @@ class ConfigModal(ModalScreen):
row = Horizontal(classes="field-row")
container.mount(row)
row.mount(Button("Test connection", id="matrix-test-btn"))
row.mount(Button("Choose default rooms", variant="primary", id="matrix-rooms-btn"))
# Load rooms refreshes the inline list and caches the results (no popup)
row.mount(Button("Load rooms", variant="primary", id="matrix-load-btn"))
self._matrix_status = status
# Inline rooms list for selecting default rooms (populated after a successful test)
container.mount(Label("Default Rooms", classes="config-label", id="matrix-inline-label"))
# Start with empty list; it will be filled when test loads rooms
container.mount(ListView(id="matrix-rooms-inline"))
# Inline actions
row2 = Horizontal(classes="field-row")
container.mount(row2)
row2.mount(Button("Select All", id="matrix-inline-select-all"))
row2.mount(Button("Clear All", id="matrix-inline-clear"))
save_inline = Button("Save defaults", variant="success", id="matrix-inline-save")
save_inline.disabled = True
row2.mount(save_inline)
# Local bookkeeping maps
try:
self._matrix_inline_checkbox_map = {}
self._matrix_inline_list = self.query_one("#matrix-rooms-inline", ListView)
# Do NOT auto-render cached rooms here; only show explicitly saved defaults
try:
existing_ids = self._parse_matrix_rooms_value()
cached = self._get_cached_matrix_rooms()
rooms_to_render: List[Dict[str, Any]] = []
# Start with cached rooms (from last Load). These are shown
# in the inline Default Rooms list but are unselected unless
# they are in the saved defaults list.
if cached:
rooms_to_render.extend(cached)
# Ensure saved default room ids are present and will be selected
if existing_ids:
cached_ids = {str(r.get("room_id") or "").strip() for r in rooms_to_render if isinstance(r, dict)}
need_resolve = [rid for rid in existing_ids if rid not in cached_ids]
if need_resolve:
try:
resolved = self._resolve_matrix_rooms_by_ids(need_resolve)
if resolved:
rooms_to_render.extend(resolved)
else:
rooms_to_render.extend([{"room_id": rid, "name": ""} for rid in need_resolve])
except Exception:
rooms_to_render.extend([{"room_id": rid, "name": ""} for rid in need_resolve])
# Deduplicate while preserving order
deduped: List[Dict[str, Any]] = []
seen_ids: set[str] = set()
for r in rooms_to_render:
try:
rid = str(r.get("room_id") or "").strip()
if not rid or rid in seen_ids:
continue
seen_ids.add(rid)
deduped.append(r)
except Exception:
continue
if self._matrix_inline_list is not None and deduped:
try:
self._render_matrix_rooms_inline(deduped)
except Exception:
pass
except Exception:
pass
except Exception:
self._matrix_inline_checkbox_map = {}
self._matrix_inline_list = None
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":
# Only respond to selections from the left-hand category list. Avoid
# resetting editor state when other ListViews (like the inline rooms
# list) trigger selection events.
if not event.item:
return
item_id = getattr(event.item, "id", None)
if item_id not in ("cat-globals", "cat-stores", "cat-providers", "cat-tools"):
return
if item_id == "cat-globals":
self.current_category = "globals"
elif event.item.id == "cat-stores":
elif item_id == "cat-stores":
self.current_category = "stores"
elif event.item.id == "cat-providers":
elif item_id == "cat-providers":
self.current_category = "providers"
elif event.item.id == "cat-tools":
elif item_id == "cat-tools":
self.current_category = "tools"
# Reset editor state and refresh view for the new category
self.editing_item_name = None
self.editing_item_type = None
self.refresh_view()
@@ -745,17 +870,63 @@ class ConfigModal(ModalScreen):
self.app.push_screen(SelectionModal("Select Tool", options), callback=self.on_tool_type_selected)
elif bid == "matrix-test-btn":
self._request_matrix_test()
elif bid == "matrix-rooms-btn":
self._open_matrix_room_picker()
# Restore UI removed: backups/audit remain available programmatically
elif bid.startswith("paste-"):
# Programmatic paste button
target_id = bid.replace("paste-", "")
elif bid == "matrix-load-btn":
# Refresh the inline rooms list and cache the results (no popup)
self._request_matrix_load()
elif bid == "matrix-inline-select-all":
for checkbox_id in list(self._matrix_inline_checkbox_map.keys()):
try:
cb = self.query_one(f"#{checkbox_id}", Checkbox)
cb.value = True
except Exception:
pass
try:
inp = self.query_one(f"#{target_id}", Input)
self.focus_and_paste(inp)
self.query_one("#matrix-inline-save", Button).disabled = False
except Exception:
pass
elif bid == "matrix-inline-clear":
for checkbox_id in list(self._matrix_inline_checkbox_map.keys()):
try:
cb = self.query_one(f"#{checkbox_id}", Checkbox)
cb.value = False
except Exception:
pass
try:
self.query_one("#matrix-inline-save", Button).disabled = True
except Exception:
pass
elif bid == "matrix-inline-save":
selected: List[str] = []
for checkbox_id, room_id in self._matrix_inline_checkbox_map.items():
try:
cb = self.query_one(f"#{checkbox_id}", Checkbox)
if cb.value and room_id:
selected.append(room_id)
except Exception:
pass
if not selected:
if self._matrix_status:
self._matrix_status.update("No default rooms were saved.")
return
matrix_block = self.config_data.setdefault("provider", {}).setdefault("matrix", {})
matrix_block["rooms"] = ", ".join(selected)
changed = count_changed_entries(self.config_data)
try:
entries = save_config(self.config_data)
except Exception as exc:
if self._matrix_status:
self._matrix_status.update(f"Saving default rooms failed: {exc}")
return
self.config_data = reload_config()
if self._matrix_status:
status = f"Saved {len(selected)} default room(s) ({changed} change(s)) to {db.db_path.name}."
self._matrix_status.update(status)
try:
self.query_one("#matrix-inline-save", Button).disabled = True
except Exception:
pass
self.refresh_view()
async def focus_and_paste(self, inp: Input) -> None:
if hasattr(self.app, "paste_from_clipboard"):
@@ -1025,6 +1196,20 @@ class ConfigModal(ModalScreen):
if self._matrix_test_running:
return
self._synchronize_inputs_to_config()
# Quick client-side pre-check before attempting to save/test to provide
# immediate guidance when required fields are missing.
try:
matrix_block = self.config_data.get("provider", {}).get("matrix", {})
hs = matrix_block.get("homeserver")
token = matrix_block.get("access_token")
if not hs or not token:
if self._matrix_status:
self._matrix_status.update("Matrix test skipped: please set both 'homeserver' and 'access_token' before testing.")
return
except Exception:
pass
if self._matrix_status:
self._matrix_status.update("Saving configuration before testing…")
changed = count_changed_entries(self.config_data)
@@ -1050,23 +1235,215 @@ class ConfigModal(ModalScreen):
rooms = provider.list_rooms()
self.app.call_from_thread(self._matrix_test_result, True, rooms, None)
except Exception as exc:
self.app.call_from_thread(self._matrix_test_result, False, [], str(exc))
# Log full traceback for diagnostics but present a concise, actionable
# message to the user in the UI.
tb = traceback.format_exc()
try:
debug(f"[matrix] Test connection failed: {exc}\n{tb}")
except Exception:
pass
def _matrix_test_result(
self,
success: bool,
rooms: List[Dict[str, Any]],
error: Optional[str],
) -> None:
msg = str(exc) or "Matrix test failed"
m_lower = msg.lower()
if "auth" in m_lower or "authentication" in m_lower:
msg = msg + ". Please verify your access token and try again."
elif "homeserver" in m_lower or "missing" in m_lower:
msg = msg + ". Check your homeserver URL (include https://)."
else:
msg = msg + " (see logs for details)"
self.app.call_from_thread(self._matrix_test_result, False, [], msg)
def _get_cached_matrix_rooms(self) -> List[Dict[str, Any]]:
"""Return cached rooms stored in the provider config (normalized).
The config value can be a list/dict, a JSON string, or a Python literal
string (repr). This method normalizes the input and returns a list of
dicts containing 'room_id' and 'name'. Malformed inputs are ignored.
"""
try:
block = self._get_matrix_provider_block()
raw = block.get("cached_rooms")
if not raw:
return []
# If it's already a list or tuple, normalize each element
if isinstance(raw, (list, tuple)):
return self._normalize_cached_raw(list(raw))
# If it's a dict, wrap and normalize
if isinstance(raw, dict):
return self._normalize_cached_raw([raw])
# If it's a string, try JSON -> ast.literal_eval -> regex ID extraction
if isinstance(raw, str):
s = str(raw).strip()
if not s:
return []
# Try JSON first (strict)
try:
import json
parsed = json.loads(s)
if isinstance(parsed, (list, tuple, dict)):
return self._normalize_cached_raw(parsed if isinstance(parsed, (list, tuple)) else [parsed])
except Exception:
pass
# Try Python literal eval (accepts single quotes, repr-style lists)
try:
import ast
parsed = ast.literal_eval(s)
if isinstance(parsed, (list, tuple, dict)):
return self._normalize_cached_raw(parsed if isinstance(parsed, (list, tuple)) else [parsed])
except Exception:
pass
# Try to extract dict-like pairs for room_id/name when the string looks like
# a Python repr or partial dict fragment (e.g., "'room_id': '!r1', 'name': 'Room'"
try:
import re
pair_pat = re.compile(r"[\"']room_id[\"']\s*:\s*[\"'](?P<id>[^\"']+)[\"']\s*,\s*[\"']name[\"']\s*:\s*[\"'](?P<name>[^\"']+)[\"']")
pairs = [m.groupdict() for m in pair_pat.finditer(s)]
if pairs:
out = []
for p in pairs:
rid = str(p.get("id") or "").strip()
name = str(p.get("name") or "").strip()
if rid:
out.append({"room_id": rid, "name": name})
if out:
return out
# As a last resort, extract candidate room ids via regex (look for leading '!')
ids = re.findall(r"![-A-Za-z0-9._=]+(?::[-A-Za-z0-9._=]+)?", s)
if ids:
return [{"room_id": rid, "name": ""} for rid in ids]
except Exception:
pass
return []
except Exception:
pass
return []
def _normalize_cached_raw(self, parsed: List[Any]) -> List[Dict[str, Any]]:
out: List[Dict[str, Any]] = []
for it in parsed:
try:
if isinstance(it, dict):
rid = str(it.get("room_id") or "").strip()
name = str(it.get("name") or "").strip()
if rid:
out.append({"room_id": rid, "name": name})
elif isinstance(it, str):
s = str(it or "").strip()
if s:
out.append({"room_id": s, "name": ""})
except Exception:
continue
return out
def _request_matrix_load(self) -> None:
"""Save current config and request a background load of joined rooms.
This replaces the old "Choose default rooms" popup and instead refreshes
the inline default rooms list and caches the results to config.
"""
if self._matrix_test_running:
return
self._synchronize_inputs_to_config()
# Quick client-side pre-check for required fields
try:
matrix_block = self.config_data.get("provider", {}).get("matrix", {})
hs = matrix_block.get("homeserver")
token = matrix_block.get("access_token")
if not hs or not token:
if self._matrix_status:
self._matrix_status.update("Load skipped: please set both 'homeserver' and 'access_token' before loading rooms.")
return
except Exception:
pass
if self._matrix_status:
self._matrix_status.update("Saving configuration before loading rooms…")
changed = count_changed_entries(self.config_data)
try:
entries = save_config(self.config_data)
except Exception as exc:
if self._matrix_status:
self._matrix_status.update(f"Saving configuration failed: {exc}")
self._matrix_test_running = False
return
self.config_data = reload_config()
if self._matrix_status:
self._matrix_status.update(f"Saved configuration ({changed} change(s)) to {db.db_path.name}. Loading Matrix rooms…")
self._matrix_test_running = True
self._matrix_load_background()
@work(thread=True)
def _matrix_load_background(self) -> None:
try:
from Provider.matrix import Matrix
provider = Matrix(self.config_data)
rooms = provider.list_rooms()
self.app.call_from_thread(self._matrix_load_result, True, rooms, None)
except Exception as exc:
tb = traceback.format_exc()
try:
debug(f"[matrix] Load rooms failed: {exc}\n{tb}")
except Exception:
pass
msg = str(exc) or "Matrix load failed"
if "auth" in msg.lower():
msg = msg + ". Please verify your access token and try again."
self.app.call_from_thread(self._matrix_load_result, False, [], msg)
def _matrix_load_result(self, success: bool, rooms: List[Dict[str, Any]], error: Optional[str]) -> None:
# Called on the main thread via call_from_thread
self._matrix_test_running = False
if success:
msg = f"Matrix test succeeded ({len(rooms)} room(s))."
if not success:
full_msg = f"Matrix load failed: {error or '(error)'}"
if self._matrix_status:
self._matrix_status.update(msg)
self._open_matrix_room_picker(prefetched_rooms=rooms)
else:
self._matrix_status.update(full_msg)
try:
self.notify(full_msg, severity="error", timeout=8)
except Exception:
pass
return
# Populate inline list
try:
self._render_matrix_rooms_inline(rooms)
except Exception:
pass
# Persist cached rooms so they are available on next editor open
try:
matrix_block = self.config_data.setdefault("provider", {}).setdefault("matrix", {})
matrix_block["cached_rooms"] = rooms
# Schedule a background save of the config (non-blocking)
try:
self.save_all()
except Exception:
# Fallback to direct save when save_all is unavailable (tests)
try:
save_config(self.config_data)
except Exception:
pass
if self._matrix_status:
self._matrix_status.update(f"Matrix test failed: {error}")
self._matrix_status.update(f"Loaded and cached {len(rooms)} room(s).")
try:
self.notify(f"Loaded {len(rooms)} rooms and cached the results", timeout=5)
except Exception:
pass
except Exception:
pass
def _open_matrix_room_picker(
self,
@@ -1083,6 +1460,138 @@ class ConfigModal(ModalScreen):
callback=self.on_matrix_rooms_selected,
)
def _render_matrix_rooms_inline(self, rooms: List[Dict[str, Any]]) -> None:
"""
Populate the inline matrix rooms ListView with checkboxes based on the
list of rooms returned from a successful test. If the inline ListView
is not present in the current editor view, fall back to opening the
MatrixRoomPicker popup with the results.
"""
try:
inline_list = self._matrix_inline_list or self.query_one("#matrix-rooms-inline", ListView)
except Exception:
inline_list = None
if inline_list is None:
# Inline view isn't available in this context; cache the rooms and persist
try:
matrix_block = self.config_data.setdefault("provider", {}).setdefault("matrix", {})
matrix_block["cached_rooms"] = rooms
try:
self.save_all()
except Exception:
try:
save_config(self.config_data)
except Exception:
pass
if self._matrix_status:
self._matrix_status.update(f"Loaded {len(rooms)} rooms (cached)")
try:
self.notify(f"Loaded {len(rooms)} rooms and cached the results", timeout=5)
except Exception:
pass
except Exception:
pass
return
# Clear current entries
for child in list(inline_list.children):
child.remove()
self._matrix_inline_checkbox_map.clear()
self._matrix_inline_list = inline_list
# Determine existing selection from current config (so saved defaults are pre-selected)
existing = set(self._parse_matrix_rooms_value())
if not rooms:
if self._matrix_status:
self._matrix_status.update("Matrix returned no rooms.")
try:
save_btn = self.query_one("#matrix-inline-save", Button)
save_btn.disabled = True
except Exception:
pass
return
any_selected = False
# Filter and normalize rooms to avoid malformed cache entries or split-word artifacts.
normalized: List[Dict[str, str]] = []
for r in rooms:
try:
rid = str(r.get("room_id") or "").strip()
name = str(r.get("name") or "").strip()
except Exception:
continue
# Ignore obviously malformed tokens coming from bad caching/parsing
le_rid = rid.lower()
le_name = name.lower()
if "room_id" in le_rid or "room_id" in le_name:
continue
# Require a valid room id (Matrix ids usually start with '!' and often contain ':')
if not rid or (not rid.startswith("!") and ":" not in rid):
# Skip entries without a sensible ID (we rely on IDs for saving)
continue
normalized.append({"room_id": rid, "name": name})
for idx, room in enumerate(normalized):
room_id = room.get("room_id") or ""
name = room.get("name") or ""
checkbox_id = f"matrix-inline-room-{idx}"
label_text = name or room_id or "Matrix Room"
checked = bool(room_id and room_id in existing)
if checked:
any_selected = True
from textual.widgets import Checkbox as _Checkbox # local import to avoid top-level change
checkbox = _Checkbox(label_text, id=checkbox_id, value=checked)
self._matrix_inline_checkbox_map[checkbox_id] = room_id
inline_list.mount(ListItem(checkbox, classes="matrix-room-row"))
if self._matrix_status:
self._matrix_status.update("Loaded rooms. Select one or more and save.")
try:
save_btn = self.query_one("#matrix-inline-save", Button)
save_btn.disabled = not any_selected
except Exception:
pass
def _resolve_matrix_rooms_by_ids(self, ids: Iterable[str]) -> List[Dict[str, Any]]:
"""
Resolve room display names for a list of room IDs using the Matrix provider.
Returns a list of dictionaries with keys 'room_id' and 'name' on success, or an
empty list on failure.
"""
try:
ids_list = [str(i).strip() for i in ids if str(i).strip()]
except Exception:
return []
if not ids_list:
return []
# Only attempt network resolution if homeserver + token are present
block = self._get_matrix_provider_block()
hs = block.get("homeserver")
token = block.get("access_token")
if not hs or not token:
return []
try:
from Provider.matrix import Matrix
provider = Matrix(self.config_data)
rooms = provider.list_rooms(room_ids=ids_list)
return rooms or []
except Exception as exc:
try:
debug(f"[config] failed to resolve matrix room names: {exc}")
except Exception:
pass
return []
def on_matrix_rooms_selected(self, result: Any = None) -> None:
if not isinstance(result, list):
if self._matrix_status:
@@ -1113,6 +1622,30 @@ class ConfigModal(ModalScreen):
self.refresh_view()
@on(Input.Changed)
@on(Checkbox.Changed)
def on_checkbox_changed(self, event: Checkbox.Changed) -> None:
# Only respond to inline matrix checkboxes
try:
cbid = event.checkbox.id
except Exception:
cbid = None
if not cbid or cbid not in self._matrix_inline_checkbox_map:
return
any_selected = False
for checkbox_id in self._matrix_inline_checkbox_map.keys():
try:
cb = self.query_one(f"#{checkbox_id}", Checkbox)
if cb.value:
any_selected = True
break
except Exception:
continue
try:
self.query_one("#matrix-inline-save", Button).disabled = not any_selected
except Exception:
pass
def on_input_changed(self, event: Input.Changed) -> None:
if event.input.id:
self._update_config_value(event.input.id, event.value)

View File

@@ -3,9 +3,9 @@ from __future__ import annotations
from typing import Any, Dict, List, Optional
from textual.app import ComposeResult
from textual.containers import Container, Horizontal, ScrollableContainer, Vertical
from textual.containers import Container, Horizontal, ScrollableContainer
from textual.screen import ModalScreen
from textual.widgets import Static, Button, Checkbox
from textual.widgets import Static, Button, Checkbox, ListView, ListItem
from textual import work
from rich.text import Text
@@ -40,13 +40,13 @@ class MatrixRoomPicker(ModalScreen[List[str]]):
margin-bottom: 1;
}
#matrix-room-checklist {
#matrix-room-list {
padding: 0;
}
.matrix-room-row {
border-bottom: solid $surface;
padding: 0.5 0;
padding: 1 0;
align: left middle;
}
@@ -91,15 +91,17 @@ class MatrixRoomPicker(ModalScreen[List[str]]):
id="matrix-room-picker-hint",
)
with ScrollableContainer(id="matrix-room-scroll"):
yield Vertical(id="matrix-room-checklist")
yield ListView(id="matrix-room-list")
with Horizontal(id="matrix-room-actions"):
yield Button("Cancel", variant="error", id="matrix-room-cancel")
yield Button("Select All", id="matrix-room-select-all")
yield Button("Clear All", id="matrix-room-clear")
yield Button("Save defaults", variant="success", id="matrix-room-save")
yield Static("Loading rooms...", id="matrix-room-status")
def on_mount(self) -> None:
self._status_widget = self.query_one("#matrix-room-status", Static)
self._checklist = self.query_one("#matrix-room-checklist", Vertical)
self._checklist = self.query_one("#matrix-room-list", ListView)
self._save_button = self.query_one("#matrix-room-save", Button)
if self._save_button:
self._save_button.disabled = True
@@ -110,10 +112,35 @@ class MatrixRoomPicker(ModalScreen[List[str]]):
self._set_status("Loading rooms...")
self._load_rooms_background()
def on_list_view_selected(self, event: ListView.Selected) -> None:
"""Intercept ListView.Selected events and prevent them from bubbling up
to parent components which may react (e.g., the main config modal).
Selecting an item should not implicitly close the picker or change the
outer editor state."""
try:
# Stop propagation so parent handlers (ConfigModal) don't react.
event.stop()
except Exception:
pass
def _set_status(self, text: str) -> None:
if self._status_widget:
self._status_widget.update(text)
def on_checkbox_changed(self, event: Checkbox.Changed) -> None:
# Enable Save only when at least one checkbox is selected
any_selected = False
for checkbox_id in self._checkbox_map.keys():
try:
cb = self.query_one(f"#{checkbox_id}", Checkbox)
if cb.value:
any_selected = True
break
except Exception:
continue
if self._save_button:
self._save_button.disabled = not any_selected
def _render_rooms(self, rooms: List[Dict[str, Any]]) -> None:
if not self._checklist:
return
@@ -130,22 +157,20 @@ class MatrixRoomPicker(ModalScreen[List[str]]):
room_id = str(room.get("room_id") or "").strip()
name = str(room.get("name") or "").strip()
checkbox_id = f"matrix-room-{idx}"
# Prefer display name; otherwise fall back to room id
label_text = name or room_id or "Matrix Room"
checkbox = Checkbox(
"",
label_text,
id=checkbox_id,
value=bool(room_id and room_id in self._existing_ids),
)
self._checkbox_map[checkbox_id] = room_id
label = Text(name or room_id or "Matrix Room")
label.stylize("bold")
label.append("\n")
label.append(room_id or "(no id)", style="dim")
list_item = ListItem(checkbox, classes="matrix-room-row")
self._checklist.mount(list_item)
row = Horizontal(classes="matrix-room-row")
self._checklist.mount(row)
row.mount(checkbox)
row.mount(Static(label, classes="matrix-room-meta"))
self._set_status("Loaded rooms. Select one or more and save.")
if self._save_button:
self._save_button.disabled = False
@@ -167,10 +192,40 @@ class MatrixRoomPicker(ModalScreen[List[str]]):
self._save_button.disabled = True
return
self._render_rooms(rooms)
# Ensure save button is enabled only if at least one checkbox is selected
any_selected = False
for cbid in self._checkbox_map.keys():
try:
cb = self.query_one(f"#{cbid}", Checkbox)
if cb.value:
any_selected = True
break
except Exception:
continue
if self._save_button:
self._save_button.disabled = not any_selected
def on_button_pressed(self, event: Button.Pressed) -> None:
if event.button.id == "matrix-room-cancel":
self.dismiss([])
elif event.button.id == "matrix-room-select-all":
for checkbox_id in self._checkbox_map.keys():
try:
cb = self.query_one(f"#{checkbox_id}", Checkbox)
cb.value = True
except Exception:
pass
if self._save_button:
self._save_button.disabled = False
elif event.button.id == "matrix-room-clear":
for checkbox_id in self._checkbox_map.keys():
try:
cb = self.query_one(f"#{checkbox_id}", Checkbox)
cb.value = False
except Exception:
pass
if self._save_button:
self._save_button.disabled = True
elif event.button.id == "matrix-room-save":
selected: List[str] = []
for checkbox_id, room_id in self._checkbox_map.items():