Coverage for src / lilbee / cli / tui / screens / settings.py: 100%
407 statements
« prev ^ index » next coverage.py v7.13.4, created at 2026-06-28 01:01 +0000
« prev ^ index » next coverage.py v7.13.4, created at 2026-06-28 01:01 +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.screens.settings_widgets import (
32 API_KEYS_GROUP,
33 API_KEYS_WARNING_CLASS,
34 EDITOR_ID_PREFIX,
35 LIST_ERROR_ID_PREFIX,
36 LIST_ERROR_VISIBLE_CLASS,
37 LIST_RESTORE_PREFIX,
38 MODEL_PICKER_BUTTON_PREFIX,
39 RESET_BUTTON_ID_PREFIX,
40 RESET_BUTTON_LABEL,
41 ROW_ID_PREFIX,
42 config_toml_path,
43 group_settings,
44 help_content,
45 make_editor,
46 model_field_to_picker_scope,
47 model_picker_label,
48 picker_scope_to_task,
49 set_widget_value,
50 stringify_default,
51 title_content,
52)
53from lilbee.cli.tui.widgets.list_text_area import ListTextArea
54from lilbee.cli.tui.widgets.model_pick import apply_model_pick
55from lilbee.core.config import DEFAULT_CRAWL_EXCLUDE_PATTERNS, cfg
57if TYPE_CHECKING:
58 from lilbee.cli.tui.app import LilbeeApp
59 from lilbee.cli.tui.screens.model_picker import PickerScope
60 from lilbee.cli.tui.widgets.model_bar import ModelOption
62log = logging.getLogger(__name__)
65@dataclass(frozen=True)
66class _PaneGroup:
67 """One settings tab: pane id, group label, ordered settings."""
69 pane_id: str
70 group_name: SettingGroup
71 items: list[tuple[str, SettingDef]]
74class _LazyGroupBody(VerticalScroll, can_focus=False):
75 """Pane-body that mounts rows on first activation; scrolls when taller than viewport."""
77 def __init__(self, *, id: str | None = None) -> None:
78 super().__init__(id=id)
79 self._populated = False
81 @property
82 def populated(self) -> bool:
83 return self._populated
85 def populate(self, build: Callable[[], list[Widget]]) -> None:
86 """Build and mount this pane's row widgets exactly once."""
87 if self._populated:
88 return
89 self._populated = True
90 widgets = build()
91 if widgets:
92 self.mount_all(widgets)
95class SettingsScreen(Screen[None]):
96 """Interactive settings viewer with grouped, type-aware editors."""
98 app: LilbeeApp # type: ignore[assignment]
100 CSS_PATH = "settings.tcss"
101 # Target the TabbedContent's inner Tabs strip rather than the outer
102 # #settings-scroll Container -- Container can't accept focus, so on
103 # mount focus would otherwise stay at None and downstream Tab-cycling
104 # has nowhere to start. The Tabs widget is the canonical entry point.
105 AUTO_FOCUS = "#settings-tabs Tabs"
106 HELP = (
107 "Browse and edit configuration.\n\n"
108 "Use / to search, Enter to confirm, Escape to return to the list."
109 )
111 BINDINGS: ClassVar[list[BindingType]] = [
112 Binding("q", "go_back", "Back", show=True),
113 Binding("escape", "go_back", "Back", show=False),
114 # Tab cycles editors inside the active pane and rolls over to the
115 # next group tab when you Tab past the last editor (and the
116 # previous group tab on shift+Tab past the first editor). Use
117 # > / < to jump straight to the next / previous group tab.
118 Binding("tab", "next_field_or_pane", "Next field", show=True),
119 Binding("shift+tab", "prev_field_or_pane", "Prev field", show=True),
120 # Direct tab cycling, mirrored from CatalogScreen. priority=True
121 # so the bindings win when an editor input has focus.
122 Binding("greater_than_sign", "cycle_pane(1)", "Next tab", show=True, priority=True),
123 Binding("less_than_sign", "cycle_pane(-1)", "Prev tab", show=True, priority=True),
124 Binding("ctrl+r", "reset_focused", "Reset field", show=False),
125 Binding("ctrl+shift+r", "reset_all", "Reset all", show=True),
126 Binding("j", "scroll_down", "Down", show=False),
127 Binding("k", "scroll_up", "Up", show=False),
128 Binding("g", "scroll_home", "Top", show=False),
129 Binding("G", "scroll_end", "End", show=False),
130 ]
132 def __init__(self) -> None:
133 super().__init__()
134 # Group definitions for lazy-mount on tab activation. Indexed
135 # by pane id so the activated-pane handler can look up its
136 # bundle in O(1). ``_eagerly_populate`` is the pane id whose
137 # body gets populated in on_mount (the active-by-default first
138 # pane); the rest fill in on first activation.
139 self._pane_groups: dict[str, _PaneGroup] = {}
140 self._eagerly_populate: str | None = None
142 def compose(self) -> ComposeResult:
143 from textual.widgets import Footer
145 from lilbee.cli.tui.widgets.bottom_bars import BottomBars
146 from lilbee.cli.tui.widgets.status_bar import ViewTabs
147 from lilbee.cli.tui.widgets.task_bar import TaskBar
148 from lilbee.cli.tui.widgets.top_bars import TopBars
150 with TopBars():
151 yield ViewTabs()
152 # Container (not VerticalScroll) here -- each tab body is itself a
153 # VerticalScroll, and stacking two scrollables on the same column
154 # tears the layout when the inner one wheels past its top edge
155 # (bb-...-wiki-tear). Only the inner pane scrolls; the outer just
156 # reserves the flex row.
157 with Container(id="settings-scroll"), TabbedContent(id="settings-tabs"):
158 yield from self._compose_group_tabs()
159 with BottomBars():
160 yield TaskBar()
161 yield Footer()
163 def _compose_group_tabs(self) -> ComposeResult:
164 """Yield one TabPane per setting group; bodies populate on activation."""
165 first = True
166 for group_name, items in group_settings().items():
167 pane_id = f"settings-tab-{group_name.lower().replace('-', '_')}"
168 self._pane_groups[pane_id] = _PaneGroup(
169 pane_id=pane_id, group_name=group_name, items=items
170 )
171 yield TabPane(
172 group_name,
173 _LazyGroupBody(id=f"{pane_id}-body"),
174 id=pane_id,
175 )
176 # The first pane is the one TabbedContent activates by
177 # default; populate it eagerly so a user landing on
178 # Settings sees content on first paint instead of an empty
179 # active pane that fills in one frame later.
180 if first:
181 first = False
182 self._eagerly_populate = pane_id
184 def on_mount(self) -> None:
185 """Defer first-pane content mount until after the screen has painted.
187 ``_populate_pane`` calls ``mount_all`` for ~25 editor widgets which
188 triggers a full Textual layout pass; running it inside ``on_mount``
189 adds that pass to the screen-switch latency budget. ``call_after_refresh``
190 moves it to the next event-loop tick so the user sees the empty pane
191 skeleton immediately and the rows hydrate one frame later.
192 """
193 if self._eagerly_populate is not None:
194 self.call_after_refresh(self._populate_pane, self._eagerly_populate)
196 @on(TabbedContent.TabActivated)
197 def _on_tab_activated(self, event: TabbedContent.TabActivated) -> None:
198 """Populate the activated pane's body on first activation."""
199 pane = event.pane
200 if pane is None or pane.id is None:
201 return
202 self._populate_pane(pane.id)
204 def populate_all_panes(self) -> None:
205 """Force every tab body to populate now (test/agent helper)."""
206 for pane_id in self._pane_groups:
207 self._populate_pane(pane_id)
209 def _populate_pane(self, pane_id: str) -> None:
210 """Populate a pane's body if known and the body widget is mounted."""
211 group = self._pane_groups.get(pane_id)
212 if group is None:
213 return
214 try:
215 body = self.query_one(f"#{pane_id}-body", _LazyGroupBody)
216 except Exception:
217 log.debug("pane body %s not yet mounted", pane_id, exc_info=True)
218 return
219 body.populate(lambda: self._build_pane_widgets(group))
221 def _build_pane_widgets(self, group: _PaneGroup) -> list[Widget]:
222 """Return the body widgets for one settings tab."""
223 widgets: list[Widget] = []
224 if group.group_name == API_KEYS_GROUP:
225 widgets.append(
226 Static(
227 msg.SETTINGS_API_KEYS_WARNING.format(path=config_toml_path()),
228 classes=API_KEYS_WARNING_CLASS,
229 )
230 )
231 for key, defn in group.items:
232 widgets.append(self._build_setting_row(key, defn))
233 return widgets
235 def _build_setting_row(self, key: str, defn: SettingDef) -> VerticalGroup:
236 """Construct one setting row with its title, help, editor, and reset."""
237 title = Static(title_content(key, defn), classes="setting-title")
238 help_widget = Static(help_content(key, defn), classes="setting-help")
239 children: list[Widget] = [title, help_widget]
240 if key in model_field_to_picker_scope():
241 children.append(self._build_model_picker_row(key))
242 elif defn.writable:
243 editor_row = Horizontal(
244 make_editor(key, defn),
245 Button(
246 RESET_BUTTON_LABEL,
247 id=f"{RESET_BUTTON_ID_PREFIX}{key}",
248 classes="setting-reset-button",
249 tooltip=msg.SETTINGS_RESET_TO_DEFAULT_TOOLTIP,
250 ),
251 classes="setting-editor-row",
252 )
253 children.append(editor_row)
254 return VerticalGroup(
255 *children,
256 classes="setting-row",
257 id=f"{ROW_ID_PREFIX}{key}",
258 )
260 def _build_model_picker_row(self, key: str) -> Horizontal:
261 """A button-style row that opens the same ModelPickerModal as the chat bar."""
262 return Horizontal(
263 Button(
264 model_picker_label(key),
265 id=f"{MODEL_PICKER_BUTTON_PREFIX}{key}",
266 classes="setting-model-picker-button",
267 ),
268 classes="setting-editor-row",
269 )
271 @on(Input.Submitted, ".setting-editor")
272 @on(Input.Blurred, ".setting-editor")
273 def _on_input_save(self, event: Input.Submitted | Input.Blurred) -> None:
274 """Save string/number input on submit or blur."""
275 name = event.input.name
276 if name is None:
277 return
278 defn = SETTINGS_MAP.get(name)
279 if defn is None:
280 return
281 raw = event.value.strip()
282 current = str(getattr(cfg, name, ""))
283 if raw == current:
284 return
285 self._persist_value(name, defn, raw)
287 @on(ListTextArea.Blurred, ".setting-multiline-editor")
288 def _on_multiline_save(self, event: ListTextArea.Blurred) -> None:
289 """Save multi-line string settings (system prompts) on blur."""
290 ta = event.control
291 name = ta.name
292 if name is None:
293 return
294 defn = SETTINGS_MAP.get(name)
295 if defn is None:
296 return
297 raw = ta.text
298 current = str(getattr(cfg, name, ""))
299 if raw == current:
300 return
301 self._persist_value(name, defn, raw)
303 @on(Checkbox.Changed, ".setting-editor")
304 def _on_checkbox_save(self, event: Checkbox.Changed) -> None:
305 """Save boolean on toggle."""
306 name = event.checkbox.name
307 if name is None:
308 return
309 defn = SETTINGS_MAP.get(name)
310 if defn is None:
311 return
312 self._persist_value(name, defn, str(event.checkbox.value))
314 @on(Select.Changed, ".setting-editor")
315 def _on_select_save(self, event: Select.Changed) -> None:
316 """Save select choice on change."""
317 name = event.select.name
318 if name is None:
319 return
320 defn = SETTINGS_MAP.get(name)
321 if defn is None:
322 return
323 value = str(event.value) if event.value != Select.BLANK else ""
324 current = str(getattr(cfg, name, ""))
325 if value == current:
326 return
327 self._persist_value(name, defn, value)
329 def _persist_value(self, key: str, defn: SettingDef, raw: str) -> None:
330 """Parse, apply, and persist a setting value. Success is silent; errors toast."""
331 try:
332 parsed = self._parse_value(defn, raw)
333 self.app.set_setting(key, parsed)
334 self._refresh_help(key, defn)
335 except (ValueError, TypeError) as exc:
336 self.notify(msg.SETTINGS_INVALID_VALUE.format(error=exc), severity="error")
338 def _parse_value(self, defn: SettingDef, raw: str) -> object:
339 """Convert a raw string to the setting's target type."""
340 if defn.nullable and raw.lower() in ("none", "null", ""):
341 return None
342 if defn.type is bool:
343 return raw.lower() in ("true", "1", "yes", "on")
344 if defn.type is list:
345 return [line.strip() for line in raw.split("\n") if line.strip()]
346 return defn.type(raw)
348 @staticmethod
349 def _validate_regex_list(lines: list[str]) -> tuple[int, str] | None:
350 """Return the 1-indexed line number and error for the first bad regex, or None."""
351 for i, line in enumerate(lines, 1):
352 try:
353 re.compile(line)
354 except re.error as exc:
355 return (i, str(exc))
356 return None
358 @on(ListTextArea.Blurred, ".setting-list-editor")
359 def _on_list_blur_save(self, event: ListTextArea.Blurred) -> None:
360 """Validate and save list values when a ListTextArea loses focus."""
361 ta = event.control
362 key = ta.name
363 if key is None:
364 return
365 defn = SETTINGS_MAP.get(key)
366 if defn is None:
367 return
368 raw = ta.text
369 parsed = self._parse_value(defn, raw)
370 assert isinstance(parsed, list) # noqa: S101 -- mypy narrowing, defn.type is list above
371 err = self._validate_regex_list(parsed)
372 error_widget = self.query_one(f"#{LIST_ERROR_ID_PREFIX}{key}", Static)
373 if err is not None:
374 line_no, err_text = err
375 error_widget.update(
376 msg.SETTINGS_LIST_EDITOR_INVALID_REGEX.format(n=line_no, error=err_text)
377 )
378 error_widget.add_class(LIST_ERROR_VISIBLE_CLASS)
379 return
380 error_widget.remove_class(LIST_ERROR_VISIBLE_CLASS)
381 self._persist_value(key, defn, raw)
382 self._refresh_list_title(key, len(parsed))
384 @on(Button.Pressed, ".setting-list-restore")
385 def _on_list_restore(self, event: Button.Pressed) -> None:
386 """Restore defaults for a LIST_COLLAPSED setting."""
387 btn_id = event.button.id
388 if btn_id is None or not btn_id.startswith(LIST_RESTORE_PREFIX):
389 return
390 key = btn_id.removeprefix(LIST_RESTORE_PREFIX)
391 defn = SETTINGS_MAP.get(key)
392 if defn is None:
393 return
394 defaults = list(DEFAULT_CRAWL_EXCLUDE_PATTERNS)
395 text = "\n".join(defaults)
396 ta = self.query_one(f"#ed-{key}", ListTextArea)
397 ta.load_text(text)
398 self._persist_value(key, defn, text)
399 error_widget = self.query_one(f"#{LIST_ERROR_ID_PREFIX}{key}", Static)
400 error_widget.remove_class(LIST_ERROR_VISIBLE_CLASS)
401 self._refresh_list_title(key, len(defaults))
403 def _refresh_list_title(self, key: str, count: int) -> None:
404 """Update the Collapsible title to reflect the current line count."""
405 try:
406 collapsible = self.query_one(f"#collapsible-{key}", Collapsible)
407 collapsible.title = msg.SETTINGS_LIST_EDITOR_TITLE.format(key=key, count=count)
408 except Exception:
409 log.debug("Failed to refresh collapsible title for %s", key, exc_info=True)
411 def _refresh_help(self, key: str, defn: SettingDef) -> None:
412 """Update the help text after a value change."""
413 try:
414 row = self.query_one(f"#{ROW_ID_PREFIX}{key}", VerticalGroup)
415 help_widget = row.query_one(".setting-help", Static)
416 help_widget.update(help_content(key, defn))
417 except Exception:
418 log.debug("Failed to refresh help for %s", key, exc_info=True)
420 @on(Button.Pressed, ".setting-reset-button")
421 def _on_reset_pressed(self, event: Button.Pressed) -> None:
422 """Handle the small reset button embedded in each writable row."""
423 button_id = event.button.id
424 if button_id is None or not button_id.startswith(RESET_BUTTON_ID_PREFIX):
425 return
426 key = button_id[len(RESET_BUTTON_ID_PREFIX) :]
427 self._reset_to_default(key)
429 @on(Button.Pressed, ".setting-model-picker-button")
430 def _on_model_picker_pressed(self, event: Button.Pressed) -> None:
431 """Open ModelPickerModal for the model field this button represents."""
432 button_id = event.button.id
433 if button_id is None or not button_id.startswith(MODEL_PICKER_BUTTON_PREFIX):
434 return
435 key = button_id[len(MODEL_PICKER_BUTTON_PREFIX) :]
436 scope = model_field_to_picker_scope().get(key)
437 if scope is None:
438 return
439 self._discover_then_open_picker(key, scope)
441 @work(thread=True, exit_on_error=False)
442 def _discover_then_open_picker(self, key: str, scope: PickerScope) -> None:
443 """Discover installed models off the UI thread, then push the picker.
445 ``classify_installed_models_full`` probes the native registry,
446 Ollama (HTTP), and litellm provider lists. Running it on the
447 event loop blocks paint for hundreds of ms; the chat-bar uses
448 the same worker pattern.
449 """
450 from lilbee.cli.tui.thread_safe import call_from_thread
451 from lilbee.cli.tui.widgets.model_bar import classify_installed_models_full
453 task = picker_scope_to_task(scope)
454 buckets = classify_installed_models_full()
455 options = list(buckets.get(task, []))
456 call_from_thread(self, self._push_model_picker, key, scope, options)
458 def _push_model_picker(self, key: str, scope: PickerScope, options: list[ModelOption]) -> None:
459 """Push ModelPickerModal once the worker has resolved options."""
460 from lilbee.cli.tui.screens.model_picker import ModelPickerModal
461 from lilbee.cli.tui.widgets.model_bar import ModelOption
463 # Bail out if the user navigated away from Settings while the
464 # discovery worker was still running; otherwise we'd push the
465 # modal onto whatever screen is now on top.
466 if not self.is_mounted:
467 return
468 if not options:
469 options = [ModelOption(label=msg.MODEL_VALUE_NONE, ref="")]
470 # Nullable model fields (vision_model, reranker_model) need an
471 # explicit "disable this model" pick. The picker's empty-input
472 # cancel returns None; this row returns "" so the dismiss
473 # handler can distinguish "cancel" from "set to none".
474 defn = SETTINGS_MAP.get(key)
475 if defn is not None and defn.nullable:
476 options = [
477 ModelOption(label=msg.MODEL_PICKER_DISABLE_LABEL, ref=""),
478 *options,
479 ]
480 self.app.push_screen(
481 ModelPickerModal(scope=scope, options=options),
482 lambda ref: self._on_model_picker_dismissed(key, ref),
483 )
485 def _on_model_picker_dismissed(self, key: str, ref: str | None) -> None:
486 """Persist the picker selection and refresh the button label."""
487 apply_model_pick(self, key=key, ref=ref, on_done=lambda: self._refresh_picker_button(key))
489 def _refresh_picker_button(self, key: str) -> None:
490 try:
491 button = self.query_one(f"#{MODEL_PICKER_BUTTON_PREFIX}{key}", Button)
492 button.label = model_picker_label(key)
493 except Exception:
494 log.debug("Failed to refresh model picker label for %s", key, exc_info=True)
496 def action_reset_all(self) -> None:
497 """Bound to Ctrl+Shift+R; opens the destructive-confirm dialog."""
498 from lilbee.cli.tui.widgets.confirm_dialog import ConfirmDialog
500 self.app.push_screen(
501 ConfirmDialog(
502 title=msg.SETTINGS_RESET_ALL_CONFIRM_TITLE,
503 message=msg.SETTINGS_RESET_ALL_CONFIRM_MESSAGE,
504 ),
505 self._on_reset_all_confirmed,
506 )
508 def _on_reset_all_confirmed(self, confirmed: bool | None) -> None:
509 """Reset every writable setting to its cfg default atomically."""
510 if not confirmed:
511 return
513 writable = [(key, defn) for key, defn in SETTINGS_MAP.items() if defn.writable]
514 try:
515 result = reset_settings([key for key, _ in writable], skip_unresettable=True)
516 except (ValueError, OSError) as exc:
517 self.notify(msg.SETTINGS_INVALID_VALUE.format(error=exc), severity="error")
518 return
519 resettable = set(result.updated)
520 for key, defn in writable:
521 if key not in resettable:
522 continue
523 self._refresh_editor(key, defn, getattr(cfg, key))
524 self._refresh_help(key, defn)
525 self.app.settings_changed_signal.publish((key, getattr(cfg, key)))
526 self.notify(msg.SETTINGS_RESET_ALL_SUCCESS)
528 def action_reset_focused(self) -> None:
529 """Reset the currently-focused setting row to its cfg default."""
530 focused = self.focused
531 if focused is None:
532 return
533 for ancestor in focused.ancestors_with_self:
534 ancestor_id = getattr(ancestor, "id", None)
535 if ancestor_id and ancestor_id.startswith(ROW_ID_PREFIX):
536 key = ancestor_id[len(ROW_ID_PREFIX) :]
537 self._reset_to_default(key)
538 return
540 def _reset_to_default(self, key: str) -> None:
541 """Restore a single setting to its cfg default."""
542 defn = SETTINGS_MAP.get(key)
543 if defn is None or not defn.writable:
544 return
545 default = get_default(key)
546 stringified = stringify_default(default)
547 self._persist_value(key, defn, stringified)
548 self._refresh_editor(key, defn, default)
550 def _refresh_editor(self, key: str, defn: SettingDef, value: object) -> None:
551 """Update the editor widget to reflect a new value (e.g. after reset)."""
552 try:
553 widget = self.query_one(f"#{EDITOR_ID_PREFIX}{key}")
554 except Exception:
555 log.debug("Failed to refresh editor for %s", key, exc_info=True)
556 return
557 set_widget_value(widget, value)
559 def action_go_back(self) -> None:
560 self.app.switch_view("Chat")
562 def _active_pane_body(self) -> _LazyGroupBody | None:
563 """Resolve the currently-active settings tab body (a VerticalScroll).
565 j/k/g/G key actions scroll this body directly because the outer
566 ``#settings-scroll`` is a Container, not a scroller -- one column
567 of scrolling per screen, the active tab's pane.
568 """
569 try:
570 tabs = self.query_one("#settings-tabs", TabbedContent)
571 except Exception:
572 return None
573 active = tabs.active
574 if not active:
575 return None
576 try:
577 return self.query_one(f"#{active}-body", _LazyGroupBody)
578 except Exception:
579 return None
581 def action_scroll_down(self) -> None:
582 if (body := self._active_pane_body()) is not None:
583 body.scroll_down()
585 def action_scroll_up(self) -> None:
586 if (body := self._active_pane_body()) is not None:
587 body.scroll_up()
589 def action_scroll_home(self) -> None:
590 if (body := self._active_pane_body()) is not None:
591 body.scroll_home()
593 def action_scroll_end(self) -> None:
594 if (body := self._active_pane_body()) is not None:
595 body.scroll_end()
597 def action_next_field_or_pane(self) -> None:
598 """Tab inside a pane; on overflow advance to the next group tab."""
599 self._move_focus_within_pane(direction=1)
601 def action_prev_field_or_pane(self) -> None:
602 """Shift+Tab inside a pane; on underflow retreat to the previous group tab."""
603 self._move_focus_within_pane(direction=-1)
605 def action_cycle_pane(self, delta: int) -> None:
606 """Step the active settings tab by *delta*, wrapping around the strip.
608 Shortcut for users who don't want to Tab through every field to
609 reach the next group. Mirrors CatalogScreen.action_cycle_tab.
610 """
611 try:
612 tabs = self.query_one("#settings-tabs", TabbedContent)
613 except Exception:
614 return
615 pane_ids = list(self._pane_groups)
616 if not pane_ids:
617 return
618 try:
619 current = pane_ids.index(tabs.active)
620 except ValueError:
621 current = 0
622 next_id = pane_ids[(current + delta) % len(pane_ids)]
623 if tabs.active != next_id:
624 tabs.active = next_id
626 def _move_focus_within_pane(self, *, direction: int) -> None:
627 focused = self.app.focused
628 tabs = self.query_one("#settings-tabs", TabbedContent)
629 active_pane_id = tabs.active
630 try:
631 body = self.query_one(f"#{active_pane_id}-body", _LazyGroupBody)
632 except Exception:
633 self.app.action_focus_next() if direction == 1 else self.app.action_focus_previous()
634 return
635 focusables = [w for w in body.query("*") if w.focusable]
636 if not focusables or focused is None or focused not in focusables:
637 self.app.action_focus_next() if direction == 1 else self.app.action_focus_previous()
638 return
639 index = focusables.index(focused)
640 next_index = index + direction
641 if 0 <= next_index < len(focusables):
642 focusables[next_index].focus()
643 return
644 # At the boundary: advance to the next/previous pane.
645 pane_ids = list(self._pane_groups.keys())
646 if active_pane_id not in pane_ids:
647 return
648 target_index = (pane_ids.index(active_pane_id) + direction) % len(pane_ids)
649 target_pane = pane_ids[target_index]
650 tabs.active = target_pane
651 self._populate_pane(target_pane)
652 # Park focus on the first/last field of the new pane so the next
653 # Tab keeps moving in the same direction.
654 self.call_after_refresh(self._focus_pane_edge, target_pane, direction)
656 def _focus_pane_edge(self, pane_id: str, direction: int) -> None:
657 try:
658 body = self.query_one(f"#{pane_id}-body", _LazyGroupBody)
659 except Exception:
660 return
661 focusables = [w for w in body.query("*") if w.focusable]
662 if not focusables:
663 return
664 focusables[0 if direction == 1 else -1].focus()