QOL: auto select target channel

This commit is contained in:
rambros 2026-03-06 21:31:33 +05:30
parent 1835cbcc17
commit 69936fb351

View file

@ -4,7 +4,8 @@ Shared modals used by backup and shuttle operations.
from textual.app import ComposeResult from textual.app import ComposeResult
from textual.containers import Horizontal, Vertical, VerticalScroll, Container, Center from textual.containers import Horizontal, Vertical, VerticalScroll, Container, Center
from textual.widgets import Button, Label, Input, ProgressBar, RichLog, Rule, RadioButton, LoadingIndicator, Header, Footer, RadioSet from textual.widgets import Button, Label, Input, ProgressBar, RichLog, Rule, RadioButton, LoadingIndicator, Header, Footer, RadioSet, OptionList
from textual.widgets.option_list import Option
from textual.screen import ModalScreen, Screen from textual.screen import ModalScreen, Screen
@ -488,12 +489,28 @@ class ChannelPickerScreen(Screen[tuple]):
margin: 0 1; margin: 0 1;
padding: 0 1; padding: 0 1;
} }
.split_pane:blur {
border: solid $primary; /* Prevent border color change on blur */
}
.pane_title { text-style: bold; margin-bottom: 1; color: cyan; margin-left: 1; } .pane_title { text-style: bold; margin-bottom: 1; color: cyan; margin-left: 1; }
OptionList {
background: $surface;
border: none;
height: 1fr;
}
OptionList:blur .option-list--option-highlighted {
background: $primary 100%; /* Brighter blurred highlight */
}
OptionList .option-list--option-highlighted {
background: $primary 100%;
text-style: bold;
}
.category_header { .category_header {
margin-top: 1; margin-top: 1;
background: $primary 10%; background: $primary 10%;
text-style: bold; text-style: bold;
padding-left: 1; padding-left: 1;
color: cyan;
} }
#chanpick_buttons { height: auto; margin-top: 1; dock: bottom; margin-bottom: 0; } #chanpick_buttons { height: auto; margin-top: 1; dock: bottom; margin-bottom: 0; }
#chanpick_buttons Button { width: 1fr; margin: 0 1; } #chanpick_buttons Button { width: 1fr; margin: 0 1; }
@ -509,7 +526,7 @@ class ChannelPickerScreen(Screen[tuple]):
def _render_pane(self, channels, categories, pane_id, prefix): def _render_pane(self, channels, categories, pane_id, prefix):
cat_grouped: dict[int | None, list] = {} cat_grouped: dict[int | None, list] = {}
seen_ids = set() # Prevent duplicate widget IDs seen_ids = set()
for c in channels: for c in channels:
cat_id = getattr(c, "category_id", None) if not isinstance(c, dict) else c.get("parent_id") cat_id = getattr(c, "category_id", None) if not isinstance(c, dict) else c.get("parent_id")
cid = c.get("id") if isinstance(c, dict) else c.id cid = c.get("id") if isinstance(c, dict) else c.id
@ -518,11 +535,12 @@ class ChannelPickerScreen(Screen[tuple]):
seen_ids.add(cid) seen_ids.add(cid)
cat_grouped.setdefault(cat_id, []).append(c) cat_grouped.setdefault(cat_id, []).append(c)
with VerticalScroll(classes="split_pane", id=pane_id): options = []
with RadioSet(id=f"{prefix}_radioset"):
for cat_id in sorted(cat_grouped, key=lambda k: categories.get(k, "") if k else ""): for cat_id in sorted(cat_grouped, key=lambda k: categories.get(k, "") if k else ""):
if cat_id is not None and cat_id in categories: if cat_id is not None and cat_id in categories:
yield Label(f"[cyan]{categories[cat_id]}[/cyan]", classes="category_header") # Category header as a bold, non-selectable option
options.append(Option(f"[bold cyan]{categories[cat_id]}[/bold cyan]", id=f"header_{cat_id}", disabled=True))
for c in cat_grouped[cat_id]: for c in cat_grouped[cat_id]:
if isinstance(c, dict): if isinstance(c, dict):
name = c.get("name", "Unnamed") name = c.get("name", "Unnamed")
@ -530,7 +548,9 @@ class ChannelPickerScreen(Screen[tuple]):
else: else:
name = c.name name = c.name
cid = c.id cid = c.id
yield RadioButton(name, value=False, id=f"{prefix}_{cid}") options.append(Option(name, id=f"{prefix}_{cid}"))
yield OptionList(*options, id=f"{prefix}_list", classes="split_pane")
def compose(self) -> ComposeResult: def compose(self) -> ComposeResult:
yield Header(show_clock=True) yield Header(show_clock=True)
@ -552,20 +572,52 @@ class ChannelPickerScreen(Screen[tuple]):
yield Button("Back", id="btn_pick_back") yield Button("Back", id="btn_pick_back")
yield Footer() yield Footer()
def on_mount(self) -> None:
"""Set initial highlights after mounting."""
for lid in ["#src_list", "#tgt_list"]:
lst = self.query_one(lid, OptionList)
if lst.option_count > 0:
# Find first selectable (non-disabled) option
for i in range(lst.option_count):
opt = lst.get_option_at_index(i)
if not opt.disabled:
lst.highlighted = i
break
def on_option_list_option_selected(self, event: OptionList.OptionSelected) -> None:
"""Handle selection in either list."""
if event.option_list.id == "src_list":
# QoL: Auto-select target if name matches
src_name = str(event.option.prompt).strip().lower()
tgt_list = self.query_one("#tgt_list", OptionList)
for i in range(tgt_list.option_count):
opt = tgt_list.get_option_at_index(i)
if opt.id and opt.id.startswith("tgt_"):
if str(opt.prompt).strip().lower() == src_name:
tgt_list.highlighted = i
tgt_list.scroll_to_highlight()
break
def on_button_pressed(self, event: Button.Pressed): def on_button_pressed(self, event: Button.Pressed):
if event.button.id == "btn_pick_back": if event.button.id == "btn_pick_back":
self.dismiss(None) self.dismiss(None)
elif event.button.id == "btn_pick_ok": elif event.button.id == "btn_pick_ok":
src_list = self.query_one("#src_list", OptionList)
tgt_list = self.query_one("#tgt_list", OptionList)
src_val = None src_val = None
tgt_val = None tgt_val = None
src_rset = self.query_one("#src_radioset", RadioSet) if src_list.highlighted is not None:
if src_rset.pressed_button: opt = src_list.get_option_at_index(src_list.highlighted)
src_val = src_rset.pressed_button.id.split("_", 1)[1] if opt.id and opt.id.startswith("src_"):
src_val = opt.id.split("_", 1)[1]
tgt_rset = self.query_one("#tgt_radioset", RadioSet) if tgt_list.highlighted is not None:
if tgt_rset.pressed_button: opt = tgt_list.get_option_at_index(tgt_list.highlighted)
tgt_val = tgt_rset.pressed_button.id.split("_", 1)[1] if opt.id and opt.id.startswith("tgt_"):
tgt_val = opt.id.split("_", 1)[1]
if src_val and tgt_val: if src_val and tgt_val:
self.dismiss((int(src_val), tgt_val)) # Source is guaranteed Discord ID (int), Target could be UUID/string self.dismiss((int(src_val), tgt_val)) # Source is guaranteed Discord ID (int), Target could be UUID/string