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

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

57 

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 

62 

63log = logging.getLogger(__name__) 

64 

65 

66@dataclass(frozen=True) 

67class _PaneGroup: 

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

69 

70 pane_id: str 

71 group_name: SettingGroup 

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

73 

74 

75class _LazyGroupBody(VerticalScroll, can_focus=False): 

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

77 

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

79 super().__init__(id=id) 

80 self._populated = False 

81 

82 @property 

83 def populated(self) -> bool: 

84 return self._populated 

85 

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) 

94 

95 

96class SettingsScreen(Screen[None]): 

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

98 

99 app: LilbeeApp # type: ignore[assignment] 

100 

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 ) 

114 

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) 

119 

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 ] 

153 

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 

163 

164 def compose(self) -> ComposeResult: 

165 from textual.widgets import Footer 

166 

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 

171 

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

184 

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 

205 

206 def on_mount(self) -> None: 

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

208 

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) 

217 

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) 

225 

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) 

230 

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

242 

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 

256 

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 ) 

281 

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 ) 

292 

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) 

308 

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) 

324 

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

335 

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) 

350 

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

359 

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) 

369 

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 

379 

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

405 

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

425 

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) 

433 

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) 

442 

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) 

451 

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) 

463 

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. 

467 

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 

475 

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) 

480 

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 

485 

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 ) 

507 

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

513 

514 def _after_model_pick(self, key: str) -> None: 

515 """Refresh the picker button after a swap. 

516 

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) 

523 

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) 

530 

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 

534 

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 ) 

542 

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 

547 

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) 

562 

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 

574 

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) 

584 

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) 

593 

594 def action_go_back(self) -> None: 

595 self.app.go_back() 

596 

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

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

599 

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 

615 

616 def action_cursor_down(self) -> None: 

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

618 body.scroll_down() 

619 

620 def action_cursor_up(self) -> None: 

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

622 body.scroll_up() 

623 

624 def action_jump_top(self) -> None: 

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

626 body.scroll_home() 

627 

628 def action_jump_bottom(self) -> None: 

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

630 body.scroll_end() 

631 

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) 

635 

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) 

639 

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

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

642 

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 

663 

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

670 

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) 

700 

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