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

1"""Settings screen. Grouped, type-aware configuration editor.""" 

2 

3from __future__ import annotations 

4 

5import logging 

6import re 

7from collections.abc import Callable 

8from dataclasses import dataclass 

9from typing import TYPE_CHECKING, ClassVar 

10 

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) 

27 

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 

56 

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 

61 

62log = logging.getLogger(__name__) 

63 

64 

65@dataclass(frozen=True) 

66class _PaneGroup: 

67 """One settings tab: pane id, group label, ordered settings.""" 

68 

69 pane_id: str 

70 group_name: SettingGroup 

71 items: list[tuple[str, SettingDef]] 

72 

73 

74class _LazyGroupBody(VerticalScroll, can_focus=False): 

75 """Pane-body that mounts rows on first activation; scrolls when taller than viewport.""" 

76 

77 def __init__(self, *, id: str | None = None) -> None: 

78 super().__init__(id=id) 

79 self._populated = False 

80 

81 @property 

82 def populated(self) -> bool: 

83 return self._populated 

84 

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) 

93 

94 

95class SettingsScreen(Screen[None]): 

96 """Interactive settings viewer with grouped, type-aware editors.""" 

97 

98 app: LilbeeApp # type: ignore[assignment] 

99 

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 ) 

110 

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 ] 

131 

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 

141 

142 def compose(self) -> ComposeResult: 

143 from textual.widgets import Footer 

144 

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 

149 

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() 

162 

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 

183 

184 def on_mount(self) -> None: 

185 """Defer first-pane content mount until after the screen has painted. 

186 

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) 

195 

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) 

203 

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) 

208 

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)) 

220 

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 

234 

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 ) 

259 

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 ) 

270 

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) 

286 

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) 

302 

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)) 

313 

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) 

328 

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") 

337 

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) 

347 

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 

357 

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)) 

383 

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)) 

402 

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) 

410 

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) 

419 

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) 

428 

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) 

440 

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. 

444 

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 

452 

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) 

457 

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 

462 

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 ) 

484 

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)) 

488 

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) 

495 

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 

499 

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 ) 

507 

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 

512 

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) 

527 

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 

539 

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) 

549 

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) 

558 

559 def action_go_back(self) -> None: 

560 self.app.switch_view("Chat") 

561 

562 def _active_pane_body(self) -> _LazyGroupBody | None: 

563 """Resolve the currently-active settings tab body (a VerticalScroll). 

564 

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 

580 

581 def action_scroll_down(self) -> None: 

582 if (body := self._active_pane_body()) is not None: 

583 body.scroll_down() 

584 

585 def action_scroll_up(self) -> None: 

586 if (body := self._active_pane_body()) is not None: 

587 body.scroll_up() 

588 

589 def action_scroll_home(self) -> None: 

590 if (body := self._active_pane_body()) is not None: 

591 body.scroll_home() 

592 

593 def action_scroll_end(self) -> None: 

594 if (body := self._active_pane_body()) is not None: 

595 body.scroll_end() 

596 

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) 

600 

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) 

604 

605 def action_cycle_pane(self, delta: int) -> None: 

606 """Step the active settings tab by *delta*, wrapping around the strip. 

607 

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 

625 

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) 

655 

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()