Initial commit
Co-Authored-By: Quentin Torroba <quentin.torroba@mistral.ai> Co-Authored-By: Laure Hugo <laure.hugo@mistral.ai> Co-Authored-By: Benjamin Trom <benjamin.trom@mistral.ai> Co-Authored-By: Mathias Gesbert <mathias.gesbert@ext.mistral.ai> Co-Authored-By: Michel Thomazo <michel.thomazo@mistral.ai> Co-Authored-By: Clément Drouin <clement.drouin@mistral.ai> Co-Authored-By: Vincent Guilloux <vincent.guilloux@mistral.ai> Co-Authored-By: Valentin Berard <val@mistral.ai> Co-Authored-By: Mistral Vibe <vibe@mistral.ai>
This commit is contained in:
commit
fa15fc977b
200 changed files with 30484 additions and 0 deletions
40
vibe/setup/onboarding/__init__.py
Normal file
40
vibe/setup/onboarding/__init__.py
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
|
||||
from rich import print as rprint
|
||||
from textual.app import App
|
||||
|
||||
from vibe.core.config import GLOBAL_ENV_FILE
|
||||
from vibe.setup.onboarding.screens import (
|
||||
ApiKeyScreen,
|
||||
ThemeSelectionScreen,
|
||||
WelcomeScreen,
|
||||
)
|
||||
|
||||
|
||||
class OnboardingApp(App[str | None]):
|
||||
CSS_PATH = "onboarding.tcss"
|
||||
|
||||
def on_mount(self) -> None:
|
||||
self.install_screen(WelcomeScreen(), "welcome")
|
||||
self.install_screen(ThemeSelectionScreen(), "theme_selection")
|
||||
self.install_screen(ApiKeyScreen(), "api_key")
|
||||
self.push_screen("welcome")
|
||||
|
||||
|
||||
def run_onboarding(app: App | None = None) -> None:
|
||||
result = (app or OnboardingApp()).run()
|
||||
match result:
|
||||
case None:
|
||||
rprint("\n[yellow]Setup cancelled. See you next time![/]")
|
||||
sys.exit(0)
|
||||
case str() as s if s.startswith("save_error:"):
|
||||
err = s.removeprefix("save_error:")
|
||||
rprint(
|
||||
f"\n[yellow]Warning: Could not save API key to .env file: {err}[/]"
|
||||
"\n[dim]The API key is set for this session only. "
|
||||
f"You may need to set it manually in {GLOBAL_ENV_FILE}[/]\n"
|
||||
)
|
||||
case "completed":
|
||||
pass
|
||||
14
vibe/setup/onboarding/base.py
Normal file
14
vibe/setup/onboarding/base.py
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from textual.screen import Screen
|
||||
|
||||
|
||||
class OnboardingScreen(Screen[str | None]):
|
||||
NEXT_SCREEN: str | None = None
|
||||
|
||||
def action_next(self) -> None:
|
||||
if self.NEXT_SCREEN:
|
||||
self.app.switch_screen(self.NEXT_SCREEN)
|
||||
|
||||
def action_cancel(self) -> None:
|
||||
self.app.exit(None)
|
||||
230
vibe/setup/onboarding/onboarding.tcss
Normal file
230
vibe/setup/onboarding/onboarding.tcss
Normal file
|
|
@ -0,0 +1,230 @@
|
|||
/* =============================================================================
|
||||
Onboarding App Styles
|
||||
============================================================================= */
|
||||
|
||||
Screen {
|
||||
align: center middle;
|
||||
}
|
||||
|
||||
OnboardingScreen {
|
||||
align: center middle;
|
||||
}
|
||||
|
||||
/* =============================================================================
|
||||
Welcome Screen
|
||||
============================================================================= */
|
||||
|
||||
#welcome-container {
|
||||
align: center middle;
|
||||
}
|
||||
|
||||
#welcome-text {
|
||||
border: round #555555;
|
||||
padding: 1 3;
|
||||
margin-bottom: 2;
|
||||
text-align: center;
|
||||
width: auto;
|
||||
}
|
||||
|
||||
WelcomeScreen #enter-hint {
|
||||
color: $text-muted;
|
||||
min-width: 16;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
WelcomeScreen #enter-hint.hidden {
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
/* =============================================================================
|
||||
Theme Selection Screen
|
||||
============================================================================= */
|
||||
|
||||
#theme-outer {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
align: center middle;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
#theme-content {
|
||||
width: auto;
|
||||
height: auto;
|
||||
padding: 1 0;
|
||||
}
|
||||
|
||||
#theme-title {
|
||||
text-align: center;
|
||||
width: 100%;
|
||||
margin-bottom: 2;
|
||||
}
|
||||
|
||||
#theme-row {
|
||||
width: auto;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
#nav-hint {
|
||||
color: $text-muted;
|
||||
width: 16;
|
||||
height: 100%;
|
||||
content-align: right middle;
|
||||
padding-right: 2;
|
||||
}
|
||||
|
||||
#theme-list {
|
||||
width: 24;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.theme-item {
|
||||
text-align: center;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.theme-item.selected {
|
||||
color: $text;
|
||||
text-style: bold;
|
||||
border: round #555555;
|
||||
padding: 0 2;
|
||||
}
|
||||
|
||||
.theme-item.fade-1 {
|
||||
text-opacity: 50%;
|
||||
}
|
||||
|
||||
.theme-item.fade-2 {
|
||||
text-opacity: 25%;
|
||||
}
|
||||
|
||||
.theme-item.fade-3 {
|
||||
text-opacity: 10%;
|
||||
}
|
||||
|
||||
ThemeSelectionScreen #enter-hint {
|
||||
color: $text-muted;
|
||||
width: 16;
|
||||
height: 100%;
|
||||
content-align: left middle;
|
||||
padding-left: 2;
|
||||
}
|
||||
|
||||
#preview-center {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
margin-top: 2;
|
||||
align: center middle;
|
||||
}
|
||||
|
||||
#preview {
|
||||
min-width: 50;
|
||||
max-width: 70;
|
||||
height: auto;
|
||||
max-height: 100%;
|
||||
overflow: auto;
|
||||
border: round #555555;
|
||||
border-title-align: center;
|
||||
}
|
||||
|
||||
#preview-inner {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
padding: 0 1 0 1;
|
||||
}
|
||||
|
||||
/* =============================================================================
|
||||
API Key Screen
|
||||
============================================================================= */
|
||||
|
||||
#api-key-outer {
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.spacer {
|
||||
height: 1fr;
|
||||
}
|
||||
|
||||
#api-key-title {
|
||||
text-align: center;
|
||||
margin-bottom: 2;
|
||||
}
|
||||
|
||||
#api-key-content {
|
||||
width: auto;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
#api-key-content Static {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.link-row {
|
||||
width: auto;
|
||||
height: auto;
|
||||
margin-top: 1;
|
||||
}
|
||||
|
||||
.link-chevron {
|
||||
width: auto;
|
||||
}
|
||||
|
||||
#input-box {
|
||||
border: round #555555;
|
||||
padding: 0 1;
|
||||
margin-top: 2;
|
||||
width: auto;
|
||||
height: 3;
|
||||
}
|
||||
|
||||
#input-box.valid {
|
||||
border: round $success;
|
||||
}
|
||||
|
||||
#input-box.invalid {
|
||||
border: round $error;
|
||||
}
|
||||
|
||||
#key {
|
||||
border: none;
|
||||
width: 48;
|
||||
height: 1;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
#paste-hint {
|
||||
margin-top: 3;
|
||||
}
|
||||
|
||||
#feedback {
|
||||
text-align: center;
|
||||
height: 1;
|
||||
margin-top: 1;
|
||||
}
|
||||
|
||||
#feedback.error {
|
||||
color: $error;
|
||||
}
|
||||
|
||||
#feedback.success {
|
||||
color: $text-muted;
|
||||
}
|
||||
|
||||
#config-docs-section {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
align: center top;
|
||||
padding-bottom: 2;
|
||||
}
|
||||
|
||||
#config-docs-group {
|
||||
width: auto;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
#config-docs-group Static {
|
||||
color: $text-muted;
|
||||
}
|
||||
|
||||
#config-docs-group .link-row {
|
||||
margin-top: 0;
|
||||
}
|
||||
7
vibe/setup/onboarding/screens/__init__.py
Normal file
7
vibe/setup/onboarding/screens/__init__.py
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from vibe.setup.onboarding.screens.api_key import ApiKeyScreen
|
||||
from vibe.setup.onboarding.screens.theme_selection import ThemeSelectionScreen
|
||||
from vibe.setup.onboarding.screens.welcome import WelcomeScreen
|
||||
|
||||
__all__ = ["ApiKeyScreen", "ThemeSelectionScreen", "WelcomeScreen"]
|
||||
133
vibe/setup/onboarding/screens/api_key.py
Normal file
133
vibe/setup/onboarding/screens/api_key.py
Normal file
|
|
@ -0,0 +1,133 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from typing import ClassVar
|
||||
|
||||
from dotenv import set_key
|
||||
from textual.app import ComposeResult
|
||||
from textual.binding import Binding, BindingType
|
||||
from textual.containers import Center, Horizontal, Vertical
|
||||
from textual.events import MouseUp
|
||||
from textual.validation import Length
|
||||
from textual.widgets import Input, Link, Static
|
||||
|
||||
from vibe.cli.clipboard import copy_selection_to_clipboard
|
||||
from vibe.core.config import GLOBAL_ENV_FILE, VibeConfig
|
||||
from vibe.setup.onboarding.base import OnboardingScreen
|
||||
|
||||
PROVIDER_HELP = {
|
||||
"mistral": ("https://console.mistral.ai/codestral/vibe", "Mistral AI Studio")
|
||||
}
|
||||
CONFIG_DOCS_URL = (
|
||||
"https://github.com/mistralai/mistral-vibe?tab=readme-ov-file#configuration"
|
||||
)
|
||||
|
||||
|
||||
def _save_api_key_to_env_file(env_key: str, api_key: str) -> None:
|
||||
GLOBAL_ENV_FILE.parent.mkdir(parents=True, exist_ok=True)
|
||||
set_key(GLOBAL_ENV_FILE, env_key, api_key)
|
||||
|
||||
|
||||
class ApiKeyScreen(OnboardingScreen):
|
||||
BINDINGS: ClassVar[list[BindingType]] = [
|
||||
Binding("ctrl+c", "cancel", "Cancel", show=False),
|
||||
Binding("escape", "cancel", "Cancel", show=False),
|
||||
]
|
||||
|
||||
NEXT_SCREEN = None
|
||||
|
||||
def __init__(self) -> None:
|
||||
super().__init__()
|
||||
config = VibeConfig.model_construct()
|
||||
active_model = config.get_active_model()
|
||||
self.provider = config.get_provider_for_model(active_model)
|
||||
|
||||
def _compose_provider_link(self, provider_name: str) -> ComposeResult:
|
||||
if self.provider.name not in PROVIDER_HELP:
|
||||
return
|
||||
|
||||
help_url, help_name = PROVIDER_HELP[self.provider.name]
|
||||
yield Static(f"Grab your {provider_name} API key from the {help_name}:")
|
||||
yield Center(
|
||||
Horizontal(
|
||||
Static("→ ", classes="link-chevron"),
|
||||
Link(help_url, url=help_url),
|
||||
classes="link-row",
|
||||
)
|
||||
)
|
||||
|
||||
def _compose_config_docs(self) -> ComposeResult:
|
||||
yield Static("[dim]Learn more about Vibe configuration:[/]")
|
||||
yield Horizontal(
|
||||
Static("→ ", classes="link-chevron"),
|
||||
Link(CONFIG_DOCS_URL, url=CONFIG_DOCS_URL),
|
||||
classes="link-row",
|
||||
)
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
provider_name = self.provider.name.capitalize()
|
||||
|
||||
self.input_widget = Input(
|
||||
password=True,
|
||||
id="key",
|
||||
placeholder="Paste your API key here",
|
||||
validators=[Length(minimum=1, failure_description="No API key provided.")],
|
||||
)
|
||||
|
||||
with Vertical(id="api-key-outer"):
|
||||
yield Static("", classes="spacer")
|
||||
yield Center(Static("One last thing...", id="api-key-title"))
|
||||
with Center():
|
||||
with Vertical(id="api-key-content"):
|
||||
yield from self._compose_provider_link(provider_name)
|
||||
yield Static(
|
||||
"...and paste it below to finish the setup:", id="paste-hint"
|
||||
)
|
||||
yield Center(Horizontal(self.input_widget, id="input-box"))
|
||||
yield Static("", id="feedback")
|
||||
yield Static("", classes="spacer")
|
||||
yield Vertical(
|
||||
Vertical(*self._compose_config_docs(), id="config-docs-group"),
|
||||
id="config-docs-section",
|
||||
)
|
||||
|
||||
def on_mount(self) -> None:
|
||||
self.input_widget.focus()
|
||||
|
||||
def on_input_changed(self, event: Input.Changed) -> None:
|
||||
feedback = self.query_one("#feedback", Static)
|
||||
input_box = self.query_one("#input-box")
|
||||
|
||||
if event.validation_result is None:
|
||||
return
|
||||
|
||||
input_box.remove_class("valid", "invalid")
|
||||
feedback.remove_class("error", "success")
|
||||
|
||||
if event.validation_result.is_valid:
|
||||
feedback.update("Press Enter to submit ↵")
|
||||
feedback.add_class("success")
|
||||
input_box.add_class("valid")
|
||||
return
|
||||
|
||||
descriptions = event.validation_result.failure_descriptions
|
||||
feedback.update(descriptions[0])
|
||||
feedback.add_class("error")
|
||||
input_box.add_class("invalid")
|
||||
|
||||
def on_input_submitted(self, event: Input.Submitted) -> None:
|
||||
if event.validation_result and event.validation_result.is_valid:
|
||||
self._save_and_finish(event.value)
|
||||
|
||||
def _save_and_finish(self, api_key: str) -> None:
|
||||
env_key = self.provider.api_key_env_var
|
||||
os.environ[env_key] = api_key
|
||||
try:
|
||||
_save_api_key_to_env_file(env_key, api_key)
|
||||
except OSError as err:
|
||||
self.app.exit(f"save_error:{err}")
|
||||
return
|
||||
self.app.exit("completed")
|
||||
|
||||
def on_mouse_up(self, event: MouseUp) -> None:
|
||||
copy_selection_to_clipboard(self.app)
|
||||
140
vibe/setup/onboarding/screens/theme_selection.py
Normal file
140
vibe/setup/onboarding/screens/theme_selection.py
Normal file
|
|
@ -0,0 +1,140 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from typing import ClassVar
|
||||
|
||||
from textual.app import ComposeResult
|
||||
from textual.binding import Binding, BindingType
|
||||
from textual.containers import Center, Container, Horizontal, Vertical
|
||||
from textual.events import Resize
|
||||
from textual.theme import BUILTIN_THEMES
|
||||
from textual.widgets import Markdown, Static
|
||||
|
||||
from vibe.core.config import VibeConfig
|
||||
from vibe.setup.onboarding.base import OnboardingScreen
|
||||
|
||||
THEMES = sorted(k for k in BUILTIN_THEMES if k != "textual-ansi")
|
||||
|
||||
VISIBLE_NEIGHBORS = 3
|
||||
FADE_CLASSES = ["fade-1", "fade-2", "fade-3"]
|
||||
|
||||
PREVIEW_MARKDOWN = """
|
||||
### Heading
|
||||
|
||||
**Bold**, *italic*, and `inline code`.
|
||||
|
||||
- Bullet point
|
||||
- Another bullet point
|
||||
|
||||
1. First item
|
||||
2. Second item
|
||||
|
||||
```python
|
||||
def greet(name: str = "World") -> str:
|
||||
return f"Hello, {name}!"
|
||||
```
|
||||
|
||||
> Blockquote
|
||||
|
||||
---
|
||||
|
||||
| Column 1 | Column 2 |
|
||||
|----------|----------|
|
||||
| Item 1 | Item 2 |
|
||||
"""
|
||||
|
||||
|
||||
class ThemeSelectionScreen(OnboardingScreen):
|
||||
BINDINGS: ClassVar[list[BindingType]] = [
|
||||
Binding("enter", "next", "Next", show=False, priority=True),
|
||||
Binding("up", "prev_theme", "Previous", show=False),
|
||||
Binding("down", "next_theme", "Next Theme", show=False),
|
||||
Binding("ctrl+c", "cancel", "Cancel", show=False),
|
||||
Binding("escape", "cancel", "Cancel", show=False),
|
||||
]
|
||||
|
||||
NEXT_SCREEN = "api_key"
|
||||
|
||||
def __init__(self) -> None:
|
||||
super().__init__()
|
||||
self._theme_index = 0
|
||||
self._theme_widgets: list[Static] = []
|
||||
|
||||
def _compose_theme_list(self) -> ComposeResult:
|
||||
for _ in range(VISIBLE_NEIGHBORS * 2 + 1):
|
||||
widget = Static("", classes="theme-item")
|
||||
self._theme_widgets.append(widget)
|
||||
yield widget
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
with Center(id="theme-outer"):
|
||||
with Vertical(id="theme-content"):
|
||||
yield Static("Select your preferred theme", id="theme-title")
|
||||
yield Center(
|
||||
Horizontal(
|
||||
Static("Navigate ↑ ↓", id="nav-hint"),
|
||||
Vertical(*self._compose_theme_list(), id="theme-list"),
|
||||
Static("Press Enter ↵", id="enter-hint"),
|
||||
id="theme-row",
|
||||
)
|
||||
)
|
||||
with Container(id="preview-center"):
|
||||
preview = Container(id="preview")
|
||||
preview.border_title = "Preview"
|
||||
with preview:
|
||||
yield Container(Markdown(PREVIEW_MARKDOWN), id="preview-inner")
|
||||
|
||||
def on_mount(self) -> None:
|
||||
current_theme = self.app.theme
|
||||
if current_theme in THEMES:
|
||||
self._theme_index = THEMES.index(current_theme)
|
||||
self._update_display()
|
||||
self._update_preview_height()
|
||||
self.focus()
|
||||
|
||||
def on_resize(self, _: Resize) -> None:
|
||||
self._update_preview_height()
|
||||
|
||||
def _update_preview_height(self) -> None:
|
||||
# Height is dynamically set because css won't allow filling available space and page scroll on overflow.
|
||||
preview = self.query_one("#preview", Container)
|
||||
header_height = 17 # title + margins + theme row + padding + buffer
|
||||
available = self.app.size.height - header_height
|
||||
preview.styles.max_height = max(10, available)
|
||||
|
||||
def _get_theme_at_offset(self, offset: int) -> str:
|
||||
index = (self._theme_index + offset) % len(THEMES)
|
||||
return THEMES[index]
|
||||
|
||||
def _update_display(self) -> None:
|
||||
for i, widget in enumerate(self._theme_widgets):
|
||||
offset = i - VISIBLE_NEIGHBORS
|
||||
theme = self._get_theme_at_offset(offset)
|
||||
|
||||
widget.remove_class("selected", *FADE_CLASSES)
|
||||
|
||||
if offset == 0:
|
||||
widget.update(f" {theme} ")
|
||||
widget.add_class("selected")
|
||||
else:
|
||||
distance = min(abs(offset) - 1, len(FADE_CLASSES) - 1)
|
||||
widget.update(theme)
|
||||
widget.add_class(FADE_CLASSES[distance])
|
||||
|
||||
def _navigate(self, direction: int) -> None:
|
||||
self._theme_index = (self._theme_index + direction) % len(THEMES)
|
||||
self.app.theme = THEMES[self._theme_index]
|
||||
self._update_display()
|
||||
|
||||
def action_next_theme(self) -> None:
|
||||
self._navigate(1)
|
||||
|
||||
def action_prev_theme(self) -> None:
|
||||
self._navigate(-1)
|
||||
|
||||
def action_next(self) -> None:
|
||||
theme = THEMES[self._theme_index]
|
||||
try:
|
||||
VibeConfig.save_updates({"textual_theme": theme})
|
||||
except OSError:
|
||||
pass
|
||||
super().action_next()
|
||||
135
vibe/setup/onboarding/screens/welcome.py
Normal file
135
vibe/setup/onboarding/screens/welcome.py
Normal file
|
|
@ -0,0 +1,135 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from typing import ClassVar
|
||||
|
||||
from textual.app import ComposeResult
|
||||
from textual.binding import Binding, BindingType
|
||||
from textual.containers import Center, Vertical
|
||||
from textual.timer import Timer
|
||||
from textual.widgets import Static
|
||||
|
||||
from vibe.setup.onboarding.base import OnboardingScreen
|
||||
|
||||
WELCOME_PREFIX = "Welcome to "
|
||||
WELCOME_HIGHLIGHT = "Mistral Vibe"
|
||||
WELCOME_SUFFIX = " - Let's get you started!"
|
||||
WELCOME_TEXT = WELCOME_PREFIX + WELCOME_HIGHLIGHT + WELCOME_SUFFIX
|
||||
|
||||
HIGHLIGHT_START = len(WELCOME_PREFIX)
|
||||
HIGHLIGHT_END = HIGHLIGHT_START + len(WELCOME_HIGHLIGHT)
|
||||
|
||||
BUTTON_TEXT = "Press Enter ↵"
|
||||
|
||||
GRADIENT_COLORS = [
|
||||
"#ff6b00",
|
||||
"#ff7b00",
|
||||
"#ff8c00",
|
||||
"#ff9d00",
|
||||
"#ffae00",
|
||||
"#ffbf00",
|
||||
"#ffae00",
|
||||
"#ff9d00",
|
||||
"#ff8c00",
|
||||
"#ff7b00",
|
||||
]
|
||||
|
||||
|
||||
def _apply_gradient(text: str, offset: int) -> str:
|
||||
result = []
|
||||
for i, char in enumerate(text):
|
||||
color = GRADIENT_COLORS[(i + offset) % len(GRADIENT_COLORS)]
|
||||
result.append(f"[bold {color}]{char}[/]")
|
||||
return "".join(result)
|
||||
|
||||
|
||||
class WelcomeScreen(OnboardingScreen):
|
||||
BINDINGS: ClassVar[list[BindingType]] = [
|
||||
Binding("enter", "next", "Next", show=False, priority=True),
|
||||
Binding("ctrl+c", "cancel", "Cancel", show=False),
|
||||
Binding("escape", "cancel", "Cancel", show=False),
|
||||
]
|
||||
|
||||
NEXT_SCREEN = "theme_selection"
|
||||
|
||||
def __init__(self) -> None:
|
||||
super().__init__()
|
||||
self._char_index = 0
|
||||
self._gradient_offset = 0
|
||||
self._typing_done = False
|
||||
self._paused = False
|
||||
self._typing_timer: Timer | None = None
|
||||
self._button_char_index = 0
|
||||
self._button_typing_timer: Timer | None = None
|
||||
self._welcome_text: Static
|
||||
self._enter_hint: Static
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
with Vertical(id="welcome-container"):
|
||||
with Center():
|
||||
yield Static("", id="welcome-text")
|
||||
with Center():
|
||||
yield Static("", id="enter-hint", classes="hidden")
|
||||
|
||||
def on_mount(self) -> None:
|
||||
self._welcome_text = self.query_one("#welcome-text", Static)
|
||||
self._enter_hint = self.query_one("#enter-hint", Static)
|
||||
self._typing_timer = self.set_interval(0.04, self._type_next_char)
|
||||
self.focus()
|
||||
|
||||
def _render_text(self, length: int) -> str:
|
||||
text = WELCOME_TEXT[:length]
|
||||
|
||||
if length <= HIGHLIGHT_START:
|
||||
return text
|
||||
|
||||
prefix = text[:HIGHLIGHT_START]
|
||||
highlight_len = min(length, HIGHLIGHT_END) - HIGHLIGHT_START
|
||||
highlight = _apply_gradient(
|
||||
WELCOME_HIGHLIGHT[:highlight_len], self._gradient_offset
|
||||
)
|
||||
|
||||
if length > HIGHLIGHT_END:
|
||||
suffix = text[HIGHLIGHT_END:]
|
||||
return prefix + highlight + suffix
|
||||
return prefix + highlight
|
||||
|
||||
def _type_next_char(self) -> None:
|
||||
if self._char_index >= len(WELCOME_TEXT):
|
||||
if not self._typing_done:
|
||||
self._typing_done = True
|
||||
self.set_timer(0.5, self._show_button)
|
||||
return
|
||||
|
||||
if self._char_index == HIGHLIGHT_END and not self._paused:
|
||||
self._paused = True
|
||||
if self._typing_timer:
|
||||
self._typing_timer.stop()
|
||||
self.set_interval(0.08, self._animate_gradient)
|
||||
self.set_timer(1.4, self._resume_typing)
|
||||
return
|
||||
|
||||
self._char_index += 1
|
||||
self._welcome_text.update(self._render_text(self._char_index))
|
||||
|
||||
def _resume_typing(self) -> None:
|
||||
self._typing_timer = self.set_interval(0.03, self._type_next_char)
|
||||
|
||||
def _show_button(self) -> None:
|
||||
self._enter_hint.remove_class("hidden")
|
||||
self._button_typing_timer = self.set_interval(0.03, self._type_button_char)
|
||||
|
||||
def _type_button_char(self) -> None:
|
||||
if self._button_char_index >= len(BUTTON_TEXT):
|
||||
if self._button_typing_timer:
|
||||
self._button_typing_timer.stop()
|
||||
return
|
||||
self._button_char_index += 1
|
||||
self._enter_hint.update(BUTTON_TEXT[: self._button_char_index])
|
||||
|
||||
def _animate_gradient(self) -> None:
|
||||
self._gradient_offset = (self._gradient_offset + 1) % len(GRADIENT_COLORS)
|
||||
self._welcome_text.update(self._render_text(self._char_index))
|
||||
|
||||
def action_next(self) -> None:
|
||||
if self._typing_done:
|
||||
super().action_next()
|
||||
Loading…
Add table
Add a link
Reference in a new issue