Coverage for src/lilbee/cli/tui/screens/settings.py: 100%
423 statements
« prev ^ index » next coverage.py v7.15.2, created at 2026-09-28 17:20 +0000
« prev ^ index » next coverage.py v7.15.2, created at 2026-09-28 17:20 +0000
1"""Settings screen. Grouped, type-aware configuration editor."""
3from __future__ import annotations
5import logging
6import re
7from collections.abc import Callable
8from dataclasses import dataclass
9from typing import TYPE_CHECKING, ClassVar
11from textual import on, work
12from textual.app import ComposeResult
13from textual.binding import Binding, BindingType
14from textual.containers import Container, Horizontal, VerticalGroup, VerticalScroll
15from textual.screen import Screen
16from textual.widget import Widget
17from textual.widgets import (
18 Button,
19 Checkbox,
20 Collapsible,
21 Input,
22 Select,
23 Static,
24 TabbedContent,
25 TabPane,
26)
28from lilbee.app.settings import OCR_SETTING_KEYS, reset_settings
29from lilbee.app.settings_map import SETTINGS_MAP, SettingDef, SettingGroup, get_default
30from lilbee.cli.tui import messages as msg
31from lilbee.cli.tui.browse_bindings import BROWSE_LIST_BINDINGS, browse_back_bindings
32from lilbee.cli.tui.screens.settings_widgets import (
33 API_KEYS_GROUP,
34 API_KEYS_WARNING_CLASS,
35 EDITOR_ID_PREFIX,
36 LIST_ERROR_ID_PREFIX,
37 LIST_ERROR_VISIBLE_CLASS,
38 LIST_RESTORE_PREFIX,
39 MODEL_PICKER_BUTTON_PREFIX,
40 RESET_BUTTON_ID_PREFIX,
41 RESET_BUTTON_LABEL,
42 ROW_ID_PREFIX,
43 config_toml_path,
44 group_settings,
45 help_content,
46 make_editor,
47 model_field_to_picker_scope,
48 model_picker_label,
49 picker_scope_to_task,
50 set_widget_value,
51 stringify_default,
52 title_content,
53)
54from lilbee.cli.tui.widgets.list_text_area import ListTextArea
55from lilbee.cli.tui.widgets.model_pick import apply_model_pick
56from lilbee.core.config import cfg
58if TYPE_CHECKING:
59 from lilbee.cli.tui.app import LilbeeApp
60 from lilbee.cli.tui.screens.model_picker import PickerScope
61 from lilbee.cli.tui.widgets.model_bar import ModelOption
63log = logging.getLogger(__name__)
66@dataclass(frozen=True)
67class _PaneGroup:
68 """One settings tab: pane id, group label, ordered settings."""
70 pane_id: str
71 group_name: SettingGroup
72 items: list[tuple[str, SettingDef]]
75class _LazyGroupBody(VerticalScroll, can_focus=False):
76 """Pane-body that mounts rows on first activation; scrolls when taller than viewport."""
78 def __init__(self, *, id: str | None = None) -> None:
79 super().__init__(id=id)
80 self._populated = False
82 @property
83 def populated(self) -> bool:
84 return self._populated
86 def populate(self, build: Callable[[], list[Widget]]) -> None:
87 """Build and mount this pane's row widgets exactly once."""
88 if self._populated:
89 return
90 self._populated = True
91 widgets = build()
92 if widgets:
93 self.mount_all(widgets)
96class SettingsScreen(Screen[None]):
97 """Interactive settings viewer with grouped, type-aware editors."""
99 app: LilbeeApp # type: ignore[assignment]
101 CSS_PATH = "settings.tcss"
102 # Target the TabbedContent's inner Tabs strip rather than the outer
103 # #settings-scroll Container -- Container can't accept focus, so on
104 # mount focus would otherwise stay at None and downstream Tab-cycling
105 # has nowhere to start. The Tabs widget is the canonical entry point.
106 AUTO_FOCUS = "#settings-tabs Tabs"
107 HELP = (
108 "Browse and edit configuration.\n\n"
109 "Tab / Shift+Tab move between fields, > and < jump between groups, "
110 "j / k scroll and g / G jump to the top / bottom, "
111 "Ctrl+R resets the focused field and Ctrl+Shift+R resets every "
112 "setting, and q or Escape goes back."
113 )
115 # < and > are one action in two directions, so a single "Tabs" label still
116 # says what both keys do. Keys that do different things get their own cell
117 # or move to the help panel.
118 _TAB_GROUP = Binding.Group("Tabs", compact=True)
120 BINDINGS: ClassVar[list[BindingType]] = [
121 *browse_back_bindings(),
122 # Tab cycles editors inside the active pane and rolls over to the
123 # next group tab when you Tab past the last editor (and the
124 # previous group tab on shift+Tab past the first editor). Use
125 # > / < to jump straight to the next / previous group tab.
126 Binding("tab", "next_field_or_pane", "Next field", show=False),
127 Binding("shift+tab", "prev_field_or_pane", "Prev field", show=False),
128 # Direct tab cycling, mirrored from CatalogScreen. priority=True
129 # so the bindings win when an editor input has focus.
130 Binding(
131 "less_than_sign",
132 "cycle_pane(-1)",
133 "Prev tab",
134 show=True,
135 priority=True,
136 group=_TAB_GROUP,
137 ),
138 Binding(
139 "greater_than_sign",
140 "cycle_pane(1)",
141 "Next tab",
142 show=True,
143 priority=True,
144 group=_TAB_GROUP,
145 ),
146 # Resetting the focused setting is what this screen is for, so it keeps
147 # a cell. Reset-all is a rarer, wider-reaching action and lives in help;
148 # the two are different actions, so they must not share one label.
149 Binding("ctrl+r", "reset_focused", "Reset", show=True),
150 Binding("ctrl+shift+r", "reset_all", "Reset all", show=False),
151 *BROWSE_LIST_BINDINGS,
152 ]
154 def __init__(self) -> None:
155 super().__init__()
156 # Group definitions for lazy-mount on tab activation. Indexed
157 # by pane id so the activated-pane handler can look up its
158 # bundle in O(1). ``_eagerly_populate`` is the pane id whose
159 # body gets populated in on_mount (the active-by-default first
160 # pane); the rest fill in on first activation.
161 self._pane_groups: dict[str, _PaneGroup] = {}
162 self._eagerly_populate: str | None = None
164 def compose(self) -> ComposeResult:
165 from textual.widgets import Footer
167 from lilbee.cli.tui.widgets.bottom_bars import BottomBars
168 from lilbee.cli.tui.widgets.status_bar import ViewTabs
169 from lilbee.cli.tui.widgets.task_bar import TaskBar
170 from lilbee.cli.tui.widgets.top_bars import TopBars
172 with TopBars():
173 yield ViewTabs()
174 # Container (not VerticalScroll) here -- each tab body is itself a
175 # VerticalScroll, and stacking two scrollables on the same column
176 # tears the layout when the inner one wheels past its top edge
177 # (bb-...-wiki-tear). Only the inner pane scrolls; the outer just
178 # reserves the flex row.
179 with Container(id="settings-scroll"), TabbedContent(id="settings-tabs"):
180 yield from self._compose_group_tabs()
181 with BottomBars():
182 yield TaskBar()
183 yield Footer()
185 def _compose_group_tabs(self) -> ComposeResult:
186 """Yield one TabPane per setting group; bodies populate on activation."""
187 first = True
188 for group_name, items in group_settings().items():
189 pane_id = f"settings-tab-{group_name.lower().replace('-', '_')}"
190 self._pane_groups[pane_id] = _PaneGroup(
191 pane_id=pane_id, group_name=group_name, items=items
192 )
193 yield TabPane(
194 group_name,
195 _LazyGroupBody(id=f"{pane_id}-body"),
196 id=pane_id,
197 )
198 # The first pane is the one TabbedContent activates by
199 # default; populate it eagerly so a user landing on
200 # Settings sees content on first paint instead of an empty
201 # active pane that fills in one frame later.
202 if first:
203 first = False
204 self._eagerly_populate = pane_id
206 def on_mount(self) -> None:
207 """Defer first-pane content mount until after the screen has painted.
209 ``_populate_pane`` calls ``mount_all`` for ~25 editor widgets which
210 triggers a full Textual layout pass; running it inside ``on_mount``
211 adds that pass to the screen-switch latency budget. ``call_after_refresh``
212 moves it to the next event-loop tick so the user sees the empty pane
213 skeleton immediately and the rows hydrate one frame later.
214 """
215 if self._eagerly_populate is not None:
216 self.call_after_refresh(self._populate_pane, self._eagerly_populate)
218 @on(TabbedContent.TabActivated)
219 def _on_tab_activated(self, event: TabbedContent.TabActivated) -> None:
220 """Populate the activated pane's body on first activation."""
221 pane = event.pane
222 if pane is None or pane.id is None:
223 return
224 self._populate_pane(pane.id)
226 def populate_all_panes(self) -> None:
227 """Force every tab body to populate now (test/agent helper)."""
228 for pane_id in self._pane_groups:
229 self._populate_pane(pane_id)
231 def _populate_pane(self, pane_id: str) -> None:
232 """Populate a pane's body if known and the body widget is mounted."""
233 group = self._pane_groups.get(pane_id)
234 if group is None:
235 return
236 try:
237 body = self.query_one(f"#{pane_id}-body", _LazyGroupBody)
238 except Exception:
239 log.debug("pane body %s not yet mounted", pane_id, exc_info=True)
240 return
241 body.populate(lambda: self._build_pane_widgets(group))
243 def _build_pane_widgets(self, group: _PaneGroup) -> list[Widget]:
244 """Return the body widgets for one settings tab."""
245 widgets: list[Widget] = []
246 if group.group_name == API_KEYS_GROUP:
247 widgets.append(
248 Static(
249 msg.SETTINGS_API_KEYS_WARNING.format(path=config_toml_path()),
250 classes=API_KEYS_WARNING_CLASS,
251 )
252 )
253 for key, defn in group.items:
254 widgets.append(self._build_setting_row(key, defn))
255 return widgets
257 def _build_setting_row(self, key: str, defn: SettingDef) -> VerticalGroup:
258 """Construct one setting row with its title, help, editor, and reset."""
259 title = Static(title_content(key, defn), classes="setting-title")
260 help_widget = Static(help_content(key, defn), classes="setting-help")
261 children: list[Widget] = [title, help_widget]
262 if key in model_field_to_picker_scope():
263 children.append(self._build_model_picker_row(key))
264 elif defn.writable:
265 editor_row = Horizontal(
266 make_editor(key, defn),
267 Button(
268 RESET_BUTTON_LABEL,
269 id=f"{RESET_BUTTON_ID_PREFIX}{key}",
270 classes="setting-reset-button",
271 tooltip=msg.SETTINGS_RESET_TO_DEFAULT_TOOLTIP,
272 ),
273 classes="setting-editor-row",
274 )
275 children.append(editor_row)
276 return VerticalGroup(
277 *children,
278 classes="setting-row",
279 id=f"{ROW_ID_PREFIX}{key}",
280 )
282 def _build_model_picker_row(self, key: str) -> Horizontal:
283 """A button-style row that opens the same ModelPickerModal as the chat bar."""
284 return Horizontal(
285 Button(
286 model_picker_label(key),
287 id=f"{MODEL_PICKER_BUTTON_PREFIX}{key}",
288 classes="setting-model-picker-button",
289 ),
290 classes="setting-editor-row",
291 )
293 @on(Input.Submitted, ".setting-editor")
294 @on(Input.Blurred, ".setting-editor")
295 def _on_input_save(self, event: Input.Submitted | Input.Blurred) -> None:
296 """Save string/number input on submit or blur."""
297 name = event.input.name
298 if name is None:
299 return
300 defn = SETTINGS_MAP.get(name)
301 if defn is None:
302 return
303 raw = event.value.strip()
304 current = str(getattr(cfg, name, ""))
305 if raw == current:
306 return
307 self._persist_value(name, defn, raw)
309 @on(ListTextArea.Blurred, ".setting-multiline-editor")
310 def _on_multiline_save(self, event: ListTextArea.Blurred) -> None:
311 """Save multi-line string settings (system prompts) on blur."""
312 ta = event.control
313 name = ta.name
314 if name is None:
315 return
316 defn = SETTINGS_MAP.get(name)
317 if defn is None:
318 return
319 raw = ta.text
320 current = str(getattr(cfg, name, ""))
321 if raw == current:
322 return
323 self._persist_value(name, defn, raw)
325 @on(Checkbox.Changed, ".setting-editor")
326 def _on_checkbox_save(self, event: Checkbox.Changed) -> None:
327 """Save boolean on toggle."""
328 name = event.checkbox.name
329 if name is None:
330 return
331 defn = SETTINGS_MAP.get(name)
332 if defn is None:
333 return
334 self._persist_value(name, defn, str(event.checkbox.value))
336 @on(Select.Changed, ".setting-editor")
337 def _on_select_save(self, event: Select.Changed) -> None:
338 """Save select choice on change."""
339 name = event.select.name
340 if name is None:
341 return
342 defn = SETTINGS_MAP.get(name)
343 if defn is None:
344 return
345 value = str(event.value) if event.value != Select.BLANK else ""
346 current = str(getattr(cfg, name, ""))
347 if value == current:
348 return
349 self._persist_value(name, defn, value)
351 def _persist_value(self, key: str, defn: SettingDef, raw: str) -> None:
352 """Parse, apply, and persist a setting value. Success is silent; errors toast."""
353 try:
354 parsed = self._parse_value(defn, raw)
355 self.app.set_setting(key, parsed)
356 self._refresh_help(key, defn)
357 except (ValueError, TypeError) as exc:
358 self.notify(msg.SETTINGS_INVALID_VALUE.format(error=exc), severity="error")
360 def _parse_value(self, defn: SettingDef, raw: str) -> object:
361 """Convert a raw string to the setting's target type."""
362 if defn.nullable and raw.lower() in ("none", "null", ""):
363 return None
364 if defn.type is bool:
365 return raw.lower() in ("true", "1", "yes", "on")
366 if defn.type is list:
367 return [line.strip() for line in raw.split("\n") if line.strip()]
368 return defn.type(raw)
370 @staticmethod
371 def _validate_regex_list(lines: list[str]) -> tuple[int, str] | None:
372 """Return the 1-indexed line number and error for the first bad regex, or None."""
373 for i, line in enumerate(lines, 1):
374 try:
375 re.compile(line)
376 except re.error as exc:
377 return (i, str(exc))
378 return None
380 @on(ListTextArea.Blurred, ".setting-list-editor")
381 def _on_list_blur_save(self, event: ListTextArea.Blurred) -> None:
382 """Validate and save list values when a ListTextArea loses focus."""
383 ta = event.control
384 key = ta.name
385 if key is None:
386 return
387 defn = SETTINGS_MAP.get(key)
388 if defn is None:
389 return
390 raw = ta.text
391 parsed = self._parse_value(defn, raw)
392 assert isinstance(parsed, list) # noqa: S101 -- mypy narrowing, defn.type is list above
393 err = self._validate_regex_list(parsed) if defn.validate_regex else None
394 error_widget = self.query_one(f"#{LIST_ERROR_ID_PREFIX}{key}", Static)
395 if err is not None:
396 line_no, err_text = err
397 error_widget.update(
398 msg.SETTINGS_LIST_EDITOR_INVALID_REGEX.format(n=line_no, error=err_text)
399 )
400 error_widget.add_class(LIST_ERROR_VISIBLE_CLASS)
401 return
402 error_widget.remove_class(LIST_ERROR_VISIBLE_CLASS)
403 self._persist_value(key, defn, raw)
404 self._refresh_list_title(key, len(parsed))
406 @on(Button.Pressed, ".setting-list-restore")
407 def _on_list_restore(self, event: Button.Pressed) -> None:
408 """Restore defaults for a list setting."""
409 btn_id = event.button.id
410 if btn_id is None or not btn_id.startswith(LIST_RESTORE_PREFIX):
411 return
412 key = btn_id.removeprefix(LIST_RESTORE_PREFIX)
413 defn = SETTINGS_MAP.get(key)
414 if defn is None:
415 return
416 default = get_default(key)
417 defaults = list(default) if isinstance(default, list) else []
418 text = "\n".join(str(item) for item in defaults)
419 ta = self.query_one(f"#{EDITOR_ID_PREFIX}{key}", ListTextArea)
420 ta.load_text(text)
421 self._persist_value(key, defn, text)
422 error_widget = self.query_one(f"#{LIST_ERROR_ID_PREFIX}{key}", Static)
423 error_widget.remove_class(LIST_ERROR_VISIBLE_CLASS)
424 self._refresh_list_title(key, len(defaults))
426 def _refresh_list_title(self, key: str, count: int) -> None:
427 """Update the Collapsible title to reflect the current line count."""
428 try:
429 collapsible = self.query_one(f"#collapsible-{key}", Collapsible)
430 collapsible.title = msg.SETTINGS_LIST_EDITOR_TITLE.format(key=key, count=count)
431 except Exception:
432 log.debug("Failed to refresh collapsible title for %s", key, exc_info=True)
434 def _refresh_help(self, key: str, defn: SettingDef) -> None:
435 """Update the help text after a value change; an OCR key refreshes every OCR row."""
436 if key not in OCR_SETTING_KEYS:
437 self._update_help_row(key, defn)
438 return
439 for ocr_key in OCR_SETTING_KEYS:
440 self._update_help_row(ocr_key, SETTINGS_MAP[ocr_key])
442 def _update_help_row(self, key: str, defn: SettingDef) -> None:
443 """Re-render one row's help text."""
444 try:
445 row = self.query_one(f"#{ROW_ID_PREFIX}{key}", VerticalGroup)
446 help_widget = row.query_one(".setting-help", Static)
447 help_widget.update(help_content(key, defn))
448 except Exception:
449 log.debug("Failed to refresh help for %s", key, exc_info=True)
451 @on(Button.Pressed, ".setting-reset-button")
452 def _on_reset_pressed(self, event: Button.Pressed) -> None:
453 """Handle the small reset button embedded in each writable row."""
454 button_id = event.button.id
455 if button_id is None or not button_id.startswith(RESET_BUTTON_ID_PREFIX):
456 return
457 key = button_id[len(RESET_BUTTON_ID_PREFIX) :]
458 self._reset_to_default(key)
460 @on(Button.Pressed, ".setting-model-picker-button")
461 def _on_model_picker_pressed(self, event: Button.Pressed) -> None:
462 """Open ModelPickerModal for the model field this button represents."""
463 button_id = event.button.id
464 if button_id is None or not button_id.startswith(MODEL_PICKER_BUTTON_PREFIX):
465 return
466 key = button_id[len(MODEL_PICKER_BUTTON_PREFIX) :]
467 scope = model_field_to_picker_scope().get(key)
468 if scope is None:
469 return
470 self._discover_then_open_picker(key, scope)
472 @work(thread=True, exit_on_error=False)
473 def _discover_then_open_picker(self, key: str, scope: PickerScope) -> None:
474 """Discover installed models off the UI thread, then push the picker.
476 ``classify_installed_models_full`` probes the native registry,
477 Ollama (HTTP), and litellm provider lists. Running it on the
478 event loop blocks paint for hundreds of ms; the chat-bar uses
479 the same worker pattern.
480 """
481 from lilbee.cli.tui.thread_safe import call_from_thread
482 from lilbee.cli.tui.widgets.model_bar import classify_installed_models_full
484 task = picker_scope_to_task(scope)
485 buckets = classify_installed_models_full()
486 options = list(buckets.get(task, []))
487 call_from_thread(self, self._push_model_picker, key, scope, options)
489 def _push_model_picker(self, key: str, scope: PickerScope, options: list[ModelOption]) -> None:
490 """Push ModelPickerModal once the worker has resolved options."""
491 from lilbee.cli.tui.screens.model_picker import ModelPickerModal
492 from lilbee.cli.tui.widgets.model_bar import ModelOption
494 # Bail out if the user navigated away from Settings while the
495 # discovery worker was still running; otherwise we'd push the
496 # modal onto whatever screen is now on top.
497 if not self.is_mounted:
498 return
499 if not options:
500 options = [ModelOption(label=msg.MODEL_VALUE_NONE, ref="")]
501 # Nullable model fields (vision_model, reranker_model) need an
502 # explicit "disable this model" pick. The picker's empty-input
503 # cancel returns None; this row returns "" so the dismiss
504 # handler can distinguish "cancel" from "set to none".
505 defn = SETTINGS_MAP.get(key)
506 if defn is not None and defn.nullable:
507 options = [
508 ModelOption(label=msg.MODEL_PICKER_DISABLE_LABEL, ref=""),
509 *options,
510 ]
511 self.app.push_screen(
512 ModelPickerModal(scope=scope, options=options),
513 lambda ref: self._on_model_picker_dismissed(key, ref),
514 )
516 def _on_model_picker_dismissed(self, key: str, ref: str | None) -> None:
517 """Persist the picker selection, refresh the button, and reload the role's server."""
518 # apply_model_pick owns the reload (off the event loop in a worker); the
519 # on_done callback only repaints the button once the swap is applied.
520 apply_model_pick(self, key=key, ref=ref, on_done=lambda: self._after_model_pick(key))
522 def _after_model_pick(self, key: str) -> None:
523 """Refresh the picker button after a swap.
525 The role reload is owned by ``apply_model_pick`` (it runs off the event
526 loop in a worker), so this on_done only repaints the label. Reloading
527 here too would double the fleet restart AND block the UI on the main
528 thread, which is the freeze this path is meant to avoid.
529 """
530 self._refresh_picker_button(key)
531 self._refresh_help(key, SETTINGS_MAP[key])
533 def _refresh_picker_button(self, key: str) -> None:
534 try:
535 button = self.query_one(f"#{MODEL_PICKER_BUTTON_PREFIX}{key}", Button)
536 button.label = model_picker_label(key)
537 except Exception:
538 log.debug("Failed to refresh model picker label for %s", key, exc_info=True)
540 def action_reset_all(self) -> None:
541 """Bound to Ctrl+Shift+R; opens the destructive-confirm dialog."""
542 from lilbee.cli.tui.widgets.confirm_dialog import ConfirmDialog
544 self.app.push_screen(
545 ConfirmDialog(
546 title=msg.SETTINGS_RESET_ALL_CONFIRM_TITLE,
547 message=msg.SETTINGS_RESET_ALL_CONFIRM_MESSAGE,
548 ),
549 self._on_reset_all_confirmed,
550 )
552 def _on_reset_all_confirmed(self, confirmed: bool | None) -> None:
553 """Reset every writable setting to its cfg default atomically."""
554 if not confirmed:
555 return
557 writable = [(key, defn) for key, defn in SETTINGS_MAP.items() if defn.writable]
558 try:
559 result = reset_settings([key for key, _ in writable], skip_unresettable=True)
560 except (ValueError, OSError) as exc:
561 self.notify(msg.SETTINGS_INVALID_VALUE.format(error=exc), severity="error")
562 return
563 resettable = set(result.updated)
564 for key, defn in writable:
565 if key not in resettable:
566 continue
567 self._refresh_editor(key, defn, getattr(cfg, key))
568 self._refresh_help(key, defn)
569 self.app.settings_changed_signal.publish((key, getattr(cfg, key)))
570 self.notify(msg.SETTINGS_RESET_ALL_SUCCESS)
572 def action_reset_focused(self) -> None:
573 """Reset the currently-focused setting row to its cfg default."""
574 focused = self.focused
575 if focused is None:
576 return
577 for ancestor in focused.ancestors_with_self:
578 ancestor_id = getattr(ancestor, "id", None)
579 if ancestor_id and ancestor_id.startswith(ROW_ID_PREFIX):
580 key = ancestor_id[len(ROW_ID_PREFIX) :]
581 self._reset_to_default(key)
582 return
584 def _reset_to_default(self, key: str) -> None:
585 """Restore a single setting to its cfg default."""
586 defn = SETTINGS_MAP.get(key)
587 if defn is None or not defn.writable:
588 return
589 default = get_default(key)
590 stringified = stringify_default(default)
591 self._persist_value(key, defn, stringified)
592 self._refresh_editor(key, defn, default)
594 def _refresh_editor(self, key: str, defn: SettingDef, value: object) -> None:
595 """Update the editor widget to reflect a new value (e.g. after reset)."""
596 try:
597 widget = self.query_one(f"#{EDITOR_ID_PREFIX}{key}")
598 except Exception:
599 log.debug("Failed to refresh editor for %s", key, exc_info=True)
600 return
601 set_widget_value(widget, value)
603 def action_go_back(self) -> None:
604 self.app.go_back()
606 def _active_pane_body(self) -> _LazyGroupBody | None:
607 """Resolve the currently-active settings tab body (a VerticalScroll).
609 j/k/g/G key actions scroll this body directly because the outer
610 ``#settings-scroll`` is a Container, not a scroller -- one column
611 of scrolling per screen, the active tab's pane.
612 """
613 try:
614 tabs = self.query_one("#settings-tabs", TabbedContent)
615 except Exception:
616 return None
617 active = tabs.active
618 if not active:
619 return None
620 try:
621 return self.query_one(f"#{active}-body", _LazyGroupBody)
622 except Exception:
623 return None
625 def action_cursor_down(self) -> None:
626 if (body := self._active_pane_body()) is not None:
627 body.scroll_down()
629 def action_cursor_up(self) -> None:
630 if (body := self._active_pane_body()) is not None:
631 body.scroll_up()
633 def action_jump_top(self) -> None:
634 if (body := self._active_pane_body()) is not None:
635 body.scroll_home()
637 def action_jump_bottom(self) -> None:
638 if (body := self._active_pane_body()) is not None:
639 body.scroll_end()
641 def action_next_field_or_pane(self) -> None:
642 """Tab inside a pane; on overflow advance to the next group tab."""
643 self._move_focus_within_pane(direction=1)
645 def action_prev_field_or_pane(self) -> None:
646 """Shift+Tab inside a pane; on underflow retreat to the previous group tab."""
647 self._move_focus_within_pane(direction=-1)
649 def action_cycle_pane(self, delta: int) -> None:
650 """Step the active settings tab by *delta*, wrapping around the strip.
652 Shortcut for users who don't want to Tab through every field to
653 reach the next group. Mirrors CatalogScreen.action_cycle_tab. A
654 focused editor is not a concern here: a focused Input/TextArea
655 consumes printable keys before even priority bindings see them
656 (verified empirically), so < and > always type into editors.
657 """
658 try:
659 tabs = self.query_one("#settings-tabs", TabbedContent)
660 except Exception:
661 return
662 pane_ids = list(self._pane_groups)
663 if not pane_ids:
664 return
665 try:
666 current = pane_ids.index(tabs.active)
667 except ValueError:
668 current = 0
669 next_id = pane_ids[(current + delta) % len(pane_ids)]
670 if tabs.active != next_id:
671 tabs.active = next_id
673 def _focus_adjacent(self, direction: int) -> None:
674 """Move focus to the next/previous widget app-wide (direction 1 / -1)."""
675 if direction == 1:
676 self.app.action_focus_next()
677 else:
678 self.app.action_focus_previous()
680 def _move_focus_within_pane(self, *, direction: int) -> None:
681 focused = self.app.focused
682 tabs = self.query_one("#settings-tabs", TabbedContent)
683 active_pane_id = tabs.active
684 try:
685 body = self.query_one(f"#{active_pane_id}-body", _LazyGroupBody)
686 except Exception:
687 self._focus_adjacent(direction)
688 return
689 focusables = [w for w in body.query("*") if w.focusable]
690 if not focusables or focused is None or focused not in focusables:
691 self._focus_adjacent(direction)
692 return
693 index = focusables.index(focused)
694 next_index = index + direction
695 if 0 <= next_index < len(focusables):
696 focusables[next_index].focus()
697 return
698 # At the boundary: advance to the next/previous pane.
699 pane_ids = list(self._pane_groups.keys())
700 if active_pane_id not in pane_ids:
701 return
702 target_index = (pane_ids.index(active_pane_id) + direction) % len(pane_ids)
703 target_pane = pane_ids[target_index]
704 tabs.active = target_pane
705 self._populate_pane(target_pane)
706 # Park focus on the first/last field of the new pane so the next
707 # Tab keeps moving in the same direction.
708 self.call_after_refresh(self._focus_pane_edge, target_pane, direction)
710 def _focus_pane_edge(self, pane_id: str, direction: int) -> None:
711 try:
712 body = self.query_one(f"#{pane_id}-body", _LazyGroupBody)
713 except Exception:
714 return
715 focusables = [w for w in body.query("*") if w.focusable]
716 if not focusables:
717 return
718 focusables[0 if direction == 1 else -1].focus()