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
« 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."""
3from __future__ import annotations
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
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
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)
43if TYPE_CHECKING:
44 from lilbee.cli.tui.app import LilbeeApp
46T = TypeVar("T")
48PROFILE_OP_GROUP = "profile-op"
49DISABLED_CLASS = "-disabled"
50COPY_NAME_TRIES = 100
51_ELLIPSIS = "…"
54class ApplyChoice(StrEnum):
55 """How the Apply dialog closed."""
57 APPLY = "apply"
58 APPLY_REINDEX = "apply_reindex"
59 CANCEL = "cancel"
62class _DialogAnswer(StrEnum):
63 SAVE = "save"
64 CANCEL = "cancel"
67@dataclass(frozen=True)
68class SwitchPlan:
69 """What applying a profile does: its file, the diff, and the files a reindex rebuilds."""
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
79@dataclass(frozen=True)
80class SaveRequest:
81 """The name and folder the Save-as dialog asks for."""
83 name: str
84 folder: ProfileFolder
87@dataclass(frozen=True)
88class PathRequest:
89 """What the path dialog asks for: a path, and a folder when it offers one."""
91 path: Path
92 folder: ProfileFolder | None
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}"
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)
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)
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)
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)
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))
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,)
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
159def free_copy_name(name: str, folder: ProfileFolder, catalog: ProfileCatalog) -> str:
160 """The first copy name *folder* accepts.
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
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)
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))
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*."""
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)
216 node.run_worker(_work, thread=True, group=PROFILE_OP_GROUP, exclusive=True, exit_on_error=False)
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)
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))
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.
246 *on_applied* runs only when the profile was applied.
247 """
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
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()
267 run_profile_op(node, apply, _applied)
269 def _ask(switch: SwitchPlan) -> None:
270 app.push_screen(ApplyProfileDialog(switch), lambda choice: _on_choice(switch, choice))
272 run_profile_op(node, plan, _ask, on_error=on_close)
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 )
293class ApplyProfileDialog(ModalScreen[ApplyChoice]):
294 """Shows what applying a profile changes and keeps; nothing changes until a pill is picked."""
296 CSS_PATH: ClassVar[CSSPathType] = ["dialog.tcss", "profile_dialogs.tcss"]
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 ]
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 ]
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()
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
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")
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 ""
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 )
385 def on_mount(self) -> None:
386 self.query_one("#apply-cancel", ConfirmPill).focus()
388 @on(ConfirmPill.Picked)
389 def _on_picked(self, event: ConfirmPill.Picked) -> None:
390 event.stop()
391 self.dismiss(ApplyChoice(str(event.answer)))
393 def action_previous_pill(self) -> None:
394 self.focus_previous("#apply-actions ConfirmPill")
396 def action_next_pill(self) -> None:
397 self.focus_next("#apply-actions ConfirmPill")
399 def action_cancel(self) -> None:
400 self.dismiss(ApplyChoice.CANCEL)
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))
408class SaveProfileDialog(ModalScreen[SaveRequest | None]):
409 """Asks for a new profile's name and folder; Save stays off while the name is refused."""
411 CSS_PATH: ClassVar[CSSPathType] = ["dialog.tcss", "profile_dialogs.tcss"]
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 ]
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
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 )
455 def on_mount(self) -> None:
456 self._opened_with = self._request()
457 self._check()
458 self.query_one("#save-name", Input).focus()
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))
464 def _problem(self) -> NameProblem | None:
465 request = self._request()
466 return name_problem(request.name, request.folder, self._catalog)
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)
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()
484 @on(Input.Submitted, "#save-name")
485 def _on_submit(self) -> None:
486 self._save()
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)
496 def _save(self) -> None:
497 if self._problem() is None:
498 self.dismiss(self._request())
500 def action_cancel(self) -> None:
501 self.dismiss(None)
504class ProfilePathDialog(ModalScreen[profiles.ProfileLocation | None]):
505 """Asks for a path, and for a folder when it offers a choice.
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 """
511 CSS_PATH: ClassVar[CSSPathType] = ["dialog.tcss", "profile_dialogs.tcss"]
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 ]
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
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 )
557 def on_mount(self) -> None:
558 self._opened_with = self._path()
559 self._check()
560 self.query_one("#path-input", Input).focus()
562 def _path(self) -> str:
563 return self.query_one("#path-input", Input).value.strip()
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)
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)
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()
584 @on(Input.Submitted, "#path-input")
585 def _on_submit(self) -> None:
586 self._confirm()
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)
596 def _confirm(self) -> None:
597 if self._path():
598 request = self._request()
599 run_profile_op(self, lambda: self._write(request), self._finish)
601 def _finish(self, location: profiles.ProfileLocation) -> None:
602 self.dismiss(location)
604 def action_cancel(self) -> None:
605 self.dismiss(None)