diff --git a/pythonlings/app.py b/pythonlings/app.py index 8023d0b..68ee35c 100644 --- a/pythonlings/app.py +++ b/pythonlings/app.py @@ -28,11 +28,16 @@ def __init__( self._force_picker = force_picker 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 pythonlings.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/pythonlings/core/state.py b/pythonlings/core/state.py index 83732a0..5a686d0 100644 --- a/pythonlings/core/state.py +++ b/pythonlings/core/state.py @@ -4,6 +4,7 @@ import json import sys +from collections.abc import Iterable from dataclasses import dataclass, field from pathlib import Path @@ -84,3 +85,12 @@ def next_pending(exercises: list[Exercise], completed: set[str]) -> str | None: if ex.name not in completed: return ex.name return None + + +def completed_count(exercise_names: Iterable[str], completed: set[str]) -> int: + """How many of `exercise_names` are completed. + + Counts only names that exist in the curriculum, so stale `completed` + entries (e.g. exercises renamed or removed) cannot inflate the total. + """ + return sum(1 for name in exercise_names if name in completed) diff --git a/pythonlings/pythonlings.tcss b/pythonlings/pythonlings.tcss index 47d6091..c431d7b 100644 --- a/pythonlings/pythonlings.tcss +++ b/pythonlings/pythonlings.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/pythonlings/screens/track.py b/pythonlings/screens/track.py index bb0332f..fc303c1 100644 --- a/pythonlings/screens/track.py +++ b/pythonlings/screens/track.py @@ -10,7 +10,7 @@ from pythonlings.core.exercise import Exercise, RunResult from pythonlings.core.runner import run as run_exercise -from pythonlings.core.state import next_pending, save as save_state +from pythonlings.core.state import completed_count, next_pending, save as save_state from pythonlings.widgets.editor_pane import EditorPane from pythonlings.widgets.exercise_tree import ExerciseTree from pythonlings.widgets.output_panel import OutputPanel @@ -19,6 +19,17 @@ _DEBOUNCE_SECONDS = 0.6 +def celebration_message(total: int) -> str: + """Message shown when every exercise in the curriculum is complete.""" + return ( + f"🎉 You finished all {total} pythonlings exercises! 🎉\n\n" + "That's the whole curriculum — nicely done.\n" + f"Share it: \"I just completed all {total} pythonlings Python exercises 🎉\"\n" + "If pythonlings helped, a ⭐ on GitHub or a contribution is hugely appreciated.\n\n" + "Press Ctrl+Q to quit, or F4 to revisit topics." + ) + + class TrackScreen(Screen[None]): """One topic's linear track: editor + output + auto-save loop.""" @@ -82,7 +93,13 @@ def _initial_exercise(self) -> str | None: def _render_state(self) -> None: exs = self._exercises() done = sum(1 for ex in exs if ex.name in self.app.state.completed) - self.query_one(ProgressBar).update_progress(done, len(exs)) + all_exercises = self.app.manifest.exercises + overall_done = completed_count( + (ex.name for ex in all_exercises), self.app.state.completed + ) + self.query_one(ProgressBar).update_progress( + done, len(exs), overall_done, len(all_exercises) + ) self.query_one(ExerciseTree).render_topic( self.topic, exs, self.app.state.completed, self.current ) @@ -175,9 +192,15 @@ def _apply_result(self, exercise: Exercise, result: RunResult) -> None: self._render_state() if self.current is None: self._record_resume(None) - self.query_one(OutputPanel).show_final( - f"Topic '{self.topic}' complete — press F4 for topics." - ) + all_exercises = self.app.manifest.exercises + if next_pending(all_exercises, self.app.state.completed) is None: + self.query_one(OutputPanel).show_final( + celebration_message(len(all_exercises)) + ) + else: + self.query_one(OutputPanel).show_final( + f"Topic '{self.topic}' complete — press F4 for topics." + ) return self._load_current() self._run_current() diff --git a/pythonlings/screens/welcome.py b/pythonlings/screens/welcome.py new file mode 100644 index 0000000..f043285 --- /dev/null +++ b/pythonlings/screens/welcome.py @@ -0,0 +1,45 @@ +# pythonlings/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 pythonlings.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. Edit the current exercise in the built-in editor.\n" + " 2. Save -- the check reruns automatically as you type.\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 pythonlings[/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/pythonlings/widgets/progress.py b/pythonlings/widgets/progress.py index 0e6bac5..2bdff91 100644 --- a/pythonlings/widgets/progress.py +++ b/pythonlings/widgets/progress.py @@ -3,13 +3,37 @@ from textual.widgets import Static +_BAR_WIDTH = 20 + + +def _bar(completed: int, total: int) -> tuple[str, float]: + pct = (completed / total * 100) if total else 0 + filled = int(_BAR_WIDTH * pct / 100) + return "█" * filled + "░" * (_BAR_WIDTH - filled), pct + + +def format_progress( + completed: int, total: int, overall_completed: int, overall_total: int +) -> str: + """Render a one-line display of topic progress and overall progress.""" + topic_bar, topic_pct = _bar(completed, total) + overall_bar, overall_pct = _bar(overall_completed, overall_total) + return ( + f"Topic: {topic_bar} {completed}/{total} ({topic_pct:.0f}%)" + f" Overall: {overall_bar} {overall_completed}/{overall_total} ({overall_pct:.0f}%)" + ) + class ProgressBar(Static): DEFAULT_CSS = "" - def update_progress(self, completed: int, total: int) -> None: - pct = (completed / total * 100) if total else 0 - bar_width = 20 - filled = int(bar_width * pct / 100) - bar = "█" * filled + "░" * (bar_width - filled) - self.update(f"Progress: {bar} {completed}/{total} ({pct:.0f}%)") + def update_progress( + self, + completed: int, + total: int, + overall_completed: int, + overall_total: int, + ) -> None: + self.update( + format_progress(completed, total, overall_completed, overall_total) + ) diff --git a/tests/tui/test_app_pilot.py b/tests/tui/test_app_pilot.py index 3452193..fdeec06 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(".pythonlings")) + # These tests exercise the returning-user flow, so start past the + # first-launch welcome overlay (covered 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 = PythonlingsApp(root=_work_copy(tmp_path), force_picker=True) + # Needs genuine first-run state (no seen_intro seed) for the banner. + work = tmp_path / "work" + shutil.copytree(MULTI, work, ignore=shutil.ignore_patterns(".pythonlings")) + app = PythonlingsApp(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..c98d5bb --- /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 pythonlings.app import PythonlingsApp +from pythonlings.core.state import State, load as load_state, save as save_state +from pythonlings.screens.track import TrackScreen +from pythonlings.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(".pythonlings")) + 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 = PythonlingsApp(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 = PythonlingsApp(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 = PythonlingsApp(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_celebration.py b/tests/unit/test_celebration.py new file mode 100644 index 0000000..9a0bcc6 --- /dev/null +++ b/tests/unit/test_celebration.py @@ -0,0 +1,10 @@ +from __future__ import annotations + +from pythonlings.screens.track import celebration_message + + +def test_celebration_message_includes_count_and_is_celebratory() -> None: + msg = celebration_message(292) + + assert "292" in msg + assert "🎉" in msg diff --git a/tests/unit/test_progress.py b/tests/unit/test_progress.py new file mode 100644 index 0000000..adbbccd --- /dev/null +++ b/tests/unit/test_progress.py @@ -0,0 +1,30 @@ +from __future__ import annotations + +from pythonlings.core.state import completed_count +from pythonlings.widgets.progress import format_progress + + +def test_format_progress_shows_topic_and_overall() -> None: + line = format_progress(2, 5, 10, 100) + + assert "2/5" in line + assert "40%" in line # topic 2/5 + assert "10/100" in line + assert "10%" in line # overall 10/100 + assert "Overall" in line + + +def test_format_progress_handles_zero_totals() -> None: + line = format_progress(0, 0, 0, 0) + + assert "0/0" in line + assert "0%" in line + + +def test_completed_count_ignores_names_not_in_curriculum() -> None: + # Stale state (renamed/removed exercises) must not inflate the count. + assert completed_count(["a", "b", "c"], {"a", "b", "ghost"}) == 2 + + +def test_completed_count_empty() -> None: + assert completed_count([], set()) == 0 diff --git a/tests/unit/test_welcome.py b/tests/unit/test_welcome.py new file mode 100644 index 0000000..2786488 --- /dev/null +++ b/tests/unit/test_welcome.py @@ -0,0 +1,11 @@ +from __future__ import annotations + +from pythonlings.screens.welcome import welcome_text + + +def test_welcome_text_explains_the_loop() -> None: + text = welcome_text() + + assert "I AM NOT DONE" in text + assert "save" in text.lower() + assert "F1" in text