Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions pylings/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,11 +30,16 @@ def __init__(
self.watch_files = watch_files

def on_mount(self) -> None:
first_launch = not self.state.seen_intro
self.push_screen(TopicPickerScreen())
target = self._startup_target()
if target is not None:
topic, exercise = target
self.push_screen(TrackScreen(topic, start_exercise=exercise))
if first_launch and target is not None:
from pylings.screens.welcome import WelcomeScreen

self.push_screen(WelcomeScreen())

def _startup_target(self) -> tuple[str, str | None] | None:
if self._start_topic is not None:
Expand Down
27 changes: 27 additions & 0 deletions pylings/pylings.tcss
Original file line number Diff line number Diff line change
Expand Up @@ -121,3 +121,30 @@ DocsScreen {
height: 1;
color: $text;
}

WelcomeScreen {
align: center middle;
}

#welcome-window {
width: 72%;
max-width: 76;
height: auto;
padding: 1 2;
border: heavy $primary;
background: $surface;
color: $text;
}

#welcome-title {
height: 1;
}

#welcome-body {
margin: 1 0;
}

#welcome-footer {
height: 1;
color: $text-muted;
}
45 changes: 45 additions & 0 deletions pylings/screens/welcome.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
# pylings/screens/welcome.py
from __future__ import annotations

from textual.app import ComposeResult
from textual.binding import Binding
from textual.containers import Vertical
from textual.screen import ModalScreen
from textual.widgets import Static

from pylings.core.state import save as save_state


def welcome_text() -> str:
"""The first-launch explainer for the edit/save/advance loop."""
return (
"You learn Python here by fixing small broken programs. The loop is:\n\n"
" 1. Open the current exercise file in your own editor.\n"
" 2. Fix the code and save -- the check reruns automatically.\n"
" 3. Remove the `# I AM NOT DONE` marker to advance to the next one.\n\n"
"Handy keys: F1 hint - F3 exercise list - F4 topics - "
"F5 local docs - Ctrl+Q quit.\n\n"
"Press Enter to start."
)


class WelcomeScreen(ModalScreen[None]):
"""First-launch onboarding overlay explaining the core loop."""

BINDINGS = [
Binding("enter", "start", "Start", priority=True),
Binding("escape", "start", "Start"),
]

def compose(self) -> ComposeResult:
yield Vertical(
Static("[bold]Welcome to pylings[/bold]", id="welcome-title"),
Static(welcome_text(), id="welcome-body"),
Static("Enter Start", id="welcome-footer"),
id="welcome-window",
)

def action_start(self) -> None:
self.app.state.seen_intro = True
save_state(self.app.root, self.app.state)
self.dismiss()
8 changes: 7 additions & 1 deletion tests/tui/test_app_pilot.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,9 @@
def _work_copy(tmp_path: Path) -> Path:
work = tmp_path / "work"
shutil.copytree(MULTI, work, ignore=shutil.ignore_patterns(".pylings"))
# These tests exercise the returning-user flow, so start past the
# first-launch welcome overlay (covered separately in test_welcome_pilot.py).
save_state(work, State(seen_intro=True))
return work


Expand Down Expand Up @@ -69,7 +72,10 @@ async def test_enter_on_picker_opens_selected_topic(tmp_path: Path) -> None:

@pytest.mark.asyncio
async def test_picker_shows_first_run_start_banner(tmp_path: Path) -> None:
app = PylingsApp(root=_work_copy(tmp_path), force_picker=True)
# This one needs genuine first-run state (no seen_intro seed) for the banner.
work = tmp_path / "work"
shutil.copytree(MULTI, work, ignore=shutil.ignore_patterns(".pylings"))
app = PylingsApp(root=work, force_picker=True)
async with app.run_test() as pilot:
await _settle(pilot)
banner = str(app.screen.query_one("#topic-banner").content)
Expand Down
62 changes: 62 additions & 0 deletions tests/tui/test_welcome_pilot.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import shutil
from pathlib import Path

import pytest
from textual.worker import WorkerCancelled

from pylings.app import PylingsApp
from pylings.core.state import State, load as load_state, save as save_state
from pylings.screens.track import TrackScreen
from pylings.screens.welcome import WelcomeScreen

MULTI = Path(__file__).parent.parent / "fixtures" / "multi_topic"


def _work_copy(tmp_path: Path) -> Path:
work = tmp_path / "work"
shutil.copytree(MULTI, work, ignore=shutil.ignore_patterns(".pylings"))
return work


async def _settle(pilot) -> None:
for _ in range(6):
try:
await pilot.app.workers.wait_for_complete()
except WorkerCancelled:
pass
await pilot.pause()


@pytest.mark.asyncio
async def test_welcome_shown_on_first_launch(tmp_path: Path) -> None:
app = PylingsApp(root=_work_copy(tmp_path))
async with app.run_test() as pilot:
await _settle(pilot)
assert isinstance(app.screen, WelcomeScreen)


@pytest.mark.asyncio
async def test_welcome_not_shown_after_intro_seen(tmp_path: Path) -> None:
work = _work_copy(tmp_path)
save_state(work, State(seen_intro=True))

app = PylingsApp(root=work)
async with app.run_test() as pilot:
await _settle(pilot)
assert not isinstance(app.screen, WelcomeScreen)
assert isinstance(app.screen, TrackScreen)


@pytest.mark.asyncio
async def test_dismissing_welcome_persists_seen_intro(tmp_path: Path) -> None:
work = _work_copy(tmp_path)

app = PylingsApp(root=work)
async with app.run_test() as pilot:
await _settle(pilot)
assert isinstance(app.screen, WelcomeScreen)
await pilot.press("enter")
await _settle(pilot)
assert not isinstance(app.screen, WelcomeScreen)

assert load_state(work).seen_intro is True
13 changes: 13 additions & 0 deletions tests/unit/test_welcome.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
from __future__ import annotations

from pylings.screens.welcome import welcome_text


def test_welcome_text_explains_the_loop() -> None:
text = welcome_text()

# the core loop a beginner needs to understand
assert "I AM NOT DONE" in text
assert "save" in text.lower()
# mentions at least one key shortcut
assert "F1" in text
Loading