Coverage for src/lilbee/cli/tui/screens/settings.py: 100%

423 statements  

« prev     ^ index     » next       coverage.py v7.15.2, created at 2026-09-28 17:20 +0000

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 OCR_SETTING_KEYS, reset_settings 

29from lilbee.app.settings_map import SETTINGS_MAP, SettingDef, SettingGroup, get_default 

30from lilbee.cli.tui import messages as msg 

31from lilbee.cli.tui.browse_bindings import BROWSE_LIST_BINDINGS, browse_back_bindings 

32from lilbee.cli.tui.screens.settings_widgets import ( 

33 API_KEYS_GROUP, 

34 API_KEYS_WARNING_CLASS, 

35 EDITOR_ID_PREFIX, 

36 LIST_ERROR_ID_PREFIX, 

37 LIST_ERROR_VISIBLE_CLASS, 

38 LIST_RESTORE_PREFIX, 

39 MODEL_PICKER_BUTTON_PREFIX, 

40 RESET_BUTTON_ID_PREFIX, 

41 RESET_BUTTON_LABEL, 

42 ROW_ID_PREFIX, 

43 config_toml_path, 

44 group_settings, 

45 help_content, 

46 make_editor, 

47 model_field_to_picker_scope, 

48 model_picker_label, 

49 picker_scope_to_task, 

50 set_widget_value, 

51 stringify_default, 

52 title_content, 

53) 

54from lilbee.cli.tui.widgets.list_text_area import ListTextArea 

55from lilbee.cli.tui.widgets.model_pick import apply_model_pick 

56from lilbee.core.config import cfg 

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 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; an OCR key refreshes every OCR row.""" 

436 if key not in OCR_SETTING_KEYS: 

437 self._update_help_row(key, defn) 

438 return 

439 for ocr_key in OCR_SETTING_KEYS: 

440 self._update_help_row(ocr_key, SETTINGS_MAP[ocr_key]) 

441 

442 def _update_help_row(self, key: str, defn: SettingDef) -> None: 

443 """Re-render one row's help text.""" 

444 try: 

445 row = self.query_one(f"#{ROW_ID_PREFIX}{key}", VerticalGroup) 

446 help_widget = row.query_one(".setting-help", Static) 

447 help_widget.update(help_content(key, defn)) 

448 except Exception: 

449 log.debug("Failed to refresh help for %s", key, exc_info=True) 

450 

451 @on(Button.Pressed, ".setting-reset-button") 

452 def _on_reset_pressed(self, event: Button.Pressed) -> None: 

453 """Handle the small reset button embedded in each writable row.""" 

454 button_id = event.button.id 

455 if button_id is None or not button_id.startswith(RESET_BUTTON_ID_PREFIX): 

456 return 

457 key = button_id[len(RESET_BUTTON_ID_PREFIX) :] 

458 self._reset_to_default(key) 

459 

460 @on(Button.Pressed, ".setting-model-picker-button") 

461 def _on_model_picker_pressed(self, event: Button.Pressed) -> None: 

462 """Open ModelPickerModal for the model field this button represents.""" 

463 button_id = event.button.id 

464 if button_id is None or not button_id.startswith(MODEL_PICKER_BUTTON_PREFIX): 

465 return 

466 key = button_id[len(MODEL_PICKER_BUTTON_PREFIX) :] 

467 scope = model_field_to_picker_scope().get(key) 

468 if scope is None: 

469 return 

470 self._discover_then_open_picker(key, scope) 

471 

472 @work(thread=True, exit_on_error=False) 

473 def _discover_then_open_picker(self, key: str, scope: PickerScope) -> None: 

474 """Discover installed models off the UI thread, then push the picker. 

475 

476 ``classify_installed_models_full`` probes the native registry, 

477 Ollama (HTTP), and litellm provider lists. Running it on the 

478 event loop blocks paint for hundreds of ms; the chat-bar uses 

479 the same worker pattern. 

480 """ 

481 from lilbee.cli.tui.thread_safe import call_from_thread 

482 from lilbee.cli.tui.widgets.model_bar import classify_installed_models_full 

483 

484 task = picker_scope_to_task(scope) 

485 buckets = classify_installed_models_full() 

486 options = list(buckets.get(task, [])) 

487 call_from_thread(self, self._push_model_picker, key, scope, options) 

488 

489 def _push_model_picker(self, key: str, scope: PickerScope, options: list[ModelOption]) -> None: 

490 """Push ModelPickerModal once the worker has resolved options.""" 

491 from lilbee.cli.tui.screens.model_picker import ModelPickerModal 

492 from lilbee.cli.tui.widgets.model_bar import ModelOption 

493 

494 # Bail out if the user navigated away from Settings while the 

495 # discovery worker was still running; otherwise we'd push the 

496 # modal onto whatever screen is now on top. 

497 if not self.is_mounted: 

498 return 

499 if not options: 

500 options = [ModelOption(label=msg.MODEL_VALUE_NONE, ref="")] 

501 # Nullable model fields (vision_model, reranker_model) need an 

502 # explicit "disable this model" pick. The picker's empty-input 

503 # cancel returns None; this row returns "" so the dismiss 

504 # handler can distinguish "cancel" from "set to none". 

505 defn = SETTINGS_MAP.get(key) 

506 if defn is not None and defn.nullable: 

507 options = [ 

508 ModelOption(label=msg.MODEL_PICKER_DISABLE_LABEL, ref=""), 

509 *options, 

510 ] 

511 self.app.push_screen( 

512 ModelPickerModal(scope=scope, options=options), 

513 lambda ref: self._on_model_picker_dismissed(key, ref), 

514 ) 

515 

516 def _on_model_picker_dismissed(self, key: str, ref: str | None) -> None: 

517 """Persist the picker selection, refresh the button, and reload the role's server.""" 

518 # apply_model_pick owns the reload (off the event loop in a worker); the 

519 # on_done callback only repaints the button once the swap is applied. 

520 apply_model_pick(self, key=key, ref=ref, on_done=lambda: self._after_model_pick(key)) 

521 

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

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

524 

525 The role reload is owned by ``apply_model_pick`` (it runs off the event 

526 loop in a worker), so this on_done only repaints the label. Reloading 

527 here too would double the fleet restart AND block the UI on the main 

528 thread, which is the freeze this path is meant to avoid. 

529 """ 

530 self._refresh_picker_button(key) 

531 self._refresh_help(key, SETTINGS_MAP[key]) 

532 

533 def _refresh_picker_button(self, key: str) -> None: 

534 try: 

535 button = self.query_one(f"#{MODEL_PICKER_BUTTON_PREFIX}{key}", Button) 

536 button.label = model_picker_label(key) 

537 except Exception: 

538 log.debug("Failed to refresh model picker label for %s", key, exc_info=True) 

539 

540 def action_reset_all(self) -> None: 

541 """Bound to Ctrl+Shift+R; opens the destructive-confirm dialog.""" 

542 from lilbee.cli.tui.widgets.confirm_dialog import ConfirmDialog 

543 

544 self.app.push_screen( 

545 ConfirmDialog( 

546 title=msg.SETTINGS_RESET_ALL_CONFIRM_TITLE, 

547 message=msg.SETTINGS_RESET_ALL_CONFIRM_MESSAGE, 

548 ), 

549 self._on_reset_all_confirmed, 

550 ) 

551 

552 def _on_reset_all_confirmed(self, confirmed: bool | None) -> None: 

553 """Reset every writable setting to its cfg default atomically.""" 

554 if not confirmed: 

555 return 

556 

557 writable = [(key, defn) for key, defn in SETTINGS_MAP.items() if defn.writable] 

558 try: 

559 result = reset_settings([key for key, _ in writable], skip_unresettable=True) 

560 except (ValueError, OSError) as exc: 

561 self.notify(msg.SETTINGS_INVALID_VALUE.format(error=exc), severity="error") 

562 return 

563 resettable = set(result.updated) 

564 for key, defn in writable: 

565 if key not in resettable: 

566 continue 

567 self._refresh_editor(key, defn, getattr(cfg, key)) 

568 self._refresh_help(key, defn) 

569 self.app.settings_changed_signal.publish((key, getattr(cfg, key))) 

570 self.notify(msg.SETTINGS_RESET_ALL_SUCCESS) 

571 

572 def action_reset_focused(self) -> None: 

573 """Reset the currently-focused setting row to its cfg default.""" 

574 focused = self.focused 

575 if focused is None: 

576 return 

577 for ancestor in focused.ancestors_with_self: 

578 ancestor_id = getattr(ancestor, "id", None) 

579 if ancestor_id and ancestor_id.startswith(ROW_ID_PREFIX): 

580 key = ancestor_id[len(ROW_ID_PREFIX) :] 

581 self._reset_to_default(key) 

582 return 

583 

584 def _reset_to_default(self, key: str) -> None: 

585 """Restore a single setting to its cfg default.""" 

586 defn = SETTINGS_MAP.get(key) 

587 if defn is None or not defn.writable: 

588 return 

589 default = get_default(key) 

590 stringified = stringify_default(default) 

591 self._persist_value(key, defn, stringified) 

592 self._refresh_editor(key, defn, default) 

593 

594 def _refresh_editor(self, key: str, defn: SettingDef, value: object) -> None: 

595 """Update the editor widget to reflect a new value (e.g. after reset).""" 

596 try: 

597 widget = self.query_one(f"#{EDITOR_ID_PREFIX}{key}") 

598 except Exception: 

599 log.debug("Failed to refresh editor for %s", key, exc_info=True) 

600 return 

601 set_widget_value(widget, value) 

602 

603 def action_go_back(self) -> None: 

604 self.app.go_back() 

605 

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

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

608 

609 j/k/g/G key actions scroll this body directly because the outer 

610 ``#settings-scroll`` is a Container, not a scroller -- one column 

611 of scrolling per screen, the active tab's pane. 

612 """ 

613 try: 

614 tabs = self.query_one("#settings-tabs", TabbedContent) 

615 except Exception: 

616 return None 

617 active = tabs.active 

618 if not active: 

619 return None 

620 try: 

621 return self.query_one(f"#{active}-body", _LazyGroupBody) 

622 except Exception: 

623 return None 

624 

625 def action_cursor_down(self) -> None: 

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

627 body.scroll_down() 

628 

629 def action_cursor_up(self) -> None: 

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

631 body.scroll_up() 

632 

633 def action_jump_top(self) -> None: 

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

635 body.scroll_home() 

636 

637 def action_jump_bottom(self) -> None: 

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

639 body.scroll_end() 

640 

641 def action_next_field_or_pane(self) -> None: 

642 """Tab inside a pane; on overflow advance to the next group tab.""" 

643 self._move_focus_within_pane(direction=1) 

644 

645 def action_prev_field_or_pane(self) -> None: 

646 """Shift+Tab inside a pane; on underflow retreat to the previous group tab.""" 

647 self._move_focus_within_pane(direction=-1) 

648 

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

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

651 

652 Shortcut for users who don't want to Tab through every field to 

653 reach the next group. Mirrors CatalogScreen.action_cycle_tab. A 

654 focused editor is not a concern here: a focused Input/TextArea 

655 consumes printable keys before even priority bindings see them 

656 (verified empirically), so < and > always type into editors. 

657 """ 

658 try: 

659 tabs = self.query_one("#settings-tabs", TabbedContent) 

660 except Exception: 

661 return 

662 pane_ids = list(self._pane_groups) 

663 if not pane_ids: 

664 return 

665 try: 

666 current = pane_ids.index(tabs.active) 

667 except ValueError: 

668 current = 0 

669 next_id = pane_ids[(current + delta) % len(pane_ids)] 

670 if tabs.active != next_id: 

671 tabs.active = next_id 

672 

673 def _focus_adjacent(self, direction: int) -> None: 

674 """Move focus to the next/previous widget app-wide (direction 1 / -1).""" 

675 if direction == 1: 

676 self.app.action_focus_next() 

677 else: 

678 self.app.action_focus_previous() 

679 

680 def _move_focus_within_pane(self, *, direction: int) -> None: 

681 focused = self.app.focused 

682 tabs = self.query_one("#settings-tabs", TabbedContent) 

683 active_pane_id = tabs.active 

684 try: 

685 body = self.query_one(f"#{active_pane_id}-body", _LazyGroupBody) 

686 except Exception: 

687 self._focus_adjacent(direction) 

688 return 

689 focusables = [w for w in body.query("*") if w.focusable] 

690 if not focusables or focused is None or focused not in focusables: 

691 self._focus_adjacent(direction) 

692 return 

693 index = focusables.index(focused) 

694 next_index = index + direction 

695 if 0 <= next_index < len(focusables): 

696 focusables[next_index].focus() 

697 return 

698 # At the boundary: advance to the next/previous pane. 

699 pane_ids = list(self._pane_groups.keys()) 

700 if active_pane_id not in pane_ids: 

701 return 

702 target_index = (pane_ids.index(active_pane_id) + direction) % len(pane_ids) 

703 target_pane = pane_ids[target_index] 

704 tabs.active = target_pane 

705 self._populate_pane(target_pane) 

706 # Park focus on the first/last field of the new pane so the next 

707 # Tab keeps moving in the same direction. 

708 self.call_after_refresh(self._focus_pane_edge, target_pane, direction) 

709 

710 def _focus_pane_edge(self, pane_id: str, direction: int) -> None: 

711 try: 

712 body = self.query_one(f"#{pane_id}-body", _LazyGroupBody) 

713 except Exception: 

714 return 

715 focusables = [w for w in body.query("*") if w.focusable] 

716 if not focusables: 

717 return 

718 focusables[0 if direction == 1 else -1].focus()