Coverage for src/lilbee/cli/tui/screens/settings.py: 100%
416 statements
« prev ^ index » next coverage.py v7.15.2, created at 2026-08-14 11:46 +0000
« prev ^ index » next coverage.py v7.15.2, created at 2026-08-14 11:46 +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 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_COLLAPSED 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."""
436 try:
437 row = self.query_one(f"#{ROW_ID_PREFIX}{key}", VerticalGroup)
438 help_widget = row.query_one(".setting-help", Static)
439 help_widget.update(help_content(key, defn))
440 except Exception:
441 log.debug("Failed to refresh help for %s", key, exc_info=True)
443 @on(Button.Pressed, ".setting-reset-button")
444 def _on_reset_pressed(self, event: Button.Pressed) -> None:
445 """Handle the small reset button embedded in each writable row."""
446 button_id = event.button.id
447 if button_id is None or not button_id.startswith(RESET_BUTTON_ID_PREFIX):
448 return
449 key = button_id[len(RESET_BUTTON_ID_PREFIX) :]
450 self._reset_to_default(key)
452 @on(Button.Pressed, ".setting-model-picker-button")
453 def _on_model_picker_pressed(self, event: Button.Pressed) -> None:
454 """Open ModelPickerModal for the model field this button represents."""
455 button_id = event.button.id
456 if button_id is None or not button_id.startswith(MODEL_PICKER_BUTTON_PREFIX):
457 return
458 key = button_id[len(MODEL_PICKER_BUTTON_PREFIX) :]
459 scope = model_field_to_picker_scope().get(key)
460 if scope is None:
461 return
462 self._discover_then_open_picker(key, scope)
464 @work(thread=True, exit_on_error=False)
465 def _discover_then_open_picker(self, key: str, scope: PickerScope) -> None:
466 """Discover installed models off the UI thread, then push the picker.
468 ``classify_installed_models_full`` probes the native registry,
469 Ollama (HTTP), and litellm provider lists. Running it on the
470 event loop blocks paint for hundreds of ms; the chat-bar uses
471 the same worker pattern.
472 """
473 from lilbee.cli.tui.thread_safe import call_from_thread
474 from lilbee.cli.tui.widgets.model_bar import classify_installed_models_full
476 task = picker_scope_to_task(scope)
477 buckets = classify_installed_models_full()
478 options = list(buckets.get(task, []))
479 call_from_thread(self, self._push_model_picker, key, scope, options)
481 def _push_model_picker(self, key: str, scope: PickerScope, options: list[ModelOption]) -> None:
482 """Push ModelPickerModal once the worker has resolved options."""
483 from lilbee.cli.tui.screens.model_picker import ModelPickerModal
484 from lilbee.cli.tui.widgets.model_bar import ModelOption
486 # Bail out if the user navigated away from Settings while the
487 # discovery worker was still running; otherwise we'd push the
488 # modal onto whatever screen is now on top.
489 if not self.is_mounted:
490 return
491 if not options:
492 options = [ModelOption(label=msg.MODEL_VALUE_NONE, ref="")]
493 # Nullable model fields (vision_model, reranker_model) need an
494 # explicit "disable this model" pick. The picker's empty-input
495 # cancel returns None; this row returns "" so the dismiss
496 # handler can distinguish "cancel" from "set to none".
497 defn = SETTINGS_MAP.get(key)
498 if defn is not None and defn.nullable:
499 options = [
500 ModelOption(label=msg.MODEL_PICKER_DISABLE_LABEL, ref=""),
501 *options,
502 ]
503 self.app.push_screen(
504 ModelPickerModal(scope=scope, options=options),
505 lambda ref: self._on_model_picker_dismissed(key, ref),
506 )
508 def _on_model_picker_dismissed(self, key: str, ref: str | None) -> None:
509 """Persist the picker selection, refresh the button, and reload the role's server."""
510 # apply_model_pick owns the reload (off the event loop in a worker); the
511 # on_done callback only repaints the button once the swap is applied.
512 apply_model_pick(self, key=key, ref=ref, on_done=lambda: self._after_model_pick(key))
514 def _after_model_pick(self, key: str) -> None:
515 """Refresh the picker button after a swap.
517 The role reload is owned by ``apply_model_pick`` (it runs off the event
518 loop in a worker), so this on_done only repaints the label. Reloading
519 here too would double the fleet restart AND block the UI on the main
520 thread, which is the freeze this path is meant to avoid.
521 """
522 self._refresh_picker_button(key)
524 def _refresh_picker_button(self, key: str) -> None:
525 try:
526 button = self.query_one(f"#{MODEL_PICKER_BUTTON_PREFIX}{key}", Button)
527 button.label = model_picker_label(key)
528 except Exception:
529 log.debug("Failed to refresh model picker label for %s", key, exc_info=True)
531 def action_reset_all(self) -> None:
532 """Bound to Ctrl+Shift+R; opens the destructive-confirm dialog."""
533 from lilbee.cli.tui.widgets.confirm_dialog import ConfirmDialog
535 self.app.push_screen(
536 ConfirmDialog(
537 title=msg.SETTINGS_RESET_ALL_CONFIRM_TITLE,
538 message=msg.SETTINGS_RESET_ALL_CONFIRM_MESSAGE,
539 ),
540 self._on_reset_all_confirmed,
541 )
543 def _on_reset_all_confirmed(self, confirmed: bool | None) -> None:
544 """Reset every writable setting to its cfg default atomically."""
545 if not confirmed:
546 return
548 writable = [(key, defn) for key, defn in SETTINGS_MAP.items() if defn.writable]
549 try:
550 result = reset_settings([key for key, _ in writable], skip_unresettable=True)
551 except (ValueError, OSError) as exc:
552 self.notify(msg.SETTINGS_INVALID_VALUE.format(error=exc), severity="error")
553 return
554 resettable = set(result.updated)
555 for key, defn in writable:
556 if key not in resettable:
557 continue
558 self._refresh_editor(key, defn, getattr(cfg, key))
559 self._refresh_help(key, defn)
560 self.app.settings_changed_signal.publish((key, getattr(cfg, key)))
561 self.notify(msg.SETTINGS_RESET_ALL_SUCCESS)
563 def action_reset_focused(self) -> None:
564 """Reset the currently-focused setting row to its cfg default."""
565 focused = self.focused
566 if focused is None:
567 return
568 for ancestor in focused.ancestors_with_self:
569 ancestor_id = getattr(ancestor, "id", None)
570 if ancestor_id and ancestor_id.startswith(ROW_ID_PREFIX):
571 key = ancestor_id[len(ROW_ID_PREFIX) :]
572 self._reset_to_default(key)
573 return
575 def _reset_to_default(self, key: str) -> None:
576 """Restore a single setting to its cfg default."""
577 defn = SETTINGS_MAP.get(key)
578 if defn is None or not defn.writable:
579 return
580 default = get_default(key)
581 stringified = stringify_default(default)
582 self._persist_value(key, defn, stringified)
583 self._refresh_editor(key, defn, default)
585 def _refresh_editor(self, key: str, defn: SettingDef, value: object) -> None:
586 """Update the editor widget to reflect a new value (e.g. after reset)."""
587 try:
588 widget = self.query_one(f"#{EDITOR_ID_PREFIX}{key}")
589 except Exception:
590 log.debug("Failed to refresh editor for %s", key, exc_info=True)
591 return
592 set_widget_value(widget, value)
594 def action_go_back(self) -> None:
595 self.app.go_back()
597 def _active_pane_body(self) -> _LazyGroupBody | None:
598 """Resolve the currently-active settings tab body (a VerticalScroll).
600 j/k/g/G key actions scroll this body directly because the outer
601 ``#settings-scroll`` is a Container, not a scroller -- one column
602 of scrolling per screen, the active tab's pane.
603 """
604 try:
605 tabs = self.query_one("#settings-tabs", TabbedContent)
606 except Exception:
607 return None
608 active = tabs.active
609 if not active:
610 return None
611 try:
612 return self.query_one(f"#{active}-body", _LazyGroupBody)
613 except Exception:
614 return None
616 def action_cursor_down(self) -> None:
617 if (body := self._active_pane_body()) is not None:
618 body.scroll_down()
620 def action_cursor_up(self) -> None:
621 if (body := self._active_pane_body()) is not None:
622 body.scroll_up()
624 def action_jump_top(self) -> None:
625 if (body := self._active_pane_body()) is not None:
626 body.scroll_home()
628 def action_jump_bottom(self) -> None:
629 if (body := self._active_pane_body()) is not None:
630 body.scroll_end()
632 def action_next_field_or_pane(self) -> None:
633 """Tab inside a pane; on overflow advance to the next group tab."""
634 self._move_focus_within_pane(direction=1)
636 def action_prev_field_or_pane(self) -> None:
637 """Shift+Tab inside a pane; on underflow retreat to the previous group tab."""
638 self._move_focus_within_pane(direction=-1)
640 def action_cycle_pane(self, delta: int) -> None:
641 """Step the active settings tab by *delta*, wrapping around the strip.
643 Shortcut for users who don't want to Tab through every field to
644 reach the next group. Mirrors CatalogScreen.action_cycle_tab. A
645 focused editor is not a concern here: a focused Input/TextArea
646 consumes printable keys before even priority bindings see them
647 (verified empirically), so < and > always type into editors.
648 """
649 try:
650 tabs = self.query_one("#settings-tabs", TabbedContent)
651 except Exception:
652 return
653 pane_ids = list(self._pane_groups)
654 if not pane_ids:
655 return
656 try:
657 current = pane_ids.index(tabs.active)
658 except ValueError:
659 current = 0
660 next_id = pane_ids[(current + delta) % len(pane_ids)]
661 if tabs.active != next_id:
662 tabs.active = next_id
664 def _focus_adjacent(self, direction: int) -> None:
665 """Move focus to the next/previous widget app-wide (direction 1 / -1)."""
666 if direction == 1:
667 self.app.action_focus_next()
668 else:
669 self.app.action_focus_previous()
671 def _move_focus_within_pane(self, *, direction: int) -> None:
672 focused = self.app.focused
673 tabs = self.query_one("#settings-tabs", TabbedContent)
674 active_pane_id = tabs.active
675 try:
676 body = self.query_one(f"#{active_pane_id}-body", _LazyGroupBody)
677 except Exception:
678 self._focus_adjacent(direction)
679 return
680 focusables = [w for w in body.query("*") if w.focusable]
681 if not focusables or focused is None or focused not in focusables:
682 self._focus_adjacent(direction)
683 return
684 index = focusables.index(focused)
685 next_index = index + direction
686 if 0 <= next_index < len(focusables):
687 focusables[next_index].focus()
688 return
689 # At the boundary: advance to the next/previous pane.
690 pane_ids = list(self._pane_groups.keys())
691 if active_pane_id not in pane_ids:
692 return
693 target_index = (pane_ids.index(active_pane_id) + direction) % len(pane_ids)
694 target_pane = pane_ids[target_index]
695 tabs.active = target_pane
696 self._populate_pane(target_pane)
697 # Park focus on the first/last field of the new pane so the next
698 # Tab keeps moving in the same direction.
699 self.call_after_refresh(self._focus_pane_edge, target_pane, direction)
701 def _focus_pane_edge(self, pane_id: str, direction: int) -> None:
702 try:
703 body = self.query_one(f"#{pane_id}-body", _LazyGroupBody)
704 except Exception:
705 return
706 focusables = [w for w in body.query("*") if w.focusable]
707 if not focusables:
708 return
709 focusables[0 if direction == 1 else -1].focus()