diff --git a/pylings/app.py b/pylings/app.py index 2895cf1..f766aff 100644 --- a/pylings/app.py +++ b/pylings/app.py @@ -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: diff --git a/pylings/pylings.tcss b/pylings/pylings.tcss index 47d6091..c431d7b 100644 --- a/pylings/pylings.tcss +++ b/pylings/pylings.tcss @@ -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; +} diff --git a/pylings/screens/welcome.py b/pylings/screens/welcome.py new file mode 100644 index 0000000..992be50 --- /dev/null +++ b/pylings/screens/welcome.py @@ -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() diff --git a/tests/tui/test_app_pilot.py b/tests/tui/test_app_pilot.py index f213cab..21df184 100644 --- a/tests/tui/test_app_pilot.py +++ b/tests/tui/test_app_pilot.py @@ -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 @@ -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) diff --git a/tests/tui/test_welcome_pilot.py b/tests/tui/test_welcome_pilot.py new file mode 100644 index 0000000..14c9546 --- /dev/null +++ b/tests/tui/test_welcome_pilot.py @@ -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 diff --git a/tests/unit/test_welcome.py b/tests/unit/test_welcome.py new file mode 100644 index 0000000..ba9e74d --- /dev/null +++ b/tests/unit/test_welcome.py @@ -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