copier-tui 0.6.8__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,38 @@
1
+ Metadata-Version: 2.4
2
+ Name: copier-tui
3
+ Version: 0.6.8
4
+ Summary: A terminal UI for copier: the same CLI, a survey you can move back and forth through before instantiating the template
5
+ Author-email: Stellars Henson <konrad.jelen+github@gmail.com>
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/stellarshenson/copier-tui
8
+ Project-URL: Repository, https://github.com/stellarshenson/copier-tui
9
+ Project-URL: Issues, https://github.com/stellarshenson/copier-tui/issues
10
+ Keywords: copier,template,scaffolding,tui,textual,terminal
11
+ Classifier: Development Status :: 3 - Alpha
12
+ Classifier: Environment :: Console
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Topic :: Software Development :: Code Generators
16
+ Requires-Python: >=3.11
17
+ Description-Content-Type: text/markdown
18
+ Requires-Dist: copier-ui==0.6.8
19
+ Requires-Dist: copier>=9.6
20
+ Requires-Dist: textual>=1.0
21
+ Requires-Dist: rich>=13.0
22
+
23
+ # copier-tui
24
+
25
+ A terminal UI for [copier](https://copier.readthedocs.io). Same CLI, better survey.
26
+
27
+ `copier-tui` takes copier's exact command line and replaces the prompt sequence with a
28
+ [Textual](https://textual.textualize.io) interface you can move back and forth through, review,
29
+ and only then instantiate. Templates need no changes.
30
+
31
+ ```bash
32
+ pip install copier-tui
33
+ copier-tui copy gh:stellarshenson/copier-data-science ./my-project
34
+ ```
35
+
36
+ Built on [copier-ui](https://pypi.org/project/copier-ui/), which owns the survey semantics.
37
+
38
+ Full documentation at [github.com/stellarshenson/copier-tui](https://github.com/stellarshenson/copier-tui).
@@ -0,0 +1,16 @@
1
+ # copier-tui
2
+
3
+ A terminal UI for [copier](https://copier.readthedocs.io). Same CLI, better survey.
4
+
5
+ `copier-tui` takes copier's exact command line and replaces the prompt sequence with a
6
+ [Textual](https://textual.textualize.io) interface you can move back and forth through, review,
7
+ and only then instantiate. Templates need no changes.
8
+
9
+ ```bash
10
+ pip install copier-tui
11
+ copier-tui copy gh:stellarshenson/copier-data-science ./my-project
12
+ ```
13
+
14
+ Built on [copier-ui](https://pypi.org/project/copier-ui/), which owns the survey semantics.
15
+
16
+ Full documentation at [github.com/stellarshenson/copier-tui](https://github.com/stellarshenson/copier-tui).
@@ -0,0 +1,41 @@
1
+ [build-system]
2
+ requires = ["setuptools", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "copier-tui"
7
+ version = "0.6.8"
8
+ description = "A terminal UI for copier: the same CLI, a survey you can move back and forth through before instantiating the template"
9
+ authors = [
10
+ { name = "Stellars Henson", email = "konrad.jelen+github@gmail.com" },
11
+ ]
12
+ license = "MIT"
13
+ readme = "README.md"
14
+ requires-python = ">=3.11"
15
+ keywords = ["copier", "template", "scaffolding", "tui", "textual", "terminal"]
16
+ classifiers = [
17
+ "Development Status :: 3 - Alpha",
18
+ "Environment :: Console",
19
+ "Intended Audience :: Developers",
20
+ "Programming Language :: Python :: 3",
21
+ "Topic :: Software Development :: Code Generators",
22
+ ]
23
+
24
+ dependencies = [
25
+ "copier-ui==0.6.8",
26
+ "copier>=9.6",
27
+ "textual>=1.0",
28
+ "rich>=13.0",
29
+ ]
30
+
31
+ [project.scripts]
32
+ copier-tui = "copier_tui.cli:main"
33
+
34
+ [project.urls]
35
+ Homepage = "https://github.com/stellarshenson/copier-tui"
36
+ Repository = "https://github.com/stellarshenson/copier-tui"
37
+ Issues = "https://github.com/stellarshenson/copier-tui/issues"
38
+
39
+ [tool.setuptools.packages.find]
40
+ where = ["src"]
41
+ include = ["copier_tui*"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,11 @@
1
+ """Terminal renderer for a copier template survey."""
2
+
3
+ from importlib.metadata import version
4
+ import os
5
+
6
+ # WSL leaves COLORTERM unset, and the dark slates downsample to xterm teal without it.
7
+ os.environ.setdefault("COLORTERM", "truecolor")
8
+
9
+ __version__ = version("copier-tui")
10
+
11
+ __all__ = ["__version__"]
@@ -0,0 +1,129 @@
1
+ """The Textual application and the survey entry point."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Iterable
6
+ from pathlib import Path
7
+ from typing import Any, ClassVar
8
+
9
+ from rich.text import Text
10
+ from textual import events
11
+ from textual.app import App, SystemCommand
12
+ from textual.binding import Binding
13
+ from textual.geometry import Size
14
+ from textual.screen import Screen
15
+ from textual.widgets import Static
16
+
17
+ from copier_tui.errors import EXIT_CANCELLED, EXIT_FAILURE, EXIT_OK
18
+ from copier_tui.screens import ExecutionScreen, ReviewScreen, SurveyScreen
19
+ from copier_tui.screens.survey import askable_ids
20
+ from copier_tui.theme import BASE_CSS, HEADER_CSS, MIN_HEIGHT, MIN_WIDTH, THEME
21
+ from copier_ui import TemplateUI
22
+
23
+
24
+ class SurveyApp(App[int]):
25
+ """Drives survey, review and execution, and returns a process exit code.
26
+
27
+ Review is pushed on top of the survey rather than replacing it, so coming back finds
28
+ the form exactly as it was left - same scroll offset, same focused field - while every
29
+ row re-reads the state on resume and shows whatever the new answers recomputed.
30
+ """
31
+
32
+ CSS = HEADER_CSS + BASE_CSS
33
+ TITLE = "copier-tui"
34
+
35
+ BINDINGS: ClassVar[list[Binding]] = [
36
+ Binding("ctrl+c", "cancel", "Cancel", priority=True, show=False),
37
+ ]
38
+
39
+ def __init__(self, ui: TemplateUI, dst: Path, copier_kwargs: dict[str, Any]) -> None:
40
+ """Hold the loaded template UI, the destination and copier's own flags."""
41
+ super().__init__()
42
+ self.ui = ui
43
+ self.dst = dst
44
+ self.copier_kwargs = copier_kwargs
45
+
46
+ def get_system_commands(self, screen: Screen) -> Iterable[SystemCommand]:
47
+ """copier-tui's palette is the brand; drop Textual's theme switcher."""
48
+ for command in super().get_system_commands(screen):
49
+ if command.title != "Change theme":
50
+ yield command
51
+
52
+ def on_mount(self) -> None:
53
+ """Register the palette, then open the survey - or review when nothing is asked."""
54
+ self.register_theme(THEME)
55
+ self.theme = THEME.name
56
+ if self._has_questions():
57
+ self._push(SurveyScreen(self.ui))
58
+ else:
59
+ self._open_review()
60
+
61
+ def on_resize(self, event: events.Resize) -> None:
62
+ """Show the resize prompt below MIN_WIDTH or MIN_HEIGHT, hide it above.
63
+
64
+ The event carries the new size; App.size still reports the old one here.
65
+ """
66
+ self._check_size(event.size)
67
+
68
+ def on_survey_screen_confirmed(self, message: SurveyScreen.Confirmed) -> None:
69
+ """The survey is ready: stack review on top of it, leaving the form untouched."""
70
+ message.stop()
71
+ self._open_review()
72
+
73
+ def on_survey_screen_cancelled(self, message: SurveyScreen.Cancelled) -> None:
74
+ """The survey was abandoned: leave without writing anything."""
75
+ message.stop()
76
+ self.exit(EXIT_CANCELLED)
77
+
78
+ def action_cancel(self) -> None:
79
+ """Leave without writing anything."""
80
+ self.exit(EXIT_CANCELLED)
81
+
82
+ def _has_questions(self) -> bool:
83
+ """True when at least one field is visible and not already answered by --data."""
84
+ return bool(askable_ids(self.ui.state()))
85
+
86
+ def _push(self, screen: Screen[Any], callback: Any = None) -> None:
87
+ """Push a screen and re-check the terminal size once it has laid out."""
88
+ self.push_screen(screen, callback)
89
+ self.call_after_refresh(self._check_size)
90
+
91
+ def _open_review(self) -> None:
92
+ """Stack the review screen over whatever is showing."""
93
+ self._push(ReviewScreen(self.ui, self.dst), self._after_review)
94
+
95
+ def _after_review(self, confirmed: bool | None) -> None:
96
+ """Review confirmed starts the render; back just uncovers the survey again."""
97
+ if confirmed:
98
+ self._push(
99
+ ExecutionScreen(self.ui, self.dst, self.copier_kwargs), self._after_execution
100
+ )
101
+ elif not self._has_questions():
102
+ self.exit(EXIT_CANCELLED)
103
+ else:
104
+ self.call_after_refresh(self._check_size)
105
+
106
+ def _after_execution(self, ok: bool | None) -> None:
107
+ """The render's verdict is the process exit code."""
108
+ self.exit(EXIT_OK if ok else EXIT_FAILURE)
109
+
110
+ def _check_size(self, size: Size | None = None) -> None:
111
+ """Mount or drop the resize prompt on the screen currently on top."""
112
+ size = self.size if size is None else size
113
+ prompt = self.screen.query("#resize-prompt")
114
+ if size.width < MIN_WIDTH or size.height < MIN_HEIGHT:
115
+ if not prompt:
116
+ self.screen.mount(
117
+ Static(
118
+ Text(f"terminal too small\nresize to {MIN_WIDTH} x {MIN_HEIGHT}"),
119
+ id="resize-prompt",
120
+ )
121
+ )
122
+ else:
123
+ prompt.remove()
124
+
125
+
126
+ def run_survey(ui: TemplateUI, dst: Path, copier_kwargs: dict[str, Any]) -> int:
127
+ """Run the app to completion and return its exit code."""
128
+ exit_code = SurveyApp(ui, dst, copier_kwargs).run()
129
+ return EXIT_CANCELLED if exit_code is None else exit_code
@@ -0,0 +1,161 @@
1
+ """copier's own command line, subclassed.
2
+
3
+ plumbum collects subcommands and switches across the MRO and lets a child class override a
4
+ parent's subcommand of the same name, so every flag, short form, help string and error message
5
+ below is copier's, inherited rather than restated. Only `main` is overridden, and only to open
6
+ the survey instead of copier's prompt sequence. `check-update` is inherited untouched.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from pathlib import Path
12
+ import sys
13
+ from typing import Any
14
+
15
+ from copier import VcsRef
16
+ from copier._cli import (
17
+ CopierApp,
18
+ CopierCopySubApp,
19
+ CopierRecopySubApp,
20
+ CopierUpdateSubApp,
21
+ )
22
+ from copier._tools import try_enum
23
+ from copier.errors import UnsafeTemplateError
24
+ from plumbum import colors
25
+
26
+ from copier_tui import __version__
27
+ from copier_tui.app import run_survey
28
+ from copier_tui.errors import EXIT_CANCELLED, EXIT_FAILURE, EXIT_UNSAFE, NotATerminalError
29
+ from copier_ui import CopierUIError, Operation, TemplateUI
30
+
31
+
32
+ class CopierTuiApp(CopierApp):
33
+ """The copier-tui CLI application."""
34
+
35
+ PROGNAME = "copier-tui"
36
+ VERSION = __version__
37
+
38
+
39
+ class _TuiSubcommand:
40
+ """Shared launch path for the subcommands that open a survey."""
41
+
42
+ def _headless(self) -> bool:
43
+ """True when copier's own flags say the survey is not the one asking.
44
+
45
+ `--defaults` answers everything, and `--force` implies it. `--quiet` is not one of
46
+ them: it suppresses status output and still asks. `--ask` is - it asks its questions
47
+ again at render time whatever answers were supplied, so the survey would collect an
48
+ answer only for copier to prompt for it on the render thread with the terminal held.
49
+ A user who asked for copier's own prompting gets copier's own prompting.
50
+ """
51
+ return bool(self.defaults or self.ask or getattr(self, "force", False))
52
+
53
+ def _copier_kwargs(self) -> dict[str, Any]:
54
+ """The subcommand's flags, as keyword arguments for copier's run_* function."""
55
+ return {
56
+ "answers_file": self.answers_file,
57
+ "vcs_ref": try_enum(VcsRef, self.vcs_ref),
58
+ "exclude": self.exclude,
59
+ "use_prereleases": self.prereleases,
60
+ "skip_if_exists": self.skip,
61
+ "pretend": self.pretend,
62
+ "quiet": self.quiet,
63
+ "unsafe": self.unsafe,
64
+ "skip_tasks": self.skip_tasks,
65
+ "ask": self.ask,
66
+ }
67
+
68
+ def _launch(self, src: str | None, destination_path: str, operation: Operation) -> int:
69
+ """Check for a tty, load the template, run the survey; report load failures and exit."""
70
+ if not (sys.stdin.isatty() and sys.stdout.isatty()):
71
+ message = "copier-tui needs a terminal; use --defaults or --force to run headless"
72
+ print(colors.red | str(NotATerminalError(message)), file=sys.stderr)
73
+ return EXIT_FAILURE
74
+ dst = Path(destination_path)
75
+ try:
76
+ ui = TemplateUI.from_template(
77
+ src,
78
+ vcs_ref=self.vcs_ref,
79
+ answers_file=self.answers_file,
80
+ dst=dst,
81
+ data=self.data,
82
+ operation=operation,
83
+ unsafe=self.unsafe,
84
+ )
85
+ except CopierUIError as error:
86
+ print(colors.red | str(error), file=sys.stderr)
87
+ if isinstance(error.__cause__, UnsafeTemplateError):
88
+ return EXIT_UNSAFE
89
+ return EXIT_FAILURE
90
+ with ui:
91
+ code = run_survey(ui, dst, self._copier_kwargs())
92
+ if code == EXIT_CANCELLED:
93
+ print(colors.yellow | f"cancelled - nothing written to {dst}", file=sys.stderr)
94
+ return code
95
+
96
+
97
+ @CopierTuiApp.subcommand("copy")
98
+ class TuiCopySubApp(CopierCopySubApp, _TuiSubcommand):
99
+ """The `copier-tui copy` subcommand."""
100
+
101
+ def _copier_kwargs(self) -> dict[str, Any]:
102
+ """copier's own `copy` arguments, as `copier copy` assembles them."""
103
+ return {
104
+ **super()._copier_kwargs(),
105
+ "cleanup_on_error": self.cleanup_on_error,
106
+ "defaults": self.force or self.defaults,
107
+ "overwrite": self.force or self.overwrite,
108
+ }
109
+
110
+ def main(self, template_src: str, destination_path: str) -> int:
111
+ """Survey the template, then copy it; headless flags fall through to copier."""
112
+ if self._headless():
113
+ return super().main(template_src, destination_path)
114
+ return self._launch(template_src, destination_path, "copy")
115
+
116
+
117
+ @CopierTuiApp.subcommand("recopy")
118
+ class TuiRecopySubApp(CopierRecopySubApp, _TuiSubcommand):
119
+ """The `copier-tui recopy` subcommand."""
120
+
121
+ def _copier_kwargs(self) -> dict[str, Any]:
122
+ """copier's own `recopy` arguments, as `copier recopy` assembles them."""
123
+ return {
124
+ **super()._copier_kwargs(),
125
+ "defaults": self.force or self.defaults,
126
+ "overwrite": self.force or self.overwrite,
127
+ "skip_answered": self.skip_answered,
128
+ }
129
+
130
+ def main(self, destination_path: str = ".") -> int:
131
+ """Survey seeded from the destination's answers file, then recopy."""
132
+ if self._headless():
133
+ return super().main(destination_path)
134
+ return self._launch(None, destination_path, "recopy")
135
+
136
+
137
+ @CopierTuiApp.subcommand("update")
138
+ class TuiUpdateSubApp(CopierUpdateSubApp, _TuiSubcommand):
139
+ """The `copier-tui update` subcommand."""
140
+
141
+ def _copier_kwargs(self) -> dict[str, Any]:
142
+ """copier's own `update` arguments, as `copier update` assembles them."""
143
+ return {
144
+ **super()._copier_kwargs(),
145
+ "defaults": self.defaults,
146
+ "overwrite": True,
147
+ "conflict": self.conflict,
148
+ "context_lines": self.context_lines,
149
+ "skip_answered": self.skip_answered,
150
+ }
151
+
152
+ def main(self, destination_path: str = ".") -> int:
153
+ """Survey seeded from the destination's answers file, then update."""
154
+ if self._headless():
155
+ return super().main(destination_path)
156
+ return self._launch(None, destination_path, "update")
157
+
158
+
159
+ def main() -> None:
160
+ """Console script entry point."""
161
+ CopierTuiApp.run()
@@ -0,0 +1,17 @@
1
+ """Errors and exit codes for the terminal frontend."""
2
+
3
+ from __future__ import annotations
4
+
5
+ EXIT_OK = 0
6
+ EXIT_FAILURE = 1
7
+ EXIT_CANCELLED = 2
8
+ EXIT_UNSAFE = 0b100
9
+ """copier's own code for a template refused for an unsafe feature."""
10
+
11
+
12
+ class TuiError(Exception):
13
+ """Base class for every copier_tui error."""
14
+
15
+
16
+ class NotATerminalError(TuiError):
17
+ """Launched without a terminal on stdin or stdout."""
@@ -0,0 +1,7 @@
1
+ """Screens of the copier-tui survey."""
2
+
3
+ from copier_tui.screens.execution import ExecutionScreen
4
+ from copier_tui.screens.review import ReviewScreen
5
+ from copier_tui.screens.survey import SurveyScreen
6
+
7
+ __all__ = ["ExecutionScreen", "ReviewScreen", "SurveyScreen"]
@@ -0,0 +1,146 @@
1
+ """The execution screen: the copier run, its progress and its verdict."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+ from typing import Any, ClassVar
7
+
8
+ from rich.text import Text
9
+ from textual import events
10
+ from textual.app import ComposeResult
11
+ from textual.binding import Binding
12
+ from textual.containers import Vertical
13
+ from textual.screen import Screen
14
+ from textual.widgets import Footer, ProgressBar, Static
15
+
16
+ from copier_tui.theme import MINT, ORANGE, ROSE, SURFACE_BG, TEXT_MUTED, TEXT_SUBTLE
17
+ from copier_tui.widgets import HeaderBar
18
+ from copier_ui import TemplateUI
19
+
20
+
21
+ class ExecutionScreen(Screen[bool]):
22
+ """Runs copier in a worker thread and reports success or failure."""
23
+
24
+ DEFAULT_CSS = f"""
25
+ #exec-body {{
26
+ width: 100%;
27
+ height: 1fr;
28
+ padding: 1;
29
+ }}
30
+ #exec-status {{
31
+ height: 1;
32
+ color: {TEXT_MUTED};
33
+ }}
34
+ #exec-verdict {{
35
+ height: 1;
36
+ color: {TEXT_SUBTLE};
37
+ }}
38
+ #exec-progress {{
39
+ width: 100%;
40
+ }}
41
+ #exec-progress Bar {{
42
+ width: 1fr;
43
+ }}
44
+ #exec-progress Bar > .bar--bar {{
45
+ color: {ORANGE};
46
+ background: {SURFACE_BG};
47
+ }}
48
+ #exec-progress Bar > .bar--indeterminate {{
49
+ color: {ORANGE};
50
+ background: {SURFACE_BG};
51
+ }}
52
+ #exec-progress Bar > .bar--complete {{
53
+ color: {MINT};
54
+ background: {SURFACE_BG};
55
+ }}
56
+ """
57
+
58
+ BINDINGS: ClassVar[list[Binding]] = [
59
+ Binding("enter", "close", "Close", priority=True),
60
+ Binding("escape", "close", "Close", priority=True, show=False),
61
+ ]
62
+
63
+ def __init__(self, ui: TemplateUI, dst: Path, copier_kwargs: dict[str, Any]) -> None:
64
+ """Hold the template UI, the destination and copier's own flags."""
65
+ super().__init__(id="execution-screen")
66
+ self.ui = ui
67
+ self.dst = dst
68
+ self.copier_kwargs = copier_kwargs
69
+ self.pretend = bool(copier_kwargs.get("pretend"))
70
+ self._done = False
71
+ self._closed = False
72
+ self._ok = False
73
+
74
+ def compose(self) -> ComposeResult:
75
+ """Header, the status line and the progress bar, the verdict line, footer.
76
+
77
+ The destination is named once, by the status line that narrates the run: the header
78
+ says which run it is, and repeating the path three times said nothing extra.
79
+ """
80
+ verb = "checking" if self.pretend else "rendering"
81
+ yield HeaderBar("dry run" if self.pretend else "render")
82
+ yield Vertical(
83
+ Static(Text(f"{verb} the template"), id="exec-status"),
84
+ ProgressBar(total=None, show_percentage=False, show_eta=False, id="exec-progress"),
85
+ Static(id="exec-verdict"),
86
+ id="exec-body",
87
+ )
88
+ yield Footer()
89
+
90
+ def on_mount(self) -> None:
91
+ """Start the render worker and the indeterminate progress bar."""
92
+ self.run_worker(self._run_copier, thread=True, name="render")
93
+
94
+ def on_key(self, event: events.Key) -> None:
95
+ """Any key closes the finished run, not just the bound ones."""
96
+ if self._done:
97
+ event.stop()
98
+ self.action_close()
99
+
100
+ def action_close(self) -> None:
101
+ """End the run once the verdict is in; idempotent, any key reaches it too."""
102
+ if not self._done or self._closed:
103
+ return
104
+ self._closed = True
105
+ self.dismiss(self._ok)
106
+
107
+ def _run_copier(self) -> None:
108
+ """Call TemplateUI.render off the UI thread; report back with call_from_thread.
109
+
110
+ Not named `_render`: Widget._render is Textual's own, and shadowing it runs this
111
+ on the UI thread during layout.
112
+ """
113
+ error: BaseException | None = None
114
+ try:
115
+ self.ui.render(self.dst, **self.copier_kwargs)
116
+ except Exception as exc: # noqa: BLE001 - any failure becomes the screen's verdict
117
+ error = exc
118
+ self.app.call_from_thread(self._finish, error)
119
+
120
+ def _finish(self, error: BaseException | None) -> None:
121
+ """Put the verdict on the status line: mint on success, rose with copier's message.
122
+
123
+ A dry run says so - it reports what copier would write, and writes nothing. There is
124
+ no popup: the verdict belongs on the line that has been narrating the run, and the
125
+ destination is named once, by the status line, never also by the header.
126
+ """
127
+ self._done = True
128
+ self._ok = error is None
129
+ self.query_one("#exec-progress", ProgressBar).update(total=1, progress=1)
130
+ status = self.query_one("#exec-status", Static)
131
+ if error is None:
132
+ done = (
133
+ "nothing written - this was a dry run"
134
+ if self.pretend
135
+ else f"written to {self.dst}"
136
+ )
137
+ status.update(Text(done, style=MINT))
138
+ else:
139
+ status.update(Text(f"failed - {_message(error)}", style=ROSE))
140
+ self.query_one("#exec-verdict", Static).update(Text("press any key to close"))
141
+ self.set_focus(None)
142
+
143
+
144
+ def _message(error: BaseException) -> str:
145
+ """copier's own message where there is one, the exception class otherwise."""
146
+ return str(error) or type(error).__name__
@@ -0,0 +1,104 @@
1
+ """The review screen: every answer, confirmed before anything is written."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+ from typing import ClassVar
7
+
8
+ from rich.text import Text
9
+ from textual.app import ComposeResult
10
+ from textual.binding import Binding
11
+ from textual.containers import VerticalScroll
12
+ from textual.screen import Screen
13
+ from textual.widgets import Footer, Static
14
+
15
+ from copier_tui.theme import AMBER, CYAN_BRIGHT, LABEL_WIDTH, TEXT, TEXT_SUBTLE
16
+ from copier_tui.widgets import HeaderBar, display_value
17
+ from copier_ui import TemplateUI
18
+
19
+ UNSET = "not set"
20
+ """Stands in for an answer with no value, so a blank line is never mistaken for one."""
21
+
22
+
23
+ class ReviewScreen(Screen[bool]):
24
+ """Lists every answer and warns when the destination is not empty."""
25
+
26
+ DEFAULT_CSS = f"""
27
+ #review-list {{
28
+ width: 100%;
29
+ height: 1fr;
30
+ padding: 1 2 0 1;
31
+ scrollbar-size-vertical: 1;
32
+ }}
33
+ #review-warning {{
34
+ height: 1;
35
+ width: 100%;
36
+ padding: 0 1;
37
+ color: {AMBER};
38
+ }}
39
+ .review-answer {{
40
+ height: 1;
41
+ width: 100%;
42
+ }}
43
+ #review-empty {{
44
+ color: {TEXT_SUBTLE};
45
+ }}
46
+ """
47
+
48
+ BINDINGS: ClassVar[list[Binding]] = [
49
+ Binding("enter", "confirm", "Create", priority=True),
50
+ Binding("escape", "back", "Back", priority=True),
51
+ ]
52
+
53
+ def __init__(self, ui: TemplateUI, dst: Path) -> None:
54
+ """Hold the template UI and the destination being reviewed."""
55
+ super().__init__(id="review-screen")
56
+ self.ui = ui
57
+ self.dst = dst
58
+
59
+ def compose(self) -> ComposeResult:
60
+ """Header, one line per answer, the destination warning, footer."""
61
+ yield HeaderBar(f"review · {self.dst}")
62
+ yield VerticalScroll(*self._answer_lines(), id="review-list")
63
+ yield Static(self._destination_note(), id="review-warning")
64
+ yield Footer()
65
+
66
+ def action_confirm(self) -> None:
67
+ """Dismiss with True to start the render."""
68
+ self.dismiss(True)
69
+
70
+ def action_back(self) -> None:
71
+ """Dismiss with False to return to the survey."""
72
+ self.dismiss(False)
73
+
74
+ def _destination_note(self) -> Text:
75
+ """Warn when the destination already holds files the render could overwrite."""
76
+ if _is_not_empty(self.dst):
77
+ return Text(f"{self.dst} is not empty - existing files may be overwritten")
78
+ return Text("")
79
+
80
+ def _answer_lines(self) -> list[Static]:
81
+ """One static per visible answer, id gutter aligned, secrets masked."""
82
+ state = self.ui.state()
83
+ lines = []
84
+ for field_id in state.visible_ids:
85
+ field = state.fields[field_id]
86
+ value = display_value(field)
87
+ lines.append(
88
+ Static(
89
+ Text.assemble(
90
+ (field_id[: LABEL_WIDTH - 1].ljust(LABEL_WIDTH), f"bold {CYAN_BRIGHT}"),
91
+ (value, TEXT) if value else (UNSET, TEXT_SUBTLE),
92
+ ),
93
+ classes="review-answer",
94
+ id=f"review-{field_id}",
95
+ )
96
+ )
97
+ if not lines:
98
+ lines.append(Static(Text("this template asks nothing"), id="review-empty"))
99
+ return lines
100
+
101
+
102
+ def _is_not_empty(dst: Path) -> bool:
103
+ """True when the destination directory already holds something."""
104
+ return dst.is_dir() and any(dst.iterdir())