termainer 0.4.0__py3-none-any.whl
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.
- termainer/__init__.py +0 -0
- termainer/app.py +242 -0
- termainer/config.py +82 -0
- termainer/config_manager.py +75 -0
- termainer/locale.py +460 -0
- termainer/providers/__init__.py +0 -0
- termainer/providers/base.py +61 -0
- termainer/providers/docker.py +213 -0
- termainer/providers/kubernetes.py +239 -0
- termainer/providers/openshift.py +77 -0
- termainer/providers/podman.py +158 -0
- termainer/providers/swarm.py +211 -0
- termainer/remote/__init__.py +0 -0
- termainer/remote/ssh.py +157 -0
- termainer/server_manager.py +84 -0
- termainer/ssh_config.py +138 -0
- termainer/ui/__init__.py +0 -0
- termainer/ui/dashboard.py +837 -0
- termainer/ui/environment.py +300 -0
- termainer/ui/home.py +263 -0
- termainer/ui/splash.py +89 -0
- termainer/ui/widgets.py +335 -0
- termainer/utils/__init__.py +0 -0
- termainer/utils/helpers.py +56 -0
- termainer/version.py +1 -0
- termainer-0.4.0.dist-info/METADATA +419 -0
- termainer-0.4.0.dist-info/RECORD +30 -0
- termainer-0.4.0.dist-info/WHEEL +5 -0
- termainer-0.4.0.dist-info/entry_points.txt +2 -0
- termainer-0.4.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,300 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
import shutil
|
|
5
|
+
from typing import List
|
|
6
|
+
|
|
7
|
+
from textual.app import ComposeResult
|
|
8
|
+
from textual.binding import Binding
|
|
9
|
+
from textual.containers import Horizontal, Vertical
|
|
10
|
+
from textual.events import Resize
|
|
11
|
+
from textual.screen import Screen
|
|
12
|
+
from textual.widgets import Button, Static
|
|
13
|
+
|
|
14
|
+
from ..locale import _
|
|
15
|
+
from ..config import has_ssh_servers_configured
|
|
16
|
+
from ..server_manager import ServerManager
|
|
17
|
+
from ..version import VERSION
|
|
18
|
+
from .dashboard import Dashboard
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
_SERVER_ICONS = {
|
|
22
|
+
"docker": "[bold #4ade80]◇[/]",
|
|
23
|
+
"podman": "[bold #22d3ee]◇[/]",
|
|
24
|
+
"kubernetes": "[bold #22d3ee]◈[/]",
|
|
25
|
+
"openshift": "[bold #f87171]◈[/]",
|
|
26
|
+
"swarm": "[bold #fbbf24]⬢[/]",
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _server_icon(provider_name: str) -> str:
|
|
31
|
+
return _SERVER_ICONS.get(provider_name.lower(), "[bold white]◇[/]")
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass(frozen=True)
|
|
35
|
+
class TechnologyCard:
|
|
36
|
+
provider: str
|
|
37
|
+
title: str
|
|
38
|
+
subtitle: str
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
TECHNOLOGY_CARDS: List[TechnologyCard] = [
|
|
42
|
+
TechnologyCard("docker", "Docker", _("environment.subtitle.docker")),
|
|
43
|
+
TechnologyCard("kubernetes", "Kubernetes", _("environment.subtitle.kubernetes")),
|
|
44
|
+
TechnologyCard("podman", "Podman", _("environment.subtitle.podman")),
|
|
45
|
+
TechnologyCard("openshift", "OpenShift", _("environment.subtitle.openshift")),
|
|
46
|
+
TechnologyCard("swarm", "Docker Swarm", _("environment.subtitle.swarm")),
|
|
47
|
+
]
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
PROVIDER_CLI_COMMAND = {
|
|
51
|
+
"docker": "docker",
|
|
52
|
+
"kubernetes": "kubectl",
|
|
53
|
+
"podman": "podman",
|
|
54
|
+
"openshift": "oc",
|
|
55
|
+
"swarm": "docker",
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class EnvironmentScreen(Screen):
|
|
60
|
+
BINDINGS = [
|
|
61
|
+
Binding("up", "focus_up", _("environment.bind.up"), priority=True),
|
|
62
|
+
Binding("down", "focus_down", _("environment.bind.down"), priority=True),
|
|
63
|
+
Binding("left", "focus_left", _("environment.bind.left"), priority=True),
|
|
64
|
+
Binding("right", "focus_right", _("environment.bind.right"), priority=True),
|
|
65
|
+
Binding("enter", "select_focused", _("environment.bind.select"), priority=True),
|
|
66
|
+
Binding("?", "show_welcome_help", _("environment.bind.help"), priority=True),
|
|
67
|
+
Binding("h", "show_welcome_help", _("environment.bind.help"), priority=True),
|
|
68
|
+
Binding("q", "quit", _("environment.bind.quit"), priority=True),
|
|
69
|
+
]
|
|
70
|
+
|
|
71
|
+
def __init__(self, server_manager: ServerManager) -> None:
|
|
72
|
+
super().__init__()
|
|
73
|
+
self._server_manager = server_manager
|
|
74
|
+
self._card_ids: List[str] = []
|
|
75
|
+
self._card_actions: List[str] = []
|
|
76
|
+
|
|
77
|
+
def compose(self) -> ComposeResult:
|
|
78
|
+
rows: List[Horizontal] = []
|
|
79
|
+
row_cards: List[Vertical] = []
|
|
80
|
+
cols_per_row = 2
|
|
81
|
+
|
|
82
|
+
for i, tech in enumerate(TECHNOLOGY_CARDS):
|
|
83
|
+
row_cards.append(self._technology_card(tech, i))
|
|
84
|
+
if len(row_cards) == cols_per_row:
|
|
85
|
+
rows.append(Horizontal(*row_cards, id=f"env-row-{len(rows)}"))
|
|
86
|
+
row_cards = []
|
|
87
|
+
|
|
88
|
+
if row_cards:
|
|
89
|
+
rows.append(Horizontal(*row_cards, id=f"env-row-{len(rows)}"))
|
|
90
|
+
|
|
91
|
+
yield Vertical(
|
|
92
|
+
Static(f"[bold #22d3ee]TERMAINER[/] [#808080]v{VERSION}[/]", id="env-brand"),
|
|
93
|
+
Static(_("environment.title"), id="env-title"),
|
|
94
|
+
Static(
|
|
95
|
+
f"[dim]{_('environment.copy')}[/]",
|
|
96
|
+
id="env-copy",
|
|
97
|
+
),
|
|
98
|
+
*rows,
|
|
99
|
+
Horizontal(
|
|
100
|
+
Static(
|
|
101
|
+
_("environment.footer.full"),
|
|
102
|
+
id="env-footer-full",
|
|
103
|
+
classes="env-footer-text",
|
|
104
|
+
),
|
|
105
|
+
Static(
|
|
106
|
+
_("environment.footer.compact"),
|
|
107
|
+
id="env-footer-compact",
|
|
108
|
+
classes="env-footer-text",
|
|
109
|
+
),
|
|
110
|
+
id="env-footer",
|
|
111
|
+
),
|
|
112
|
+
id="env-root",
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
@property
|
|
116
|
+
def _has_remote_servers(self) -> bool:
|
|
117
|
+
return any(s.ssh is not None for s in self._server_manager.servers)
|
|
118
|
+
|
|
119
|
+
def _servers_for_provider(self, provider_name: str):
|
|
120
|
+
return [s for s in self._server_manager.servers if s.provider.name.lower() == provider_name.lower()]
|
|
121
|
+
|
|
122
|
+
def _provider_cli_available(self, provider_name: str) -> bool:
|
|
123
|
+
cli = PROVIDER_CLI_COMMAND.get(provider_name)
|
|
124
|
+
if not cli:
|
|
125
|
+
return False
|
|
126
|
+
return shutil.which(cli) is not None
|
|
127
|
+
|
|
128
|
+
def _provider_status_text(self, provider_name: str) -> str:
|
|
129
|
+
count = len(self._servers_for_provider(provider_name))
|
|
130
|
+
if count > 0:
|
|
131
|
+
return _("environment.status.connections", count=str(count))
|
|
132
|
+
if self._provider_cli_available(provider_name):
|
|
133
|
+
if provider_name in {"kubernetes", "openshift"}:
|
|
134
|
+
if self._has_remote_servers:
|
|
135
|
+
return _("environment.status.cli_no_context", provider=provider_name.capitalize())
|
|
136
|
+
return _("environment.status.cli_no_cluster", provider=provider_name.capitalize())
|
|
137
|
+
return _("environment.status.cli_no_servers", provider=provider_name.capitalize())
|
|
138
|
+
return _("environment.status.not_available", provider=provider_name.capitalize())
|
|
139
|
+
|
|
140
|
+
def _technology_card(self, tech: TechnologyCard, idx: int) -> Vertical:
|
|
141
|
+
icon = _server_icon(tech.provider)
|
|
142
|
+
card_id = f"env-card-{idx}"
|
|
143
|
+
self._card_ids.append(card_id)
|
|
144
|
+
self._card_actions.append(f"open_provider_{tech.provider}")
|
|
145
|
+
|
|
146
|
+
card = Vertical(
|
|
147
|
+
Static(f"{icon} [bold white]{tech.title}[/]", classes="env-card-title"),
|
|
148
|
+
Static(f"[dim]{tech.subtitle}[/]", classes="env-card-provider"),
|
|
149
|
+
Static(self._provider_status_text(tech.provider), classes="env-card-copy"),
|
|
150
|
+
Button(_("environment.button.open"), id=f"env-btn-{idx}", classes="env-card-button"),
|
|
151
|
+
classes="env-card",
|
|
152
|
+
id=card_id,
|
|
153
|
+
)
|
|
154
|
+
card.can_focus = True
|
|
155
|
+
return card
|
|
156
|
+
|
|
157
|
+
def on_mount(self) -> None:
|
|
158
|
+
self._apply_responsive_mode(self.size.width, self.size.height)
|
|
159
|
+
if self._card_ids:
|
|
160
|
+
self.query_one(f"#{self._card_ids[0]}").focus()
|
|
161
|
+
|
|
162
|
+
if not has_ssh_servers_configured():
|
|
163
|
+
self.notify(
|
|
164
|
+
_("environment.notify.ssh_hint"),
|
|
165
|
+
severity="information",
|
|
166
|
+
timeout=5,
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
def on_resize(self, event: Resize) -> None:
|
|
170
|
+
self._apply_responsive_mode(event.size.width, event.size.height)
|
|
171
|
+
|
|
172
|
+
def _apply_responsive_mode(self, width: int, height: int) -> None:
|
|
173
|
+
compact = width < 120 or height < 34
|
|
174
|
+
ultra_compact = width < 95 or height < 28
|
|
175
|
+
try:
|
|
176
|
+
root = self.query_one("#env-root", Vertical)
|
|
177
|
+
except Exception:
|
|
178
|
+
return
|
|
179
|
+
if compact:
|
|
180
|
+
root.add_class("compact")
|
|
181
|
+
else:
|
|
182
|
+
root.remove_class("compact")
|
|
183
|
+
if ultra_compact:
|
|
184
|
+
root.add_class("ultra-compact")
|
|
185
|
+
else:
|
|
186
|
+
root.remove_class("ultra-compact")
|
|
187
|
+
|
|
188
|
+
def on_button_pressed(self, event: Button.Pressed) -> None:
|
|
189
|
+
btn_id = event.button.id or ""
|
|
190
|
+
try:
|
|
191
|
+
idx = int(btn_id.replace("env-btn-", ""))
|
|
192
|
+
action_name = self._card_actions[idx]
|
|
193
|
+
self._execute_action(action_name)
|
|
194
|
+
except (ValueError, IndexError):
|
|
195
|
+
pass
|
|
196
|
+
|
|
197
|
+
def _execute_action(self, action_name: str) -> None:
|
|
198
|
+
if not action_name.startswith("open_provider_"):
|
|
199
|
+
return
|
|
200
|
+
provider_name = action_name.replace("open_provider_", "")
|
|
201
|
+
|
|
202
|
+
matches = self._servers_for_provider(provider_name)
|
|
203
|
+
if not matches:
|
|
204
|
+
if self._provider_cli_available(provider_name):
|
|
205
|
+
if provider_name in {"kubernetes", "openshift"}:
|
|
206
|
+
if self._has_remote_servers:
|
|
207
|
+
self.notify(
|
|
208
|
+
_("environment.notify.no_context", provider=provider_name.capitalize()),
|
|
209
|
+
severity="warning",
|
|
210
|
+
)
|
|
211
|
+
else:
|
|
212
|
+
self.notify(
|
|
213
|
+
_("environment.notify.no_cluster", provider=provider_name.capitalize()),
|
|
214
|
+
severity="warning",
|
|
215
|
+
)
|
|
216
|
+
return
|
|
217
|
+
self.notify(
|
|
218
|
+
_("environment.notify.no_servers", provider=provider_name.capitalize()),
|
|
219
|
+
severity="warning",
|
|
220
|
+
)
|
|
221
|
+
return
|
|
222
|
+
self.notify(
|
|
223
|
+
_("environment.notify.not_installed", provider=provider_name.capitalize()),
|
|
224
|
+
severity="warning",
|
|
225
|
+
)
|
|
226
|
+
return
|
|
227
|
+
|
|
228
|
+
provider_server_manager = ServerManager(matches)
|
|
229
|
+
self.app.switch_screen(
|
|
230
|
+
Dashboard(
|
|
231
|
+
provider_server_manager,
|
|
232
|
+
server_label=None,
|
|
233
|
+
root_server_manager=self._server_manager,
|
|
234
|
+
)
|
|
235
|
+
)
|
|
236
|
+
|
|
237
|
+
# Navigation
|
|
238
|
+
def action_focus_up(self) -> None:
|
|
239
|
+
self._move_focus(-self._cols())
|
|
240
|
+
|
|
241
|
+
def action_focus_down(self) -> None:
|
|
242
|
+
self._move_focus(self._cols())
|
|
243
|
+
|
|
244
|
+
def action_focus_left(self) -> None:
|
|
245
|
+
self._move_focus(-1)
|
|
246
|
+
|
|
247
|
+
def action_focus_right(self) -> None:
|
|
248
|
+
self._move_focus(1)
|
|
249
|
+
|
|
250
|
+
def _cols(self) -> int:
|
|
251
|
+
return min(2, max(1, len(self._card_ids)))
|
|
252
|
+
|
|
253
|
+
def _move_focus(self, delta: int) -> None:
|
|
254
|
+
if not self._card_ids:
|
|
255
|
+
return
|
|
256
|
+
focused = self.focused
|
|
257
|
+
if focused is None:
|
|
258
|
+
self.query_one(f"#{self._card_ids[0]}").focus()
|
|
259
|
+
return
|
|
260
|
+
focused_id = focused.id or ""
|
|
261
|
+
if focused_id not in self._card_ids:
|
|
262
|
+
parent = focused.parent
|
|
263
|
+
if parent and parent.id in self._card_ids:
|
|
264
|
+
focused_id = parent.id
|
|
265
|
+
else:
|
|
266
|
+
return
|
|
267
|
+
try:
|
|
268
|
+
idx = self._card_ids.index(focused_id)
|
|
269
|
+
except ValueError:
|
|
270
|
+
return
|
|
271
|
+
new_idx = (idx + delta) % len(self._card_ids)
|
|
272
|
+
self.query_one(f"#{self._card_ids[new_idx]}").focus()
|
|
273
|
+
|
|
274
|
+
def action_select_focused(self) -> None:
|
|
275
|
+
if not self._card_ids:
|
|
276
|
+
return
|
|
277
|
+
focused = self.focused
|
|
278
|
+
if focused is None:
|
|
279
|
+
return
|
|
280
|
+
focused_id = focused.id or ""
|
|
281
|
+
if focused_id not in self._card_ids:
|
|
282
|
+
parent = focused.parent
|
|
283
|
+
if parent and parent.id in self._card_ids:
|
|
284
|
+
focused_id = parent.id
|
|
285
|
+
else:
|
|
286
|
+
return
|
|
287
|
+
try:
|
|
288
|
+
idx = self._card_ids.index(focused_id)
|
|
289
|
+
except ValueError:
|
|
290
|
+
return
|
|
291
|
+
action_name = self._card_actions[idx]
|
|
292
|
+
self._execute_action(action_name)
|
|
293
|
+
|
|
294
|
+
def action_quit(self) -> None:
|
|
295
|
+
self.app.exit()
|
|
296
|
+
|
|
297
|
+
def action_show_welcome_help(self) -> None:
|
|
298
|
+
from .home import HomeScreen
|
|
299
|
+
|
|
300
|
+
self.app.switch_screen(HomeScreen(self._server_manager))
|
termainer/ui/home.py
ADDED
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from textual.app import ComposeResult
|
|
4
|
+
from textual.containers import Horizontal, Vertical
|
|
5
|
+
from textual.events import Resize
|
|
6
|
+
from textual.reactive import reactive
|
|
7
|
+
from textual.screen import Screen
|
|
8
|
+
from textual.widgets import Static
|
|
9
|
+
|
|
10
|
+
from ..locale import _
|
|
11
|
+
from ..server_manager import ServerManager
|
|
12
|
+
from ..version import VERSION
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
# ── ASCII art ─────────────────────────────────────────────────────────────────
|
|
16
|
+
|
|
17
|
+
LOGO_BLOCK = """\
|
|
18
|
+
[bold #66FF33] _______ ______ _____ __ __ _____ _ _ ______ _____ [/]
|
|
19
|
+
[bold #66FF33]|__ __| ____| __ \\| \\/ | /\\ |_ _| \\ | | ____| __ \\ [/]
|
|
20
|
+
[bold #66FF33] | | | |__ | |__) | \\ / | / \\ | | | \\| | |__ | |__) |[/]
|
|
21
|
+
[bold #66FF33] | | | __| | _ /| |\\/| | / /\\ \\ | | | . ` | __| | _ / [/]
|
|
22
|
+
[bold #66FF33] | | | |____| | \\ \\| | | |/ ____ \\ _| |_| |\\ | |____| | \\ \\ [/]
|
|
23
|
+
[bold #66FF33] |_| |______|_| \\_\\_| |_/_/ \\_\\_____|_| \\_|______|_| \\_\\ [/]"""
|
|
24
|
+
|
|
25
|
+
LOGO_COMPACT = "[bold #66FF33][ >_ ] TERMAINER[/]"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
# ── Widgets ───────────────────────────────────────────────────────────────────
|
|
29
|
+
|
|
30
|
+
class HeaderWidget(Horizontal):
|
|
31
|
+
"""Fixed header: brand (left) · welcome (center) · live status (right)."""
|
|
32
|
+
|
|
33
|
+
_pulse: reactive[bool] = reactive(True)
|
|
34
|
+
|
|
35
|
+
def __init__(self, server_manager: ServerManager) -> None:
|
|
36
|
+
super().__init__(id="home-header")
|
|
37
|
+
self._server_manager = server_manager
|
|
38
|
+
|
|
39
|
+
def compose(self) -> ComposeResult:
|
|
40
|
+
yield Static(
|
|
41
|
+
f"[bold #66FF33][ >_ ][/] [bold #66FF33]TERMAINER[/] [dim #66FF33]v{VERSION}[/]",
|
|
42
|
+
id="home-logo",
|
|
43
|
+
)
|
|
44
|
+
yield Static(f"─── {_('home.welcome')} ───", id="home-welcome")
|
|
45
|
+
yield Static("", id="home-status")
|
|
46
|
+
|
|
47
|
+
def on_mount(self) -> None:
|
|
48
|
+
self._render_status()
|
|
49
|
+
self.set_interval(0.9, self._toggle_pulse)
|
|
50
|
+
|
|
51
|
+
def _toggle_pulse(self) -> None:
|
|
52
|
+
self._pulse = not self._pulse
|
|
53
|
+
|
|
54
|
+
def watch__pulse(self, _: bool) -> None:
|
|
55
|
+
self._render_status()
|
|
56
|
+
|
|
57
|
+
def _render_status(self) -> None:
|
|
58
|
+
count = self._server_manager.server_count
|
|
59
|
+
dot = "[bold #66FF33]●[/]" if self._pulse else "[dim #2a2a2a]●[/]"
|
|
60
|
+
try:
|
|
61
|
+
self.query_one("#home-status", Static).update(
|
|
62
|
+
f"{dot} {_('home.header.status', connected=count, total=count)}"
|
|
63
|
+
)
|
|
64
|
+
except Exception:
|
|
65
|
+
pass
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class HeroWidget(Vertical):
|
|
69
|
+
"""Full-width hero section: big logo + tagline."""
|
|
70
|
+
|
|
71
|
+
def compose(self) -> ComposeResult:
|
|
72
|
+
yield Static(LOGO_BLOCK, id="hero-logo")
|
|
73
|
+
yield Static(
|
|
74
|
+
f"[bold #00E5FF]{_('home.hero.tagline')}[/]",
|
|
75
|
+
id="hero-tagline",
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
def set_compact(self, compact: bool) -> None:
|
|
79
|
+
try:
|
|
80
|
+
self.query_one("#hero-logo", Static).update(
|
|
81
|
+
LOGO_COMPACT if compact else LOGO_BLOCK
|
|
82
|
+
)
|
|
83
|
+
except Exception:
|
|
84
|
+
pass
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
class PlatformsStripWidget(Horizontal):
|
|
88
|
+
"""Horizontal strip of 6 platform icons — no boxes, icon + name only."""
|
|
89
|
+
|
|
90
|
+
_PLATFORMS = [
|
|
91
|
+
("🐳", "#3399FF", "home.platform.docker"),
|
|
92
|
+
("⎈", "#3399FF", "home.platform.k8s"),
|
|
93
|
+
("⬡", "#FFD400", "home.platform.swarm"),
|
|
94
|
+
("◇", "#B366FF", "home.platform.podman"),
|
|
95
|
+
("◈", "#FF3B30", "home.platform.openshift"),
|
|
96
|
+
("▸_", "#66FF33", "home.platform.ssh"),
|
|
97
|
+
]
|
|
98
|
+
|
|
99
|
+
def compose(self) -> ComposeResult:
|
|
100
|
+
for icon, color, key in self._PLATFORMS:
|
|
101
|
+
yield Static(
|
|
102
|
+
f"[bold {color}]{icon}[/] [{color}]{_(key)}[/]",
|
|
103
|
+
classes="platform-item",
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
class AboutWidget(Vertical):
|
|
108
|
+
"""Compact description of what Termainer is."""
|
|
109
|
+
|
|
110
|
+
def compose(self) -> ComposeResult:
|
|
111
|
+
yield Static(f"[bold #00E5FF]{_('home.about.title')}[/]", classes="section-label")
|
|
112
|
+
yield Static(
|
|
113
|
+
_("home.about.description"),
|
|
114
|
+
id="about-description",
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
class FeaturesWidget(Horizontal):
|
|
119
|
+
"""Five-column feature breakdown with icons, titles and item lists."""
|
|
120
|
+
|
|
121
|
+
_COLS = [
|
|
122
|
+
("#66FF33", "▥", "home.features.obs.title", "home.features.obs.items"),
|
|
123
|
+
("#00E5FF", "⌕", "home.features.inspection.title", "home.features.inspection.items"),
|
|
124
|
+
("#3399FF", "⚙", "home.features.ops.title", "home.features.ops.items"),
|
|
125
|
+
("#B366FF", "⬡", "home.features.export.title", "home.features.export.items"),
|
|
126
|
+
("#FFD400", "⇶", "home.features.unified.title", "home.features.unified.items"),
|
|
127
|
+
]
|
|
128
|
+
|
|
129
|
+
def compose(self) -> ComposeResult:
|
|
130
|
+
for color, icon, title_key, items_key in self._COLS:
|
|
131
|
+
yield Vertical(
|
|
132
|
+
Static(f"[bold {color}]{icon} {_(title_key)}[/]", classes="feat-title"),
|
|
133
|
+
Static(f"[dim]{_(items_key)}[/]", classes="feat-body"),
|
|
134
|
+
classes="feat-col",
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
class SupportedPlatformsWidget(Horizontal):
|
|
139
|
+
"""Six platform cards, each with its brand-color border."""
|
|
140
|
+
|
|
141
|
+
_CARDS = [
|
|
142
|
+
("🐳", "#3399FF", "home.platform.docker", "plat-docker"),
|
|
143
|
+
("⎈", "#3399FF", "home.platform.k8s", "plat-k8s"),
|
|
144
|
+
("⬡", "#FFD400", "home.platform.swarm", "plat-swarm"),
|
|
145
|
+
("◇", "#B366FF", "home.platform.podman", "plat-podman"),
|
|
146
|
+
("◈", "#FF3B30", "home.platform.openshift", "plat-openshift"),
|
|
147
|
+
("▸_", "#66FF33", "home.platform.ssh", "plat-ssh"),
|
|
148
|
+
]
|
|
149
|
+
|
|
150
|
+
def compose(self) -> ComposeResult:
|
|
151
|
+
for icon, color, key, css_class in self._CARDS:
|
|
152
|
+
name = _(key)
|
|
153
|
+
parts = name.split(" ", 1)
|
|
154
|
+
display = f"{parts[0]}\n{parts[1]}" if len(parts) > 1 else name
|
|
155
|
+
yield Vertical(
|
|
156
|
+
Static(f"[bold {color}]{icon}[/]", classes="plat-icon"),
|
|
157
|
+
Static(display, classes="plat-name"),
|
|
158
|
+
Static(f"[bold #4ade80]{_('home.platforms.supported')}[/]", classes="plat-status"),
|
|
159
|
+
classes=f"plat-card {css_class}",
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
class FooterWidget(Vertical):
|
|
164
|
+
"""Centered footer: Enter hint + credits."""
|
|
165
|
+
|
|
166
|
+
def compose(self) -> ComposeResult:
|
|
167
|
+
hint = _("home.footer.enter_hint")
|
|
168
|
+
styled = hint.replace("ENTER", "[bold #66FF33]ENTER[/]")
|
|
169
|
+
yield Static(styled, id="footer-enter")
|
|
170
|
+
yield Static(
|
|
171
|
+
_("home.footer.credits"),
|
|
172
|
+
id="footer-credits",
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
# ── Screen ────────────────────────────────────────────────────────────────────
|
|
177
|
+
|
|
178
|
+
class HomeScreen(Screen):
|
|
179
|
+
"""
|
|
180
|
+
Widget-based home/welcome screen.
|
|
181
|
+
|
|
182
|
+
Hierarchy:
|
|
183
|
+
HomeScreen
|
|
184
|
+
├── HeaderWidget brand · welcome · live pulsing status
|
|
185
|
+
├── HeroWidget big logo + tagline
|
|
186
|
+
├── PlatformsStripWidget 6 platform icons, no boxes
|
|
187
|
+
├── AboutWidget description + ASCII connection diagram
|
|
188
|
+
├── FeaturesWidget 5-column feature breakdown
|
|
189
|
+
├── SupportedPlatformsWidget 6 colored-border platform cards
|
|
190
|
+
└── FooterWidget Enter hint + credits (no shortcuts)
|
|
191
|
+
"""
|
|
192
|
+
|
|
193
|
+
BINDINGS = [
|
|
194
|
+
("enter", "continue_to_env", _("home.bind.continue")),
|
|
195
|
+
("escape", "continue_to_env", _("home.bind.continue")),
|
|
196
|
+
("q", "quit", _("home.bind.quit")),
|
|
197
|
+
]
|
|
198
|
+
|
|
199
|
+
def __init__(self, server_manager: ServerManager) -> None:
|
|
200
|
+
super().__init__()
|
|
201
|
+
self._server_manager = server_manager
|
|
202
|
+
self._dismissed = False
|
|
203
|
+
self._hero: HeroWidget | None = None
|
|
204
|
+
|
|
205
|
+
def compose(self) -> ComposeResult:
|
|
206
|
+
hero = HeroWidget(id="home-hero")
|
|
207
|
+
self._hero = hero
|
|
208
|
+
yield Vertical(
|
|
209
|
+
HeaderWidget(self._server_manager),
|
|
210
|
+
hero,
|
|
211
|
+
PlatformsStripWidget(id="home-platforms"),
|
|
212
|
+
AboutWidget(id="home-about"),
|
|
213
|
+
FeaturesWidget(id="home-features"),
|
|
214
|
+
Static(
|
|
215
|
+
f"[bold #00E5FF]{_('home.platforms.title')}[/]",
|
|
216
|
+
id="home-supported-label",
|
|
217
|
+
),
|
|
218
|
+
SupportedPlatformsWidget(id="home-supported"),
|
|
219
|
+
id="home-root",
|
|
220
|
+
)
|
|
221
|
+
yield FooterWidget(id="home-footer")
|
|
222
|
+
|
|
223
|
+
def on_mount(self) -> None:
|
|
224
|
+
self._apply_responsive(self.size.width, self.size.height)
|
|
225
|
+
|
|
226
|
+
def on_resize(self, event: Resize) -> None:
|
|
227
|
+
self._apply_responsive(event.size.width, event.size.height)
|
|
228
|
+
|
|
229
|
+
def _apply_responsive(self, width: int, height: int) -> None:
|
|
230
|
+
compact = width < 100 or height < 34
|
|
231
|
+
try:
|
|
232
|
+
root = self.query_one("#home-root", Vertical)
|
|
233
|
+
except Exception:
|
|
234
|
+
return
|
|
235
|
+
if compact:
|
|
236
|
+
root.add_class("compact")
|
|
237
|
+
if self._hero:
|
|
238
|
+
self._hero.set_compact(True)
|
|
239
|
+
else:
|
|
240
|
+
root.remove_class("compact")
|
|
241
|
+
if self._hero:
|
|
242
|
+
self._hero.set_compact(False)
|
|
243
|
+
|
|
244
|
+
def on_key(self, event) -> None:
|
|
245
|
+
if event.key in ("enter", "escape"):
|
|
246
|
+
event.stop()
|
|
247
|
+
self._dismiss()
|
|
248
|
+
|
|
249
|
+
def on_click(self) -> None:
|
|
250
|
+
self._dismiss()
|
|
251
|
+
|
|
252
|
+
def action_continue_to_env(self) -> None:
|
|
253
|
+
self._dismiss()
|
|
254
|
+
|
|
255
|
+
def action_quit(self) -> None:
|
|
256
|
+
self.app.exit()
|
|
257
|
+
|
|
258
|
+
def _dismiss(self) -> None:
|
|
259
|
+
if self._dismissed:
|
|
260
|
+
return
|
|
261
|
+
self._dismissed = True
|
|
262
|
+
from .environment import EnvironmentScreen
|
|
263
|
+
self.app.switch_screen(EnvironmentScreen(self._server_manager))
|
termainer/ui/splash.py
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
|
|
5
|
+
from textual.app import ComposeResult
|
|
6
|
+
from textual.containers import Center, Vertical
|
|
7
|
+
from textual.screen import Screen
|
|
8
|
+
from textual.widgets import Static
|
|
9
|
+
|
|
10
|
+
from ..locale import _
|
|
11
|
+
from ..server_manager import ServerManager
|
|
12
|
+
from ..version import VERSION
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
_BRAND = f"[bold #66FF33] [ >_ ] T · E · R · M · A · I · N · E · R v{VERSION} [/]"
|
|
16
|
+
|
|
17
|
+
_BOOT_STEP_KEYS: list[tuple[str, str]] = [
|
|
18
|
+
("splash.boot.init", "#66FF33"),
|
|
19
|
+
("splash.boot.docker", "#3399FF"),
|
|
20
|
+
("splash.boot.k8s", "#3399FF"),
|
|
21
|
+
("splash.boot.openshift","#FF3B30"),
|
|
22
|
+
("splash.boot.discover", "#00E5FF"),
|
|
23
|
+
("splash.boot.sync", "#66FF33"),
|
|
24
|
+
]
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class BootScreen(Screen):
|
|
28
|
+
"""Animated boot sequence. Auto-transitions to HomeScreen when done."""
|
|
29
|
+
|
|
30
|
+
BINDINGS = [
|
|
31
|
+
("enter", "skip", _("home.bind.continue")),
|
|
32
|
+
("space", "skip", _("home.bind.continue")),
|
|
33
|
+
]
|
|
34
|
+
|
|
35
|
+
def __init__(self, server_manager: ServerManager) -> None:
|
|
36
|
+
super().__init__()
|
|
37
|
+
self._server_manager = server_manager
|
|
38
|
+
self._done = False
|
|
39
|
+
|
|
40
|
+
def compose(self) -> ComposeResult:
|
|
41
|
+
yield Center(
|
|
42
|
+
Vertical(
|
|
43
|
+
Static(_BRAND, id="boot-brand"),
|
|
44
|
+
Static(
|
|
45
|
+
"[dim]─────────────────────────────────────────────[/]",
|
|
46
|
+
id="boot-sep",
|
|
47
|
+
),
|
|
48
|
+
Static("", id="boot-messages"),
|
|
49
|
+
id="boot-panel",
|
|
50
|
+
),
|
|
51
|
+
id="boot-root",
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
async def on_mount(self) -> None:
|
|
55
|
+
asyncio.create_task(self._run_sequence())
|
|
56
|
+
|
|
57
|
+
async def _run_sequence(self) -> None:
|
|
58
|
+
widget = self.query_one("#boot-messages", Static)
|
|
59
|
+
lines: list[str] = []
|
|
60
|
+
for key, color in _BOOT_STEP_KEYS:
|
|
61
|
+
if self._done:
|
|
62
|
+
break
|
|
63
|
+
text = _(key)
|
|
64
|
+
lines.append(f" [{color}]▶[/] {text}[dim]...[/]")
|
|
65
|
+
widget.update("\n".join(lines))
|
|
66
|
+
await asyncio.sleep(0.11)
|
|
67
|
+
lines[-1] = f" [{color}]✓[/] {text}"
|
|
68
|
+
widget.update("\n".join(lines))
|
|
69
|
+
await asyncio.sleep(0.07)
|
|
70
|
+
if not self._done:
|
|
71
|
+
await asyncio.sleep(0.25)
|
|
72
|
+
self._go_home()
|
|
73
|
+
|
|
74
|
+
def _go_home(self) -> None:
|
|
75
|
+
if self._done:
|
|
76
|
+
return
|
|
77
|
+
self._done = True
|
|
78
|
+
from .home import HomeScreen
|
|
79
|
+
self.app.switch_screen(HomeScreen(self._server_manager))
|
|
80
|
+
|
|
81
|
+
def action_skip(self) -> None:
|
|
82
|
+
self._go_home()
|
|
83
|
+
|
|
84
|
+
def on_click(self) -> None:
|
|
85
|
+
self.action_skip()
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
# Backward-compat alias
|
|
89
|
+
SplashScreen = BootScreen
|