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

342 statements  

« prev     ^ index     » next       coverage.py v7.15.2, created at 2026-11-06 08:17 +0000

1"""Profile dialogs: apply a profile, save the project's values as a new one, and their workers.""" 

2 

3from __future__ import annotations 

4 

5from collections.abc import Callable, Sequence 

6from dataclasses import dataclass 

7from enum import StrEnum 

8from pathlib import Path 

9from typing import TYPE_CHECKING, Any, ClassVar, TypeVar 

10 

11from rich.cells import cell_len, chop_cells 

12from textual import on 

13from textual.app import ComposeResult, CSSPathType 

14from textual.binding import Binding, BindingType 

15from textual.containers import Horizontal, Vertical, VerticalScroll 

16from textual.content import Content 

17from textual.screen import ModalScreen, Screen 

18from textual.widget import Widget 

19from textual.widgets import DataTable, Input, Select, Static 

20 

21from lilbee.app import profiles 

22from lilbee.app.profiles import DiffRow, ProfileDiff, ProfileEffect 

23from lilbee.app.services import get_services 

24from lilbee.app.settings import index_has_chunks 

25from lilbee.cli.tui import messages as msg 

26from lilbee.cli.tui.pill import pill 

27from lilbee.cli.tui.screens.settings_widgets import user_pill 

28from lilbee.cli.tui.thread_safe import call_from_thread 

29from lilbee.cli.tui.widgets.confirm_dialog import ConfirmPill 

30from lilbee.core.config import cfg 

31from lilbee.core.profile_files import ( 

32 MAX_NAME_LENGTH, 

33 NameProblem, 

34 ProfileCatalog, 

35 ProfileFile, 

36 ProfileFolder, 

37 ProfileStore, 

38 name_problem, 

39 profile_folders, 

40 profile_key, 

41) 

42 

43if TYPE_CHECKING: 

44 from lilbee.cli.tui.app import LilbeeApp 

45 

46T = TypeVar("T") 

47 

48PROFILE_OP_GROUP = "profile-op" 

49DISABLED_CLASS = "-disabled" 

50COPY_NAME_TRIES = 100 

51_ELLIPSIS = "…" 

52 

53 

54class ApplyChoice(StrEnum): 

55 """How the Apply dialog closed.""" 

56 

57 APPLY = "apply" 

58 APPLY_REINDEX = "apply_reindex" 

59 CANCEL = "cancel" 

60 

61 

62class _DialogAnswer(StrEnum): 

63 SAVE = "save" 

64 CANCEL = "cancel" 

65 

66 

67@dataclass(frozen=True) 

68class SwitchPlan: 

69 """What applying a profile does: its file, the diff, and the files a reindex rebuilds.""" 

70 

71 profile: ProfileFile 

72 diff: ProfileDiff 

73 kept: tuple[tuple[str, Any], ...] 

74 file_count: int 

75 rebuild: profiles.RebuildReason | None 

76 reapply: bool = False 

77 

78 

79@dataclass(frozen=True) 

80class SaveRequest: 

81 """The name and folder the Save-as dialog asks for.""" 

82 

83 name: str 

84 folder: ProfileFolder 

85 

86 

87@dataclass(frozen=True) 

88class PathRequest: 

89 """What the path dialog asks for: a path, and a folder when it offers one.""" 

90 

91 path: Path 

92 folder: ProfileFolder | None 

93 

94 

95def shorten_middle(text: str, width: int) -> str: 

96 """*text* in at most *width* cells (two or more); a longer text loses its middle.""" 

97 if cell_len(text) <= width: 

98 return text 

99 kept = width - 1 

100 tail = kept // 2 

101 head_text = chop_cells(text, kept - tail)[0] 

102 tail_text = chop_cells(text[::-1], tail)[0][::-1] if tail else "" 

103 return f"{head_text}{_ELLIPSIS}{tail_text}" 

104 

105 

106def value_text(value: object) -> str: 

107 """A setting value as the profile surfaces show it: on/off, none, or a comma list.""" 

108 # values are raw Config or TOML values of any setting type 

109 if isinstance(value, bool): 

110 return msg.PROFILE_VALUE_ON if value else msg.PROFILE_VALUE_OFF 

111 if value is None: 

112 return msg.PROFILE_VALUE_NONE 

113 if isinstance(value, list | tuple): 

114 return ", ".join(str(item) for item in value) or msg.PROFILE_VALUE_NONE 

115 return str(value) 

116 

117 

118def add_cost_column(table: DataTable[str | Content]) -> None: 

119 """Add the Cost column, sized to its longest pill; DataTable does not measure Content cells.""" 

120 width = max(len(text) for text in msg.PROFILE_EFFECT_TEXT.values()) + 2 

121 table.add_column(msg.PROFILE_COL_COST, width=width) 

122 

123 

124def effect_cell(effect: ProfileEffect) -> Content: 

125 """The Cost cell: a loud pill for a reindex, plain text otherwise.""" 

126 text = msg.PROFILE_EFFECT_TEXT[effect] 

127 return pill(text, "$error", "$text") if effect is ProfileEffect.REINDEX else Content(text) 

128 

129 

130def credit_text(profile: ProfileFile) -> str: 

131 """The credit line: the authors, then what the profile was tested on.""" 

132 parts = [ 

133 profiles.credit_line(profile.authors), 

134 profiles.tested_on_line(profile.tested_on, msg.PROFILE_TESTED_ON), 

135 ] 

136 return ". ".join(part for part in parts if part) 

137 

138 

139def has_project_folder() -> bool: 

140 """Whether the current data root has a project profile folder.""" 

141 return ProfileFolder.PROJECT in dict(profile_folders(cfg.data_root)) 

142 

143 

144def save_folders(has_project: bool) -> tuple[ProfileFolder, ...]: 

145 """The folders a new profile can go to, the default first.""" 

146 if has_project: 

147 return (ProfileFolder.GLOBAL, ProfileFolder.PROJECT) 

148 return (ProfileFolder.GLOBAL,) 

149 

150 

151def _copy_candidate(name: str, number: int) -> str: 

152 """The copy name for *name* that fits the name limit; its base is cut to make room.""" 

153 suffix = msg.PROFILE_DUPLICATE_INITIAL.format(name="") 

154 if number > 1: 

155 suffix = f"{suffix} {number}" 

156 return name[: MAX_NAME_LENGTH - len(suffix)].rstrip() + suffix 

157 

158 

159def free_copy_name(name: str, folder: ProfileFolder, catalog: ProfileCatalog) -> str: 

160 """The first copy name *folder* accepts. 

161 

162 When every name up to COPY_NAME_TRIES is taken, the last name built is returned 

163 unchecked, and the dialog shows its problem with Save off. 

164 """ 

165 candidate = _copy_candidate(name, 1) 

166 for number in range(2, COPY_NAME_TRIES + 1): 

167 if name_problem(candidate, folder, catalog) is None: 

168 break 

169 candidate = _copy_candidate(name, number) 

170 return candidate 

171 

172 

173def _folder_choice(select_id: str, folders: Sequence[ProfileFolder]) -> ComposeResult: 

174 """The "Save to" Select; nothing when there is no choice to make.""" 

175 if len(folders) <= 1: 

176 return 

177 yield Static(msg.PROFILE_SAVE_TO, classes="profile-section-title") 

178 options = [(msg.PROFILE_SAVE_FOLDER_TEXT[folder], folder) for folder in folders] 

179 yield Select(options, value=next(iter(folders)), allow_blank=False, id=select_id) 

180 

181 

182def _chosen_folder( 

183 screen: Screen[Any], select_id: str, folders: Sequence[ProfileFolder] 

184) -> ProfileFolder: 

185 default, *others = folders 

186 if not others: 

187 return default 

188 return ProfileFolder(str(screen.query_one(f"#{select_id}", Select).value)) 

189 

190 

191def run_profile_op( 

192 node: Widget, 

193 op: Callable[[], T], 

194 on_done: Callable[[T], None], 

195 on_error: Callable[[], None] | None = None, 

196) -> None: 

197 """Run *op* off the loop; on failure toast and call *on_error*, else call *on_done*.""" 

198 

199 def _work() -> None: 

200 try: 

201 result = op() 

202 except ValueError as exc: 

203 call_from_thread(node, node.notify, str(exc), severity="error") 

204 if on_error is not None: 

205 call_from_thread(node, on_error) 

206 return 

207 except OSError as exc: 

208 call_from_thread( 

209 node, node.notify, profiles.file_failure_message(exc), severity="error" 

210 ) 

211 if on_error is not None: 

212 call_from_thread(node, on_error) 

213 return 

214 call_from_thread(node, on_done, result) 

215 

216 node.run_worker(_work, thread=True, group=PROFILE_OP_GROUP, exclusive=True, exit_on_error=False) 

217 

218 

219def switch_plan(profile: ProfileFile, diff: ProfileDiff) -> SwitchPlan: 

220 """The Apply dialog's plan for *profile*: its diff, your kept values, the files to rebuild.""" 

221 reindex = any(row.effect is ProfileEffect.REINDEX for row in diff.changes) 

222 rebuild = profiles.rebuild_reason(profile.values, reindex) 

223 files = len(get_services().store.get_sources()) if reindex and index_has_chunks() else 0 

224 kept = tuple((key, getattr(cfg, key)) for key in diff.kept) 

225 reapply = profile_key(profile.name) == profile_key(profiles.active(ProfileStore()).name) 

226 return SwitchPlan(profile, diff, kept, files, rebuild, reapply) 

227 

228 

229def _switch_plan(name: str) -> SwitchPlan: 

230 """Diff *name* against the project and count the files a reindex would rebuild.""" 

231 store = ProfileStore() 

232 return switch_plan(profiles.usable(store, name), profiles.diff(store, name)) 

233 

234 

235def start_switch( 

236 app: LilbeeApp, 

237 node: Widget, 

238 plan: Callable[[], SwitchPlan], 

239 apply: Callable[[], profiles.ApplyResult], 

240 *, 

241 on_close: Callable[[], None], 

242 on_applied: Callable[[], None] = lambda: None, 

243) -> None: 

244 """Build *plan* off the loop, ask with the Apply dialog, then *apply*; *on_close* runs after. 

245 

246 *on_applied* runs only when the profile was applied. 

247 """ 

248 

249 def _on_choice(switch: SwitchPlan, choice: ApplyChoice | None) -> None: 

250 if choice is None or choice is ApplyChoice.CANCEL: 

251 on_close() 

252 return 

253 

254 def _applied(result: profiles.ApplyResult) -> None: 

255 changed = [row.key for row in result.changes] 

256 app.publish_settings(changed) 

257 if choice is ApplyChoice.APPLY_REINDEX: 

258 app.start_rebuild() 

259 elif switch.rebuild is not None: 

260 # The dialog asked about the profile's rows only; other edits stay owed. 

261 app.rebuild_answered(changed) 

262 node.notify(msg.PROFILE_APPLIED.format(name=result.name)) 

263 app.notify_warnings(result.warnings) 

264 on_close() 

265 on_applied() 

266 

267 run_profile_op(node, apply, _applied) 

268 

269 def _ask(switch: SwitchPlan) -> None: 

270 app.push_screen(ApplyProfileDialog(switch), lambda choice: _on_choice(switch, choice)) 

271 

272 run_profile_op(node, plan, _ask, on_error=on_close) 

273 

274 

275def start_profile_switch( 

276 app: LilbeeApp, 

277 node: Widget, 

278 name: str, 

279 on_close: Callable[[], None], 

280 on_applied: Callable[[], None] = lambda: None, 

281) -> None: 

282 """Diff *name* off the loop, ask with the Apply dialog, then apply; *on_close* runs after.""" 

283 start_switch( 

284 app, 

285 node, 

286 lambda: _switch_plan(name), 

287 lambda: profiles.apply(ProfileStore(), name), 

288 on_close=on_close, 

289 on_applied=on_applied, 

290 ) 

291 

292 

293class ApplyProfileDialog(ModalScreen[ApplyChoice]): 

294 """Shows what applying a profile changes and keeps; nothing changes until a pill is picked.""" 

295 

296 CSS_PATH: ClassVar[CSSPathType] = ["dialog.tcss", "profile_dialogs.tcss"] 

297 

298 BINDINGS: ClassVar[list[BindingType]] = [ 

299 Binding("escape", "cancel", "Cancel", show=False), 

300 Binding("left", "previous_pill", "Previous", show=False), 

301 Binding("right", "next_pill", "Next", show=False), 

302 ] 

303 

304 def __init__(self, plan: SwitchPlan) -> None: 

305 super().__init__() 

306 self._plan = plan 

307 self._reindex_rows = [ 

308 row for row in plan.diff.changes if row.effect is ProfileEffect.REINDEX 

309 ] 

310 

311 def compose(self) -> ComposeResult: 

312 profile = self._plan.profile 

313 with Vertical(id="apply-body", classes="dialog-body"): 

314 with VerticalScroll(id="apply-scroll"): 

315 title = msg.PROFILE_REAPPLY_TITLE if self._plan.reapply else msg.PROFILE_APPLY_TITLE 

316 yield Static( 

317 title.format(name=profile.name), 

318 id="apply-title", 

319 classes="dialog-title", 

320 markup=False, 

321 ) 

322 for text, widget_id in ( 

323 (profile.description, "apply-description"), 

324 (credit_text(profile), "apply-credit"), 

325 ): 

326 if text: 

327 yield Static(text, id=widget_id, classes="dialog-note", markup=False) 

328 yield from self._compose_changes() 

329 yield from self._compose_kept() 

330 yield Static( 

331 msg.PROFILE_APPLY_UNTOUCHED, id="apply-untouched", classes="dialog-note" 

332 ) 

333 summary = self._summary() 

334 if summary: 

335 yield Static(summary, id="apply-summary", markup=False) 

336 with Horizontal(id="apply-actions"): 

337 yield from self._action_pills() 

338 

339 def _compose_changes(self) -> ComposeResult: 

340 if not self._plan.diff.changes: 

341 return 

342 yield Static(msg.PROFILE_APPLY_CHANGES, classes="profile-section-title") 

343 table: DataTable[str | Content] = DataTable( 

344 id="apply-changes", cursor_type="none", zebra_stripes=False 

345 ) 

346 table.can_focus = False 

347 table.add_columns(msg.PROFILE_COL_SETTING, msg.PROFILE_COL_NOW, msg.PROFILE_COL_AFTER) 

348 add_cost_column(table) 

349 table.add_rows(diff_cells(row) for row in self._plan.diff.changes) 

350 yield table 

351 

352 def _compose_kept(self) -> ComposeResult: 

353 if not self._plan.kept: 

354 return 

355 yield Static(msg.PROFILE_APPLY_KEEPS, classes="profile-section-title") 

356 kept = ", ".join(f"{key} {value_text(value)}" for key, value in self._plan.kept) 

357 yield Static(Content.assemble(kept, " ", user_pill()), id="apply-kept") 

358 

359 def _summary(self) -> str: 

360 if self._plan.rebuild is not None: 

361 return msg.profile_rebuild_reason_text( 

362 self._plan.rebuild, len(self._reindex_rows), self._plan.file_count 

363 ) 

364 if not self._plan.diff.changes: 

365 return msg.PROFILE_APPLY_NOTHING 

366 return "" 

367 

368 def _action_pills(self) -> ComposeResult: 

369 if self._plan.rebuild is not None: 

370 yield ConfirmPill( 

371 msg.PROFILE_APPLY_REINDEX_LABEL, 

372 pill_id="apply-reindex", 

373 answer=ApplyChoice.APPLY_REINDEX, 

374 ) 

375 yield ConfirmPill( 

376 msg.PROFILE_APPLY_LABEL, 

377 pill_id="apply-apply", 

378 answer=ApplyChoice.APPLY, 

379 primary=True, 

380 ) 

381 yield ConfirmPill( 

382 msg.PROFILE_CANCEL_LABEL, pill_id="apply-cancel", answer=ApplyChoice.CANCEL 

383 ) 

384 

385 def on_mount(self) -> None: 

386 self.query_one("#apply-cancel", ConfirmPill).focus() 

387 

388 @on(ConfirmPill.Picked) 

389 def _on_picked(self, event: ConfirmPill.Picked) -> None: 

390 event.stop() 

391 self.dismiss(ApplyChoice(str(event.answer))) 

392 

393 def action_previous_pill(self) -> None: 

394 self.focus_previous("#apply-actions ConfirmPill") 

395 

396 def action_next_pill(self) -> None: 

397 self.focus_next("#apply-actions ConfirmPill") 

398 

399 def action_cancel(self) -> None: 

400 self.dismiss(ApplyChoice.CANCEL) 

401 

402 

403def diff_cells(row: DiffRow) -> Sequence[str | Content]: 

404 """A diff row's cells: the setting, its value now and after, and the cost.""" 

405 return (row.key, value_text(row.current), value_text(row.new), effect_cell(row.effect)) 

406 

407 

408class SaveProfileDialog(ModalScreen[SaveRequest | None]): 

409 """Asks for a new profile's name and folder; Save stays off while the name is refused.""" 

410 

411 CSS_PATH: ClassVar[CSSPathType] = ["dialog.tcss", "profile_dialogs.tcss"] 

412 

413 BINDINGS: ClassVar[list[BindingType]] = [ 

414 Binding("escape", "cancel", "Cancel", show=False), 

415 Binding("left", "app.focus_previous", "Previous", show=False), 

416 Binding("right", "app.focus_next", "Next", show=False), 

417 ] 

418 

419 def __init__( 

420 self, 

421 *, 

422 title: str, 

423 explain: str, 

424 catalog: ProfileCatalog, 

425 folders: Sequence[ProfileFolder], 

426 initial: str = "", 

427 ) -> None: 

428 super().__init__() 

429 self._title = title 

430 self._explain = explain 

431 self._catalog = catalog 

432 self._folders = folders 

433 self._initial = initial 

434 self._opened_with: SaveRequest | None = None 

435 self._edited = False 

436 

437 def compose(self) -> ComposeResult: 

438 with Vertical(id="save-body", classes="dialog-body"): 

439 yield Static(self._title, id="save-title", classes="dialog-title", markup=False) 

440 yield Static(self._explain, id="save-explain", classes="dialog-note", markup=False) 

441 yield Input(self._initial, placeholder=msg.PROFILE_SAVE_PLACEHOLDER, id="save-name") 

442 yield Static("", id="save-error", classes="dialog-error", markup=False) 

443 yield from _folder_choice("save-folder", self._folders) 

444 with Horizontal(id="save-actions"): 

445 yield ConfirmPill( 

446 msg.PROFILE_SAVE_LABEL, 

447 pill_id="save-save", 

448 answer=_DialogAnswer.SAVE, 

449 primary=True, 

450 ) 

451 yield ConfirmPill( 

452 msg.PROFILE_CANCEL_LABEL, pill_id="save-cancel", answer=_DialogAnswer.CANCEL 

453 ) 

454 

455 def on_mount(self) -> None: 

456 self._opened_with = self._request() 

457 self._check() 

458 self.query_one("#save-name", Input).focus() 

459 

460 def _request(self) -> SaveRequest: 

461 name = self.query_one("#save-name", Input).value.strip() 

462 return SaveRequest(name, _chosen_folder(self, "save-folder", self._folders)) 

463 

464 def _problem(self) -> NameProblem | None: 

465 request = self._request() 

466 return name_problem(request.name, request.folder, self._catalog) 

467 

468 def _check(self) -> None: 

469 """Turn Save off while the name has a problem, and say what it is once the user edits.""" 

470 problem = self._problem() 

471 error = self.query_one("#save-error", Static) 

472 text = msg.PROFILE_NAME_PROBLEM_TEXT[problem] if problem and self._edited else "" 

473 error.update(text) 

474 error.display = bool(text) 

475 self.query_one("#save-save", ConfirmPill).set_class(problem is not None, DISABLED_CLASS) 

476 

477 @on(Input.Changed, "#save-name") 

478 @on(Select.Changed, "#save-folder") 

479 def _on_edit(self) -> None: 

480 if self._opened_with is not None and self._request() != self._opened_with: 

481 self._edited = True 

482 self._check() 

483 

484 @on(Input.Submitted, "#save-name") 

485 def _on_submit(self) -> None: 

486 self._save() 

487 

488 @on(ConfirmPill.Picked) 

489 def _on_picked(self, event: ConfirmPill.Picked) -> None: 

490 event.stop() 

491 if event.answer is _DialogAnswer.SAVE: 

492 self._save() 

493 else: 

494 self.dismiss(None) 

495 

496 def _save(self) -> None: 

497 if self._problem() is None: 

498 self.dismiss(self._request()) 

499 

500 def action_cancel(self) -> None: 

501 self.dismiss(None) 

502 

503 

504class ProfilePathDialog(ModalScreen[profiles.ProfileLocation | None]): 

505 """Asks for a path, and for a folder when it offers a choice. 

506 

507 The dialog runs *write* with the answer and closes with its result; when *write* fails, 

508 the error shows as a toast and the dialog stays open for another path. 

509 """ 

510 

511 CSS_PATH: ClassVar[CSSPathType] = ["dialog.tcss", "profile_dialogs.tcss"] 

512 

513 BINDINGS: ClassVar[list[BindingType]] = [ 

514 Binding("escape", "cancel", "Cancel", show=False), 

515 Binding("left", "app.focus_previous", "Previous", show=False), 

516 Binding("right", "app.focus_next", "Next", show=False), 

517 ] 

518 

519 def __init__( 

520 self, 

521 *, 

522 title: str, 

523 explain: str, 

524 action_label: str, 

525 write: Callable[[PathRequest], profiles.ProfileLocation], 

526 initial: str = "", 

527 folders: Sequence[ProfileFolder] = (), 

528 ) -> None: 

529 super().__init__() 

530 self._write = write 

531 self._title = title 

532 self._explain = explain 

533 self._action_label = action_label 

534 self._initial = initial 

535 self._folders = folders 

536 self._opened_with: str | None = None 

537 self._edited = False 

538 

539 def compose(self) -> ComposeResult: 

540 with Vertical(id="path-body", classes="dialog-body"): 

541 yield Static(self._title, id="path-title", classes="dialog-title", markup=False) 

542 with VerticalScroll(id="path-fields"): 

543 yield Static(self._explain, id="path-explain", classes="dialog-note", markup=False) 

544 yield Input( 

545 self._initial, placeholder=msg.PROFILE_PATH_PLACEHOLDER, id="path-input" 

546 ) 

547 yield from _folder_choice("path-folder", self._folders) 

548 yield Static("", id="path-error", classes="dialog-error", markup=False) 

549 with Horizontal(id="path-actions"): 

550 yield ConfirmPill( 

551 self._action_label, pill_id="path-ok", answer=_DialogAnswer.SAVE, primary=True 

552 ) 

553 yield ConfirmPill( 

554 msg.PROFILE_CANCEL_LABEL, pill_id="path-cancel", answer=_DialogAnswer.CANCEL 

555 ) 

556 

557 def on_mount(self) -> None: 

558 self._opened_with = self._path() 

559 self._check() 

560 self.query_one("#path-input", Input).focus() 

561 

562 def _path(self) -> str: 

563 return self.query_one("#path-input", Input).value.strip() 

564 

565 def _check(self) -> None: 

566 """Turn the action off while the path is empty, and say so once the user edits.""" 

567 empty = not self._path() 

568 error = self.query_one("#path-error", Static) 

569 text = msg.PROFILE_PATH_EMPTY if empty and self._edited else "" 

570 error.update(text) 

571 error.display = bool(text) 

572 self.query_one("#path-ok", ConfirmPill).set_class(empty, DISABLED_CLASS) 

573 

574 def _request(self) -> PathRequest: 

575 folder = _chosen_folder(self, "path-folder", self._folders) if self._folders else None 

576 return PathRequest(Path(self._path()).expanduser(), folder) 

577 

578 @on(Input.Changed, "#path-input") 

579 def _on_edit(self) -> None: 

580 if self._opened_with is not None and self._path() != self._opened_with: 

581 self._edited = True 

582 self._check() 

583 

584 @on(Input.Submitted, "#path-input") 

585 def _on_submit(self) -> None: 

586 self._confirm() 

587 

588 @on(ConfirmPill.Picked) 

589 def _on_picked(self, event: ConfirmPill.Picked) -> None: 

590 event.stop() 

591 if event.answer is _DialogAnswer.SAVE: 

592 self._confirm() 

593 else: 

594 self.dismiss(None) 

595 

596 def _confirm(self) -> None: 

597 if self._path(): 

598 request = self._request() 

599 run_profile_op(self, lambda: self._write(request), self._finish) 

600 

601 def _finish(self, location: profiles.ProfileLocation) -> None: 

602 self.dismiss(location) 

603 

604 def action_cancel(self) -> None: 

605 self.dismiss(None)