chameleon-cv 0.1.0
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.
- package/AGENTS.md +186 -0
- package/LICENSE +21 -0
- package/Makefile +136 -0
- package/README.md +356 -0
- package/bin/chameleon.js +136 -0
- package/chameleon.bat +286 -0
- package/chameleon.sh +353 -0
- package/package.json +39 -0
- package/profiles/github_repos.json +1266 -0
- package/requirements.txt +5 -0
- package/scan.sh +12 -0
- package/score.sh +12 -0
- package/scripts/__init__.py +1 -0
- package/scripts/__main_tui__.py +11 -0
- package/scripts/__pycache__/__init__.cpython-312.pyc +0 -0
- package/scripts/__pycache__/__init__.cpython-313.pyc +0 -0
- package/scripts/__pycache__/__main_tui__.cpython-313.pyc +0 -0
- package/scripts/__pycache__/ats_ghost.cpython-313.pyc +0 -0
- package/scripts/__pycache__/cache.cpython-313.pyc +0 -0
- package/scripts/__pycache__/config.cpython-312.pyc +0 -0
- package/scripts/__pycache__/config.cpython-313.pyc +0 -0
- package/scripts/__pycache__/cover_letter.cpython-313.pyc +0 -0
- package/scripts/__pycache__/db.cpython-312.pyc +0 -0
- package/scripts/__pycache__/db.cpython-313.pyc +0 -0
- package/scripts/__pycache__/fonts.cpython-312.pyc +0 -0
- package/scripts/__pycache__/fonts.cpython-313.pyc +0 -0
- package/scripts/__pycache__/ghost.cpython-313.pyc +0 -0
- package/scripts/__pycache__/job_matcher.cpython-312.pyc +0 -0
- package/scripts/__pycache__/job_matcher.cpython-313.pyc +0 -0
- package/scripts/__pycache__/question.cpython-313.pyc +0 -0
- package/scripts/__pycache__/render.cpython-313.pyc +0 -0
- package/scripts/__pycache__/review.cpython-313.pyc +0 -0
- package/scripts/__pycache__/setup.cpython-312.pyc +0 -0
- package/scripts/__pycache__/tailor_brief.cpython-312.pyc +0 -0
- package/scripts/__pycache__/tailor_brief.cpython-313.pyc +0 -0
- package/scripts/__pycache__/tailor_cv.cpython-312.pyc +0 -0
- package/scripts/__pycache__/tailor_cv.cpython-313.pyc +0 -0
- package/scripts/__pycache__/tailor_prompts.cpython-312.pyc +0 -0
- package/scripts/__pycache__/tailor_prompts.cpython-313.pyc +0 -0
- package/scripts/__pycache__/tui_app.cpython-312.pyc +0 -0
- package/scripts/__pycache__/tui_app.cpython-313.pyc +0 -0
- package/scripts/ats_ghost.py +170 -0
- package/scripts/cache.py +63 -0
- package/scripts/cli.py +109 -0
- package/scripts/config.py +376 -0
- package/scripts/cover_letter.py +105 -0
- package/scripts/db.py +331 -0
- package/scripts/fonts.py +186 -0
- package/scripts/ghost.py +88 -0
- package/scripts/job_matcher.py +636 -0
- package/scripts/job_scanner/__init__.py +8 -0
- package/scripts/job_scanner/__main__.py +6 -0
- package/scripts/job_scanner/__pycache__/__init__.cpython-312.pyc +0 -0
- package/scripts/job_scanner/__pycache__/__init__.cpython-313.pyc +0 -0
- package/scripts/job_scanner/__pycache__/__main__.cpython-312.pyc +0 -0
- package/scripts/job_scanner/__pycache__/__main__.cpython-313.pyc +0 -0
- package/scripts/job_scanner/__pycache__/base.cpython-312.pyc +0 -0
- package/scripts/job_scanner/__pycache__/base.cpython-313.pyc +0 -0
- package/scripts/job_scanner/__pycache__/browser.cpython-312.pyc +0 -0
- package/scripts/job_scanner/__pycache__/browser.cpython-313.pyc +0 -0
- package/scripts/job_scanner/__pycache__/cli.cpython-313.pyc +0 -0
- package/scripts/job_scanner/__pycache__/registry.cpython-312.pyc +0 -0
- package/scripts/job_scanner/__pycache__/registry.cpython-313.pyc +0 -0
- package/scripts/job_scanner/__pycache__/scanner.cpython-312.pyc +0 -0
- package/scripts/job_scanner/__pycache__/scanner.cpython-313.pyc +0 -0
- package/scripts/job_scanner/base.py +197 -0
- package/scripts/job_scanner/browser.py +244 -0
- package/scripts/job_scanner/cli.py +6 -0
- package/scripts/job_scanner/parsers/__init__.py +24 -0
- package/scripts/job_scanner/parsers/__pycache__/__init__.cpython-312.pyc +0 -0
- package/scripts/job_scanner/parsers/__pycache__/__init__.cpython-313.pyc +0 -0
- package/scripts/job_scanner/parsers/__pycache__/adzuna.cpython-312.pyc +0 -0
- package/scripts/job_scanner/parsers/__pycache__/adzuna.cpython-313.pyc +0 -0
- package/scripts/job_scanner/parsers/__pycache__/arc.cpython-312.pyc +0 -0
- package/scripts/job_scanner/parsers/__pycache__/arc.cpython-313.pyc +0 -0
- package/scripts/job_scanner/parsers/__pycache__/crypto_careers.cpython-312.pyc +0 -0
- package/scripts/job_scanner/parsers/__pycache__/crypto_careers.cpython-313.pyc +0 -0
- package/scripts/job_scanner/parsers/__pycache__/dailyremote.cpython-312.pyc +0 -0
- package/scripts/job_scanner/parsers/__pycache__/dailyremote.cpython-313.pyc +0 -0
- package/scripts/job_scanner/parsers/__pycache__/flexjobs.cpython-313.pyc +0 -0
- package/scripts/job_scanner/parsers/__pycache__/himalayas.cpython-312.pyc +0 -0
- package/scripts/job_scanner/parsers/__pycache__/himalayas.cpython-313.pyc +0 -0
- package/scripts/job_scanner/parsers/__pycache__/hn_hiring.cpython-312.pyc +0 -0
- package/scripts/job_scanner/parsers/__pycache__/hn_hiring.cpython-313.pyc +0 -0
- package/scripts/job_scanner/parsers/__pycache__/indeed.cpython-313.pyc +0 -0
- package/scripts/job_scanner/parsers/__pycache__/jobicy.cpython-312.pyc +0 -0
- package/scripts/job_scanner/parsers/__pycache__/jobicy.cpython-313.pyc +0 -0
- package/scripts/job_scanner/parsers/__pycache__/jooble.cpython-312.pyc +0 -0
- package/scripts/job_scanner/parsers/__pycache__/jooble.cpython-313.pyc +0 -0
- package/scripts/job_scanner/parsers/__pycache__/linkedin.cpython-313.pyc +0 -0
- package/scripts/job_scanner/parsers/__pycache__/opentoworkremote.cpython-313.pyc +0 -0
- package/scripts/job_scanner/parsers/__pycache__/relocate.cpython-312.pyc +0 -0
- package/scripts/job_scanner/parsers/__pycache__/relocate.cpython-313.pyc +0 -0
- package/scripts/job_scanner/parsers/__pycache__/remote_rocketship.cpython-313.pyc +0 -0
- package/scripts/job_scanner/parsers/__pycache__/remoteok.cpython-312.pyc +0 -0
- package/scripts/job_scanner/parsers/__pycache__/remoteok.cpython-313.pyc +0 -0
- package/scripts/job_scanner/parsers/__pycache__/remotive.cpython-312.pyc +0 -0
- package/scripts/job_scanner/parsers/__pycache__/remotive.cpython-313.pyc +0 -0
- package/scripts/job_scanner/parsers/__pycache__/web3_career.cpython-313.pyc +0 -0
- package/scripts/job_scanner/parsers/__pycache__/wellfound.cpython-313.pyc +0 -0
- package/scripts/job_scanner/parsers/__pycache__/weworkremotely.cpython-312.pyc +0 -0
- package/scripts/job_scanner/parsers/__pycache__/weworkremotely.cpython-313.pyc +0 -0
- package/scripts/job_scanner/parsers/__pycache__/ycomb.cpython-313.pyc +0 -0
- package/scripts/job_scanner/parsers/adzuna.py +81 -0
- package/scripts/job_scanner/parsers/arc.py +65 -0
- package/scripts/job_scanner/parsers/crypto_careers.py +88 -0
- package/scripts/job_scanner/parsers/dailyremote.py +91 -0
- package/scripts/job_scanner/parsers/flexjobs.py +91 -0
- package/scripts/job_scanner/parsers/himalayas.py +47 -0
- package/scripts/job_scanner/parsers/hn_hiring.py +94 -0
- package/scripts/job_scanner/parsers/indeed.py +98 -0
- package/scripts/job_scanner/parsers/jobicy.py +60 -0
- package/scripts/job_scanner/parsers/jooble.py +68 -0
- package/scripts/job_scanner/parsers/linkedin.py +140 -0
- package/scripts/job_scanner/parsers/opentoworkremote.py +53 -0
- package/scripts/job_scanner/parsers/relocate.py +95 -0
- package/scripts/job_scanner/parsers/remote_rocketship.py +90 -0
- package/scripts/job_scanner/parsers/remoteok.py +63 -0
- package/scripts/job_scanner/parsers/remotive.py +57 -0
- package/scripts/job_scanner/parsers/web3_career.py +92 -0
- package/scripts/job_scanner/parsers/wellfound.py +94 -0
- package/scripts/job_scanner/parsers/weworkremotely.py +60 -0
- package/scripts/job_scanner/parsers/ycomb.py +83 -0
- package/scripts/job_scanner/registry.py +65 -0
- package/scripts/job_scanner/scanner.py +162 -0
- package/scripts/question.py +107 -0
- package/scripts/render.py +110 -0
- package/scripts/review.py +89 -0
- package/scripts/setup.py +139 -0
- package/scripts/tailor_brief.py +379 -0
- package/scripts/tailor_cv.py +746 -0
- package/scripts/tailor_prompts.py +101 -0
- package/scripts/tui/__init__.py +1 -0
- package/scripts/tui/__pycache__/__init__.cpython-312.pyc +0 -0
- package/scripts/tui/__pycache__/__init__.cpython-313.pyc +0 -0
- package/scripts/tui/__pycache__/app.cpython-312.pyc +0 -0
- package/scripts/tui/__pycache__/app.cpython-313.pyc +0 -0
- package/scripts/tui/__pycache__/helpers.cpython-312.pyc +0 -0
- package/scripts/tui/__pycache__/helpers.cpython-313.pyc +0 -0
- package/scripts/tui/__pycache__/screens.cpython-312.pyc +0 -0
- package/scripts/tui/__pycache__/screens.cpython-313.pyc +0 -0
- package/scripts/tui/__pycache__/widgets.cpython-312.pyc +0 -0
- package/scripts/tui/__pycache__/widgets.cpython-313.pyc +0 -0
- package/scripts/tui/app.py +1547 -0
- package/scripts/tui/helpers.py +65 -0
- package/scripts/tui/screens.py +642 -0
- package/scripts/tui/widgets.py +276 -0
- package/scripts/tui_app.py +25 -0
- package/scripts/wizard.py +109 -0
- package/tui.bat +50 -0
- package/tui.sh +59 -0
|
@@ -0,0 +1,642 @@
|
|
|
1
|
+
"""Modal screens for the Chameleon TUI."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from textual import on
|
|
5
|
+
from textual.binding import Binding
|
|
6
|
+
from textual.containers import Horizontal, Vertical, VerticalScroll
|
|
7
|
+
from textual.screen import ModalScreen
|
|
8
|
+
from textual.widgets import Button, Input, Static, TextArea
|
|
9
|
+
|
|
10
|
+
from scripts.config import config
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class DeleteConfirmScreen(ModalScreen[bool]):
|
|
14
|
+
CSS = """
|
|
15
|
+
DeleteConfirmScreen { align: center middle; }
|
|
16
|
+
#del_dialog { width: 52; height: 10; border: thick $error; background: $surface; padding: 1 2; }
|
|
17
|
+
#del_dialog Button { margin: 0 1; }
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
def __init__(self, job_title: str, company: str) -> None:
|
|
21
|
+
super().__init__()
|
|
22
|
+
self._job_title = job_title
|
|
23
|
+
self._company = company
|
|
24
|
+
|
|
25
|
+
def compose(self):
|
|
26
|
+
with Vertical(id="del_dialog"):
|
|
27
|
+
yield Static("[bold red]\u26a0 Delete Job?[/bold red]")
|
|
28
|
+
yield Static("")
|
|
29
|
+
yield Static(f" [bold]{self._job_title}[/bold]")
|
|
30
|
+
yield Static(f" [dim]at {self._company}[/dim]")
|
|
31
|
+
yield Static("")
|
|
32
|
+
yield Static("[dim]This cannot be undone.[/dim]")
|
|
33
|
+
yield Static("")
|
|
34
|
+
with Horizontal():
|
|
35
|
+
yield Button("Delete", id="btn_del_yes", variant="error")
|
|
36
|
+
yield Button("Cancel", id="btn_del_no", variant="default")
|
|
37
|
+
|
|
38
|
+
@on(Button.Pressed, "#btn_del_yes")
|
|
39
|
+
def on_confirm(self) -> None:
|
|
40
|
+
self.dismiss(True)
|
|
41
|
+
|
|
42
|
+
@on(Button.Pressed, "#btn_del_no")
|
|
43
|
+
def on_cancel(self) -> None:
|
|
44
|
+
self.dismiss(False)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class GotoLineScreen(ModalScreen[None]):
|
|
48
|
+
CSS = """
|
|
49
|
+
GotoLineScreen { align: center middle; }
|
|
50
|
+
#goto_dialog { width: 40; height: 7; border: thick $primary; background: $surface; padding: 1 2; }
|
|
51
|
+
"""
|
|
52
|
+
|
|
53
|
+
def __init__(self, textarea: TextArea) -> None:
|
|
54
|
+
super().__init__()
|
|
55
|
+
self._ta = textarea
|
|
56
|
+
self._max = max(1, textarea.text.count("\n") + 1)
|
|
57
|
+
|
|
58
|
+
def compose(self):
|
|
59
|
+
with Vertical(id="goto_dialog"):
|
|
60
|
+
yield Static(f"[bold]Go to line (1-{self._max}):[/bold]")
|
|
61
|
+
yield Input(placeholder="Line number", id="goto_input", type="integer")
|
|
62
|
+
yield Static("[dim]Enter to go, Escape to cancel[/dim]")
|
|
63
|
+
|
|
64
|
+
@on(Input.Submitted, "#goto_input")
|
|
65
|
+
def on_submit(self, event: Input.Submitted) -> None:
|
|
66
|
+
try:
|
|
67
|
+
line = int(event.value)
|
|
68
|
+
if 1 <= line <= self._max:
|
|
69
|
+
self._ta.move_cursor((line - 1, 0))
|
|
70
|
+
except (ValueError, TypeError):
|
|
71
|
+
pass
|
|
72
|
+
self.dismiss()
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
class PasteJobScreen(ModalScreen[dict | None]):
|
|
76
|
+
CSS = """
|
|
77
|
+
PasteJobScreen { align: center middle; }
|
|
78
|
+
#paste_dialog { width: 80; height: 32; border: thick $primary; background: $surface; padding: 1 2; }
|
|
79
|
+
#paste_dialog TextArea { height: 1fr; border: solid $secondary; }
|
|
80
|
+
#paste_status { dock: bottom; height: 1; padding: 0 1; color: $text-muted; }
|
|
81
|
+
"""
|
|
82
|
+
BINDINGS = [Binding("ctrl+s", "save_paste", "Save")]
|
|
83
|
+
|
|
84
|
+
def compose(self):
|
|
85
|
+
with Vertical(id="paste_dialog"):
|
|
86
|
+
yield Static("[bold]\u270e Paste Job Description[/bold]")
|
|
87
|
+
yield Static("[dim]Paste the full job posting below. Title = first line, company auto-detected.[/dim]")
|
|
88
|
+
yield TextArea(id="paste_text")
|
|
89
|
+
yield Static("[dim]Ctrl+S to save | Escape to cancel[/dim]", id="paste_status")
|
|
90
|
+
|
|
91
|
+
def action_save_paste(self) -> None:
|
|
92
|
+
try:
|
|
93
|
+
text = self.query_one("#paste_text", TextArea).text.strip()
|
|
94
|
+
except Exception:
|
|
95
|
+
text = ""
|
|
96
|
+
if not text:
|
|
97
|
+
self.dismiss(None)
|
|
98
|
+
return
|
|
99
|
+
lines = text.split("\n")
|
|
100
|
+
title = lines[0][:80] if lines else "Pasted Job"
|
|
101
|
+
company = "Unknown"
|
|
102
|
+
for line in lines[:15]:
|
|
103
|
+
lower = line.lower()
|
|
104
|
+
if "company:" in lower:
|
|
105
|
+
company = line.split(":", 1)[1].strip()
|
|
106
|
+
break
|
|
107
|
+
elif " at " in lower and len(line) < 60:
|
|
108
|
+
parts = line.split(" at ", 1)
|
|
109
|
+
if len(parts) == 2:
|
|
110
|
+
company = parts[1].strip()
|
|
111
|
+
break
|
|
112
|
+
self.dismiss({
|
|
113
|
+
"title": title, "company": company, "description": text,
|
|
114
|
+
"location": "Unknown", "remote": False, "source": "pasted",
|
|
115
|
+
"url": "", "salary": "",
|
|
116
|
+
})
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
class HelpScreen(ModalScreen[None]):
|
|
120
|
+
CSS = """
|
|
121
|
+
HelpScreen { align: center middle; }
|
|
122
|
+
#help_dialog { width: 76; height: auto; max-height: 88%; border: thick $primary; background: $surface; padding: 1 2; overflow: auto; }
|
|
123
|
+
#help_dialog Static { width: 100%; }
|
|
124
|
+
"""
|
|
125
|
+
BINDINGS = [Binding("escape", "dismiss_help", "Close")]
|
|
126
|
+
|
|
127
|
+
def compose(self):
|
|
128
|
+
s = config.get("score_threshold_strong", 80)
|
|
129
|
+
g = config.get("score_threshold_good", 60)
|
|
130
|
+
m = config.get("score_threshold_moderate", 40)
|
|
131
|
+
with Vertical(id="help_dialog"):
|
|
132
|
+
yield Static("[bold]\u2593 Chameleon Job TUI \u2014 Help[/bold]\n")
|
|
133
|
+
|
|
134
|
+
yield Static("[bold]\u2500\u2500 Navigation \u2500\u2500[/bold]")
|
|
135
|
+
yield Static(" [bold]\u2191\u2193[/bold] Navigate job table")
|
|
136
|
+
yield Static(" [bold]Enter[/bold] Select job for detail view")
|
|
137
|
+
yield Static(" [bold]/[/bold] Focus search bar\n")
|
|
138
|
+
|
|
139
|
+
yield Static("[bold]\u2500\u2500 Core Actions \u2500\u2500[/bold]")
|
|
140
|
+
yield Static(" [bold]Ctrl+S[/bold] Scan job platforms")
|
|
141
|
+
yield Static(" [bold]Ctrl+N[/bold] Paste a job description manually")
|
|
142
|
+
yield Static(" [bold]Ctrl+R[/bold] Re-score all loaded jobs")
|
|
143
|
+
yield Static(" [bold]Ctrl+E[/bold] Open selected job in nano editor")
|
|
144
|
+
yield Static(" [bold]Ctrl+T[/bold] Tailor CV \u2192 adds to Tailored tab")
|
|
145
|
+
yield Static(" [bold]Ctrl+D[/bold] Delete selected job (with confirm)")
|
|
146
|
+
yield Static(" [bold]Ctrl+O[/bold] Open job URL in browser")
|
|
147
|
+
yield Static(" [bold]F2[/bold] Scan with custom query/platforms")
|
|
148
|
+
yield Static(" [bold]F5[/bold] Refresh / re-score all\n")
|
|
149
|
+
|
|
150
|
+
yield Static("[bold]\u2500\u2500 Editor \u2500\u2500[/bold]")
|
|
151
|
+
yield Static(" [bold]Ctrl+X[/bold] Save & exit editor")
|
|
152
|
+
yield Static(" [bold]Ctrl+G[/bold] Go to line number\n")
|
|
153
|
+
|
|
154
|
+
yield Static("[bold]\u2500\u2500 Scoring Guide \u2500\u2500[/bold]")
|
|
155
|
+
yield Static(f" [bold bright_green]{s}+[/bold bright_green] [bold bright_green]Strong[/bold bright_green] \u2014 tailor immediately")
|
|
156
|
+
yield Static(f" [bold yellow]{g}-{s-1}[/bold yellow] [bold yellow]Good[/bold yellow] \u2014 worth applying")
|
|
157
|
+
yield Static(f" [bold dark_orange]{m}-{g-1}[/bold dark_orange] [bold dark_orange]Moderate[/bold dark_orange] \u2014 tailor with caution")
|
|
158
|
+
yield Static(f" [bold red]0-{m-1}[/bold red] [bold red]Weak[/bold red] \u2014 consider other opportunities\n")
|
|
159
|
+
|
|
160
|
+
yield Static(" [bold]F3[/bold] Open setup wizard (CV, AI, Telegram, WhatsApp)\n")
|
|
161
|
+
|
|
162
|
+
yield Static("[bold]\u2500\u2500 Tips \u2500\u2500[/bold]")
|
|
163
|
+
yield Static(" \u2022 Tailored CVs show diffs against your master CV")
|
|
164
|
+
yield Static(" \u2022 Settings tab saves changes to .chameleon/config.json")
|
|
165
|
+
yield Static(" \u2022 Scan results persist in SQLite across restarts")
|
|
166
|
+
yield Static(" \u2022 Press F3 to re-run the setup wizard anytime\n")
|
|
167
|
+
|
|
168
|
+
yield Static("[dim]Press [bold]Escape[/bold] to close[/dim]")
|
|
169
|
+
|
|
170
|
+
def action_dismiss_help(self) -> None:
|
|
171
|
+
self.dismiss()
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
class ConfirmQuitScreen(ModalScreen[None]):
|
|
175
|
+
CSS = """
|
|
176
|
+
ConfirmQuitScreen { align: center middle; }
|
|
177
|
+
#quit_dialog { width: 45; height: 9; border: thick $error; background: $surface; padding: 1 2; }
|
|
178
|
+
#quit_dialog Button { margin: 0 1; }
|
|
179
|
+
"""
|
|
180
|
+
|
|
181
|
+
def compose(self):
|
|
182
|
+
with Vertical(id="quit_dialog"):
|
|
183
|
+
yield Static("[bold]Quit Chameleon TUI?[/bold]")
|
|
184
|
+
yield Static("Unsaved editor changes will be lost.")
|
|
185
|
+
yield Static("")
|
|
186
|
+
with Horizontal():
|
|
187
|
+
yield Button("Quit", id="btn_quit_yes", variant="error")
|
|
188
|
+
yield Button("Cancel", id="btn_quit_no", variant="default")
|
|
189
|
+
|
|
190
|
+
@on(Button.Pressed, "#btn_quit_yes")
|
|
191
|
+
def on_quit(self) -> None:
|
|
192
|
+
self.dismiss(True)
|
|
193
|
+
|
|
194
|
+
@on(Button.Pressed, "#btn_quit_no")
|
|
195
|
+
def on_cancel(self) -> None:
|
|
196
|
+
self.dismiss(False)
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
class CustomScanScreen(ModalScreen[None]):
|
|
200
|
+
"""Modal for custom scan query/platform input."""
|
|
201
|
+
CSS = """
|
|
202
|
+
CustomScanScreen { align: center middle; }
|
|
203
|
+
#scan_dialog { width: 60; height: auto; border: thick $primary; background: $surface; padding: 1 2; }
|
|
204
|
+
#scan_dialog Input { margin: 0 0 1 0; }
|
|
205
|
+
#scan_help { color: $text-muted; text-style: dim; }
|
|
206
|
+
"""
|
|
207
|
+
|
|
208
|
+
def compose(self):
|
|
209
|
+
with Vertical(id="scan_dialog"):
|
|
210
|
+
yield Static("[bold]\u25b6 Custom Scan[/bold]")
|
|
211
|
+
yield Static("")
|
|
212
|
+
yield Static("Queries (comma-separated):")
|
|
213
|
+
queries = ",".join(config.scan_queries())
|
|
214
|
+
yield Input(value=queries, id="cs_queries", placeholder="python, rust, ai engineer")
|
|
215
|
+
yield Static("Platforms (comma-separated):")
|
|
216
|
+
yield Input(value=config.scan_platforms(), id="cs_platforms", placeholder="remoteok,remotive,hn_hiring")
|
|
217
|
+
yield Static("Limit per query:")
|
|
218
|
+
yield Input(value=str(config.scan_limit()), id="cs_limit", type="integer", placeholder="15")
|
|
219
|
+
yield Static("[dim]Available: remoteok, remotive, hn_hiring, indeed, linkedin, ycombinator...[/dim]", id="scan_help")
|
|
220
|
+
yield Static("")
|
|
221
|
+
with Horizontal():
|
|
222
|
+
yield Button("Scan", id="cs_scan", variant="primary")
|
|
223
|
+
yield Button("Cancel", id="cs_cancel", variant="default")
|
|
224
|
+
|
|
225
|
+
@on(Button.Pressed, "#cs_scan")
|
|
226
|
+
def on_scan(self) -> None:
|
|
227
|
+
from scripts.tui.app import ChameleonTUI
|
|
228
|
+
queries = self.query_one("#cs_queries", Input).value.strip()
|
|
229
|
+
platforms = self.query_one("#cs_platforms", Input).value.strip()
|
|
230
|
+
limit_raw = self.query_one("#cs_limit", Input).value.strip()
|
|
231
|
+
app = self.app
|
|
232
|
+
if not isinstance(app, ChameleonTUI):
|
|
233
|
+
self.dismiss()
|
|
234
|
+
return
|
|
235
|
+
q_list = [q.strip() for q in queries.split(",") if q.strip()]
|
|
236
|
+
if not q_list:
|
|
237
|
+
q_list = config.scan_queries()
|
|
238
|
+
p_str = platforms or config.scan_platforms()
|
|
239
|
+
try:
|
|
240
|
+
limit_val = int(limit_raw) if limit_raw else config.scan_limit()
|
|
241
|
+
except (ValueError, TypeError):
|
|
242
|
+
limit_val = config.scan_limit()
|
|
243
|
+
app._scanning = True
|
|
244
|
+
app._scan_count = 0
|
|
245
|
+
app._scan_total = len(q_list)
|
|
246
|
+
app._update_status()
|
|
247
|
+
try:
|
|
248
|
+
app.query_one("#main_tabs").active = "tab_scanner"
|
|
249
|
+
except Exception:
|
|
250
|
+
pass
|
|
251
|
+
app._log(f"[yellow]Custom scan: platforms={p_str}, limit={limit_val}, queries={q_list}[/yellow]")
|
|
252
|
+
app._start_scan_worker(q_list, p_str)
|
|
253
|
+
self.dismiss()
|
|
254
|
+
|
|
255
|
+
@on(Button.Pressed, "#cs_cancel")
|
|
256
|
+
def on_cancel(self) -> None:
|
|
257
|
+
self.dismiss()
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
class SetupWizardScreen(ModalScreen[bool]):
|
|
261
|
+
"""Multi-step setup wizard for CV, GitHub, AI reviewer, Telegram, WhatsApp."""
|
|
262
|
+
|
|
263
|
+
CSS = """
|
|
264
|
+
SetupWizardScreen { align: center middle; }
|
|
265
|
+
#wizard_dialog { width: 84; height: 36; border: thick $primary; background: $surface; padding: 1 2; }
|
|
266
|
+
#wizard_header { dock: top; height: 3; background: $primary-darken-1; color: $text; padding: 0 1; text-style: bold; content-align: left middle; }
|
|
267
|
+
#wizard_body { height: 1fr; overflow-y: auto; }
|
|
268
|
+
#wizard_body Input { margin: 0 0 1 0; }
|
|
269
|
+
#wizard_body .wiz-label { color: $text-muted; text-style: dim; }
|
|
270
|
+
#wizard_body .wiz-section { text-style: bold; color: $text; padding: 1 0 0 0; }
|
|
271
|
+
#wizard_body .wiz-hint { color: $text-muted; text-style: dim; }
|
|
272
|
+
#wizard_body .wiz-status { text-style: italic; }
|
|
273
|
+
#wizard_footer { dock: bottom; height: 4; padding: 0 1; }
|
|
274
|
+
#wizard_footer Button { margin: 0 1; }
|
|
275
|
+
#wizard_progress { dock: top; height: 1; background: $surface-darken-1; margin: 0; }
|
|
276
|
+
#wizard_status { dock: bottom; height: 1; padding: 0 1; color: $text-muted; text-style: italic; }
|
|
277
|
+
"""
|
|
278
|
+
|
|
279
|
+
BINDINGS = [
|
|
280
|
+
Binding("escape", "dismiss", "Cancel"),
|
|
281
|
+
Binding("ctrl+n", "next_step", "Next"),
|
|
282
|
+
Binding("ctrl+p", "prev_step", "Back"),
|
|
283
|
+
]
|
|
284
|
+
|
|
285
|
+
STEPS = [
|
|
286
|
+
"welcome",
|
|
287
|
+
"cv_setup",
|
|
288
|
+
"github_profile",
|
|
289
|
+
"ai_reviewer",
|
|
290
|
+
"telegram",
|
|
291
|
+
"whatsapp",
|
|
292
|
+
"bridge",
|
|
293
|
+
"summary",
|
|
294
|
+
]
|
|
295
|
+
|
|
296
|
+
STEP_LABELS = {
|
|
297
|
+
"welcome": "Welcome",
|
|
298
|
+
"cv_setup": "CV Setup",
|
|
299
|
+
"github_profile": "GitHub Profile",
|
|
300
|
+
"ai_reviewer": "AI Reviewer",
|
|
301
|
+
"telegram": "Telegram Bot",
|
|
302
|
+
"whatsapp": "WhatsApp / Evolution API",
|
|
303
|
+
"bridge": "RSS Bridge",
|
|
304
|
+
"summary": "Summary",
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
def __init__(self) -> None:
|
|
308
|
+
super().__init__()
|
|
309
|
+
from scripts.config import config
|
|
310
|
+
self._cfg = config
|
|
311
|
+
self._step_idx = 0
|
|
312
|
+
self._values: dict[str, object] = dict(config.all())
|
|
313
|
+
|
|
314
|
+
def _step_count(self) -> int:
|
|
315
|
+
return len(self.STEPS)
|
|
316
|
+
|
|
317
|
+
def _current_step(self) -> str:
|
|
318
|
+
return self.STEPS[self._step_idx]
|
|
319
|
+
|
|
320
|
+
def compose(self):
|
|
321
|
+
with Vertical(id="wizard_dialog"):
|
|
322
|
+
yield Static("", id="wizard_header")
|
|
323
|
+
yield Static("", id="wizard_progress")
|
|
324
|
+
with VerticalScroll(id="wizard_body"):
|
|
325
|
+
pass
|
|
326
|
+
yield Static("", id="wizard_status")
|
|
327
|
+
with Horizontal(id="wizard_footer"):
|
|
328
|
+
yield Button("Back (Ctrl+P)", id="wiz_prev", variant="default")
|
|
329
|
+
yield Button("Next (Ctrl+N)", id="wiz_next", variant="primary")
|
|
330
|
+
yield Button("Skip", id="wiz_skip", variant="default")
|
|
331
|
+
yield Button("Cancel (Esc)", id="wiz_cancel", variant="error")
|
|
332
|
+
|
|
333
|
+
def on_mount(self) -> None:
|
|
334
|
+
self._render_step()
|
|
335
|
+
|
|
336
|
+
def _render_step(self) -> None:
|
|
337
|
+
step = self._current_step()
|
|
338
|
+
total = self._step_count()
|
|
339
|
+
idx = self._step_idx + 1
|
|
340
|
+
pct = int((idx / total) * 100)
|
|
341
|
+
bar_filled = max(0, pct // 5)
|
|
342
|
+
bar_empty = max(0, 20 - bar_filled)
|
|
343
|
+
|
|
344
|
+
try:
|
|
345
|
+
self.query_one("#wizard_header", Static).update(
|
|
346
|
+
f" Step {idx}/{total}: {self.STEP_LABELS.get(step, step)}"
|
|
347
|
+
)
|
|
348
|
+
self.query_one("#wizard_progress", Static).update(
|
|
349
|
+
f"[dim][{'█' * bar_filled}{'░' * bar_empty}] {pct}%[/dim]"
|
|
350
|
+
)
|
|
351
|
+
except Exception:
|
|
352
|
+
pass
|
|
353
|
+
|
|
354
|
+
is_first = self._step_idx == 0
|
|
355
|
+
is_last = self._step_idx == total - 1
|
|
356
|
+
try:
|
|
357
|
+
self.query_one("#wiz_prev", Button).display = not is_first
|
|
358
|
+
self.query_one("#wiz_next", Button).label = "Finish" if is_last else "Next (Ctrl+N)"
|
|
359
|
+
self.query_one("#wiz_skip", Button).display = step not in ("welcome", "summary")
|
|
360
|
+
except Exception:
|
|
361
|
+
pass
|
|
362
|
+
|
|
363
|
+
try:
|
|
364
|
+
self.query_one("#wizard_status", Static).update(
|
|
365
|
+
"[dim]Tab/Shift+Tab between fields | Ctrl+N Next | Ctrl+P Back | Esc Cancel[/dim]"
|
|
366
|
+
)
|
|
367
|
+
except Exception:
|
|
368
|
+
pass
|
|
369
|
+
|
|
370
|
+
body = self.query_one("#wizard_body")
|
|
371
|
+
body.remove_children()
|
|
372
|
+
|
|
373
|
+
renderer = getattr(self, f"_render_{step}", self._render_placeholder)
|
|
374
|
+
renderer(body)
|
|
375
|
+
|
|
376
|
+
# Focus first input if any
|
|
377
|
+
try:
|
|
378
|
+
first_input = body.query(Input).first()
|
|
379
|
+
first_input.focus()
|
|
380
|
+
except Exception:
|
|
381
|
+
pass
|
|
382
|
+
|
|
383
|
+
def _emit_body_text(self, body, lines: list[str]) -> None:
|
|
384
|
+
"""Add a Static widget with the given text lines."""
|
|
385
|
+
body.mount(Static("\n".join(lines)))
|
|
386
|
+
|
|
387
|
+
def _emit_input_row(self, body, label: str, config_key: str, placeholder: str = "",
|
|
388
|
+
input_type: str = "text", hint: str = "") -> None:
|
|
389
|
+
body.mount(Static(f" {label}", classes="wiz-label"))
|
|
390
|
+
current = self._values.get(config_key, "")
|
|
391
|
+
body.mount(Input(
|
|
392
|
+
value=str(current) if current else "",
|
|
393
|
+
placeholder=placeholder,
|
|
394
|
+
id=f"wiz_{config_key}",
|
|
395
|
+
type=input_type,
|
|
396
|
+
))
|
|
397
|
+
if hint:
|
|
398
|
+
body.mount(Static(f" {hint}", classes="wiz-hint"))
|
|
399
|
+
|
|
400
|
+
def _render_welcome(self, body) -> None:
|
|
401
|
+
self._emit_body_text(body, [
|
|
402
|
+
"[bold]Welcome to Chameleon Setup Wizard[/bold]",
|
|
403
|
+
"",
|
|
404
|
+
"This wizard will guide you through configuring Chameleon.",
|
|
405
|
+
"You can skip any step and change settings later.",
|
|
406
|
+
"",
|
|
407
|
+
"[bold]You will configure:[/bold]",
|
|
408
|
+
"",
|
|
409
|
+
" [bold]1[/bold] [bold]Master CV[/bold] \u2014 Your YAML resume path",
|
|
410
|
+
" [bold]2[/bold] [bold]GitHub Profile[/bold] \u2014 Skill matching data",
|
|
411
|
+
" [bold]3[/bold] [bold]AI Reviewer[/bold] \u2014 Model endpoint",
|
|
412
|
+
" [bold]4[/bold] [bold]Telegram Bot[/bold] \u2014 Remote access",
|
|
413
|
+
" [bold]5[/bold] [bold]WhatsApp[/bold] \u2014 Evolution API setup",
|
|
414
|
+
" [bold]6[/bold] [bold]RSS Bridge[/bold] \u2014 Auto scanning",
|
|
415
|
+
" [bold]7[/bold] [bold]Summary[/bold] \u2014 Apply changes",
|
|
416
|
+
"",
|
|
417
|
+
"[bold green]Press Ctrl+N to start or Esc to skip.[/bold green]",
|
|
418
|
+
])
|
|
419
|
+
|
|
420
|
+
def _render_cv_setup(self, body) -> None:
|
|
421
|
+
from pathlib import Path
|
|
422
|
+
templates_dir = self._cfg.resolve("templates_dir")
|
|
423
|
+
existing = sorted(templates_dir.glob("*.yaml")) if templates_dir.exists() else []
|
|
424
|
+
current = self._values.get("master_cv_path", "templates/master_template.yaml")
|
|
425
|
+
|
|
426
|
+
self._emit_body_text(body, [
|
|
427
|
+
"[bold]Master CV YAML[/bold]",
|
|
428
|
+
"",
|
|
429
|
+
"[dim]Path to your master resume in RenderCV YAML format.[/dim]",
|
|
430
|
+
"",
|
|
431
|
+
])
|
|
432
|
+
if existing:
|
|
433
|
+
self._emit_body_text(body, ["[dim]Detected YAML files in templates/:[/dim]"])
|
|
434
|
+
for p in existing[:8]:
|
|
435
|
+
rel = str(p.relative_to(templates_dir.parent))
|
|
436
|
+
marker = "[green]\u2713 CURRENT[/green]" if rel == current else " "
|
|
437
|
+
self._emit_body_text(body, [f" {marker} [dim]{rel}[/dim]"])
|
|
438
|
+
body.mount(Static(""))
|
|
439
|
+
self._emit_input_row(body, "Path to CV YAML (relative to project root):",
|
|
440
|
+
"master_cv_path", placeholder="templates/master_template.yaml")
|
|
441
|
+
self._emit_body_text(body, [
|
|
442
|
+
"",
|
|
443
|
+
"[dim]Tip: Use /init-cv in the CLI to import from PDF.[/dim]",
|
|
444
|
+
])
|
|
445
|
+
|
|
446
|
+
def _render_github_profile(self, body) -> None:
|
|
447
|
+
current = self._values.get("profile_path", "profiles/github_repos.json")
|
|
448
|
+
exists = self._cfg.resolve("profile_path").exists()
|
|
449
|
+
status = "[green]\u2713 File exists[/green]" if exists else "[red]\u2717 File not found[/red]"
|
|
450
|
+
|
|
451
|
+
self._emit_body_text(body, [
|
|
452
|
+
"[bold]GitHub Profile[/bold]",
|
|
453
|
+
"",
|
|
454
|
+
f" {status}",
|
|
455
|
+
"",
|
|
456
|
+
])
|
|
457
|
+
self._emit_input_row(body, "Path to GitHub profile JSON (relative):",
|
|
458
|
+
"profile_path", placeholder="profiles/github_repos.json")
|
|
459
|
+
self._emit_body_text(body, [
|
|
460
|
+
"",
|
|
461
|
+
"[dim]Generate with: python3 -m scripts.github_scraper <username>[/dim]",
|
|
462
|
+
])
|
|
463
|
+
|
|
464
|
+
def _render_ai_reviewer(self, body) -> None:
|
|
465
|
+
self._emit_body_text(body, [
|
|
466
|
+
"[bold]AI Reviewer / OpenCode[/bold]",
|
|
467
|
+
"",
|
|
468
|
+
"[dim]Configure the AI model used for tailoring and scoring.[/dim]",
|
|
469
|
+
"",
|
|
470
|
+
])
|
|
471
|
+
self._emit_input_row(body, "OpenCode binary path:",
|
|
472
|
+
"opencode_bin_path", placeholder="~/.opencode/bin/opencode")
|
|
473
|
+
self._emit_input_row(body, "OpenCode API URL:",
|
|
474
|
+
"opencode_api_url", placeholder="http://localhost:4096")
|
|
475
|
+
self._emit_input_row(body, "Model provider (opencode, openai, anthropic, groq):",
|
|
476
|
+
"model_provider", placeholder="opencode")
|
|
477
|
+
self._emit_input_row(body, "Model ID:",
|
|
478
|
+
"model_id", placeholder="big-pickle")
|
|
479
|
+
self._emit_body_text(body, [
|
|
480
|
+
"",
|
|
481
|
+
"[dim]Examples: anthropic/claude-3-5-sonnet-20241022, openai/gpt-4-turbo[/dim]",
|
|
482
|
+
])
|
|
483
|
+
|
|
484
|
+
def _render_telegram(self, body) -> None:
|
|
485
|
+
self._emit_body_text(body, [
|
|
486
|
+
"[bold]Telegram Bot[/bold]",
|
|
487
|
+
"",
|
|
488
|
+
"[dim]Set up remote tailoring via Telegram.[/dim]",
|
|
489
|
+
"",
|
|
490
|
+
])
|
|
491
|
+
self._emit_input_row(body, "Bot Token (from @BotFather):",
|
|
492
|
+
"telegram_bot_token", placeholder="123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11")
|
|
493
|
+
self._emit_input_row(body, "Allowed User IDs (comma-separated):",
|
|
494
|
+
"telegram_allowed_user_ids", placeholder="123456789,987654321")
|
|
495
|
+
self._emit_body_text(body, [
|
|
496
|
+
"",
|
|
497
|
+
"[dim]Leave blank to skip Telegram setup (can configure later).[/dim]",
|
|
498
|
+
])
|
|
499
|
+
|
|
500
|
+
def _render_whatsapp(self, body) -> None:
|
|
501
|
+
self._emit_body_text(body, [
|
|
502
|
+
"[bold]WhatsApp / Evolution API[/bold]",
|
|
503
|
+
"",
|
|
504
|
+
"[dim]Configure WhatsApp integration via Evolution API.[/dim]",
|
|
505
|
+
"",
|
|
506
|
+
])
|
|
507
|
+
self._emit_input_row(body, "Evolution API URL:",
|
|
508
|
+
"evolution_api_url", placeholder="http://localhost:8080")
|
|
509
|
+
self._emit_input_row(body, "Evolution API Key:",
|
|
510
|
+
"evolution_api_key", placeholder="your-api-key")
|
|
511
|
+
self._emit_input_row(body, "WhatsApp Instance Name:",
|
|
512
|
+
"whatsapp_instance_name", placeholder="chameleon")
|
|
513
|
+
self._emit_body_text(body, [
|
|
514
|
+
"",
|
|
515
|
+
"[dim]Leave API Key blank to skip WhatsApp setup (can configure later).[/dim]",
|
|
516
|
+
])
|
|
517
|
+
|
|
518
|
+
def _render_bridge(self, body) -> None:
|
|
519
|
+
self._emit_body_text(body, [
|
|
520
|
+
"[bold]RSS Job Scanner Bridge[/bold]",
|
|
521
|
+
"",
|
|
522
|
+
"[dim]Automated job scanning settings for the bridge process.[/dim]",
|
|
523
|
+
"",
|
|
524
|
+
])
|
|
525
|
+
self._emit_input_row(body, "Scan interval (minutes):",
|
|
526
|
+
"rss_scan_interval_minutes", placeholder="60",
|
|
527
|
+
input_type="integer")
|
|
528
|
+
self._emit_input_row(body, "High score threshold:",
|
|
529
|
+
"rss_high_score_threshold", placeholder="70",
|
|
530
|
+
input_type="integer")
|
|
531
|
+
self._emit_body_text(body, [
|
|
532
|
+
"",
|
|
533
|
+
"[dim]Scan queries and platforms can be set in Settings > Scanner.[/dim]",
|
|
534
|
+
])
|
|
535
|
+
|
|
536
|
+
def _render_summary(self, body) -> None:
|
|
537
|
+
lines = [
|
|
538
|
+
"[bold]Setup Summary[/bold]",
|
|
539
|
+
"",
|
|
540
|
+
"[dim]Review your configuration. Press Finish to save.[/dim]",
|
|
541
|
+
"",
|
|
542
|
+
]
|
|
543
|
+
# Check browser automation
|
|
544
|
+
browser_ok = False
|
|
545
|
+
try:
|
|
546
|
+
from scripts.job_scanner.browser import is_playwright_available
|
|
547
|
+
browser_ok = is_playwright_available()
|
|
548
|
+
except Exception:
|
|
549
|
+
pass
|
|
550
|
+
browser_icon = "[green]\u2713[/green]" if browser_ok else "[yellow]\u26a0[/yellow]"
|
|
551
|
+
lines.append(f"[bold]Browser Automation:[/bold]")
|
|
552
|
+
lines.append(f" {browser_icon} Playwright + Chromium ({'Available' if browser_ok else 'Installing automatically...'})")
|
|
553
|
+
lines.append("")
|
|
554
|
+
|
|
555
|
+
sections = [
|
|
556
|
+
("CV & Profile", ["master_cv_path", "profile_path"]),
|
|
557
|
+
("AI Reviewer", ["opencode_bin_path", "opencode_api_url", "model_provider", "model_id"]),
|
|
558
|
+
("Telegram", ["telegram_bot_token", "telegram_allowed_user_ids"]),
|
|
559
|
+
("WhatsApp", ["evolution_api_url", "evolution_api_key", "whatsapp_instance_name"]),
|
|
560
|
+
("Bridge", ["rss_scan_interval_minutes", "rss_high_score_threshold"]),
|
|
561
|
+
]
|
|
562
|
+
for section_name, keys in sections:
|
|
563
|
+
lines.append(f"[bold]{section_name}:[/bold]")
|
|
564
|
+
for k in keys:
|
|
565
|
+
val = self._values.get(k, "")
|
|
566
|
+
display = str(val)
|
|
567
|
+
if k in ("telegram_bot_token", "evolution_api_key") and len(str(val)) > 8:
|
|
568
|
+
s = str(val)
|
|
569
|
+
display = s[:6] + "..." + s[-4:]
|
|
570
|
+
icon = "[green]\u2713[/green]" if val else "[dim]\u2014[/dim]"
|
|
571
|
+
short_key = k.replace("_", " ").title()
|
|
572
|
+
lines.append(f" {icon} [dim]{short_key}:[/dim] [bold]{display}[/bold]")
|
|
573
|
+
lines.append("")
|
|
574
|
+
lines.append("[bold green]Press Finish to save all settings.[/bold green]")
|
|
575
|
+
self._emit_body_text(body, lines)
|
|
576
|
+
|
|
577
|
+
def _render_placeholder(self, body) -> None:
|
|
578
|
+
self._emit_body_text(body, ["[dim]Step content not found[/dim]"])
|
|
579
|
+
|
|
580
|
+
# ── Navigation ─────────────────────────────────────────────────────
|
|
581
|
+
def _collect_values(self) -> None:
|
|
582
|
+
body = self.query_one("#wizard_body")
|
|
583
|
+
for key in list(self._values.keys()):
|
|
584
|
+
try:
|
|
585
|
+
inp = body.query_one(f"#wiz_{key}", Input)
|
|
586
|
+
val = inp.value.strip()
|
|
587
|
+
current = self._cfg.all().get(key)
|
|
588
|
+
if isinstance(current, int):
|
|
589
|
+
try:
|
|
590
|
+
self._values[key] = int(val) if val else current
|
|
591
|
+
except ValueError:
|
|
592
|
+
pass
|
|
593
|
+
elif isinstance(current, bool):
|
|
594
|
+
self._values[key] = val.lower() == "true"
|
|
595
|
+
else:
|
|
596
|
+
self._values[key] = val or current
|
|
597
|
+
except Exception:
|
|
598
|
+
pass
|
|
599
|
+
|
|
600
|
+
def action_next_step(self) -> None:
|
|
601
|
+
self._collect_values()
|
|
602
|
+
if self._step_idx < self._step_count() - 1:
|
|
603
|
+
self._step_idx += 1
|
|
604
|
+
self._render_step()
|
|
605
|
+
else:
|
|
606
|
+
self._finish_wizard()
|
|
607
|
+
|
|
608
|
+
def action_prev_step(self) -> None:
|
|
609
|
+
self._collect_values()
|
|
610
|
+
if self._step_idx > 0:
|
|
611
|
+
self._step_idx -= 1
|
|
612
|
+
self._render_step()
|
|
613
|
+
|
|
614
|
+
def _finish_wizard(self) -> None:
|
|
615
|
+
self._collect_values()
|
|
616
|
+
self._values["setup_wizard_completed"] = True
|
|
617
|
+
self._values["setup_wizard_version"] = 1
|
|
618
|
+
updates = {k: v for k, v in self._values.items() if k in self._cfg.all()}
|
|
619
|
+
self._cfg.set_many(updates)
|
|
620
|
+
self._cfg.sync_to_bridge()
|
|
621
|
+
self.dismiss(True)
|
|
622
|
+
|
|
623
|
+
@on(Button.Pressed, "#wiz_next")
|
|
624
|
+
def on_wiz_next(self) -> None:
|
|
625
|
+
self.action_next_step()
|
|
626
|
+
|
|
627
|
+
@on(Button.Pressed, "#wiz_prev")
|
|
628
|
+
def on_wiz_prev(self) -> None:
|
|
629
|
+
self.action_prev_step()
|
|
630
|
+
|
|
631
|
+
@on(Button.Pressed, "#wiz_cancel")
|
|
632
|
+
def on_wiz_cancel(self) -> None:
|
|
633
|
+
self.dismiss(False)
|
|
634
|
+
|
|
635
|
+
@on(Button.Pressed, "#wiz_skip")
|
|
636
|
+
def on_wiz_skip(self) -> None:
|
|
637
|
+
self._collect_values()
|
|
638
|
+
self._step_idx = min(self._step_idx + 1, self._step_count() - 1)
|
|
639
|
+
self._render_step()
|
|
640
|
+
|
|
641
|
+
def action_dismiss(self) -> None:
|
|
642
|
+
self.dismiss(False)
|