Skip to content

Commit 29e29bb

Browse files
authored
Merge pull request #31 from abhiksark/feature/tui-progress-and-welcome
feat: overall progress, completion celebration, first-launch welcome (#14, #15, #16)
2 parents 23fff91 + 99b581d commit 29e29bb

11 files changed

Lines changed: 265 additions & 12 deletions

File tree

pythonlings/app.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,11 +28,16 @@ def __init__(
2828
self._force_picker = force_picker
2929

3030
def on_mount(self) -> None:
31+
first_launch = not self.state.seen_intro
3132
self.push_screen(TopicPickerScreen())
3233
target = self._startup_target()
3334
if target is not None:
3435
topic, exercise = target
3536
self.push_screen(TrackScreen(topic, start_exercise=exercise))
37+
if first_launch and target is not None:
38+
from pythonlings.screens.welcome import WelcomeScreen
39+
40+
self.push_screen(WelcomeScreen())
3641

3742
def _startup_target(self) -> tuple[str, str | None] | None:
3843
if self._start_topic is not None:

pythonlings/core/state.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
import json
66
import sys
7+
from collections.abc import Iterable
78
from dataclasses import dataclass, field
89
from pathlib import Path
910

@@ -84,3 +85,12 @@ def next_pending(exercises: list[Exercise], completed: set[str]) -> str | None:
8485
if ex.name not in completed:
8586
return ex.name
8687
return None
88+
89+
90+
def completed_count(exercise_names: Iterable[str], completed: set[str]) -> int:
91+
"""How many of `exercise_names` are completed.
92+
93+
Counts only names that exist in the curriculum, so stale `completed`
94+
entries (e.g. exercises renamed or removed) cannot inflate the total.
95+
"""
96+
return sum(1 for name in exercise_names if name in completed)

pythonlings/pythonlings.tcss

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -121,3 +121,30 @@ DocsScreen {
121121
height: 1;
122122
color: $text;
123123
}
124+
125+
WelcomeScreen {
126+
align: center middle;
127+
}
128+
129+
#welcome-window {
130+
width: 72%;
131+
max-width: 76;
132+
height: auto;
133+
padding: 1 2;
134+
border: heavy $primary;
135+
background: $surface;
136+
color: $text;
137+
}
138+
139+
#welcome-title {
140+
height: 1;
141+
}
142+
143+
#welcome-body {
144+
margin: 1 0;
145+
}
146+
147+
#welcome-footer {
148+
height: 1;
149+
color: $text-muted;
150+
}

pythonlings/screens/track.py

Lines changed: 28 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010

1111
from pythonlings.core.exercise import Exercise, RunResult
1212
from pythonlings.core.runner import run as run_exercise
13-
from pythonlings.core.state import next_pending, save as save_state
13+
from pythonlings.core.state import completed_count, next_pending, save as save_state
1414
from pythonlings.widgets.editor_pane import EditorPane
1515
from pythonlings.widgets.exercise_tree import ExerciseTree
1616
from pythonlings.widgets.output_panel import OutputPanel
@@ -19,6 +19,17 @@
1919
_DEBOUNCE_SECONDS = 0.6
2020

2121

22+
def celebration_message(total: int) -> str:
23+
"""Message shown when every exercise in the curriculum is complete."""
24+
return (
25+
f"🎉 You finished all {total} pythonlings exercises! 🎉\n\n"
26+
"That's the whole curriculum — nicely done.\n"
27+
f"Share it: \"I just completed all {total} pythonlings Python exercises 🎉\"\n"
28+
"If pythonlings helped, a ⭐ on GitHub or a contribution is hugely appreciated.\n\n"
29+
"Press Ctrl+Q to quit, or F4 to revisit topics."
30+
)
31+
32+
2233
class TrackScreen(Screen[None]):
2334
"""One topic's linear track: editor + output + auto-save loop."""
2435

@@ -82,7 +93,13 @@ def _initial_exercise(self) -> str | None:
8293
def _render_state(self) -> None:
8394
exs = self._exercises()
8495
done = sum(1 for ex in exs if ex.name in self.app.state.completed)
85-
self.query_one(ProgressBar).update_progress(done, len(exs))
96+
all_exercises = self.app.manifest.exercises
97+
overall_done = completed_count(
98+
(ex.name for ex in all_exercises), self.app.state.completed
99+
)
100+
self.query_one(ProgressBar).update_progress(
101+
done, len(exs), overall_done, len(all_exercises)
102+
)
86103
self.query_one(ExerciseTree).render_topic(
87104
self.topic, exs, self.app.state.completed, self.current
88105
)
@@ -175,9 +192,15 @@ def _apply_result(self, exercise: Exercise, result: RunResult) -> None:
175192
self._render_state()
176193
if self.current is None:
177194
self._record_resume(None)
178-
self.query_one(OutputPanel).show_final(
179-
f"Topic '{self.topic}' complete — press F4 for topics."
180-
)
195+
all_exercises = self.app.manifest.exercises
196+
if next_pending(all_exercises, self.app.state.completed) is None:
197+
self.query_one(OutputPanel).show_final(
198+
celebration_message(len(all_exercises))
199+
)
200+
else:
201+
self.query_one(OutputPanel).show_final(
202+
f"Topic '{self.topic}' complete — press F4 for topics."
203+
)
181204
return
182205
self._load_current()
183206
self._run_current()

pythonlings/screens/welcome.py

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
# pythonlings/screens/welcome.py
2+
from __future__ import annotations
3+
4+
from textual.app import ComposeResult
5+
from textual.binding import Binding
6+
from textual.containers import Vertical
7+
from textual.screen import ModalScreen
8+
from textual.widgets import Static
9+
10+
from pythonlings.core.state import save as save_state
11+
12+
13+
def welcome_text() -> str:
14+
"""The first-launch explainer for the edit/save/advance loop."""
15+
return (
16+
"You learn Python here by fixing small broken programs. The loop is:\n\n"
17+
" 1. Edit the current exercise in the built-in editor.\n"
18+
" 2. Save -- the check reruns automatically as you type.\n"
19+
" 3. Remove the `# I AM NOT DONE` marker to advance to the next one.\n\n"
20+
"Handy keys: F1 hint - F3 exercise list - F4 topics - "
21+
"F5 local docs - Ctrl+Q quit.\n\n"
22+
"Press Enter to start."
23+
)
24+
25+
26+
class WelcomeScreen(ModalScreen[None]):
27+
"""First-launch onboarding overlay explaining the core loop."""
28+
29+
BINDINGS = [
30+
Binding("enter", "start", "Start", priority=True),
31+
Binding("escape", "start", "Start"),
32+
]
33+
34+
def compose(self) -> ComposeResult:
35+
yield Vertical(
36+
Static("[bold]Welcome to pythonlings[/bold]", id="welcome-title"),
37+
Static(welcome_text(), id="welcome-body"),
38+
Static("Enter Start", id="welcome-footer"),
39+
id="welcome-window",
40+
)
41+
42+
def action_start(self) -> None:
43+
self.app.state.seen_intro = True
44+
save_state(self.app.root, self.app.state)
45+
self.dismiss()

pythonlings/widgets/progress.py

Lines changed: 30 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,37 @@
33

44
from textual.widgets import Static
55

6+
_BAR_WIDTH = 20
7+
8+
9+
def _bar(completed: int, total: int) -> tuple[str, float]:
10+
pct = (completed / total * 100) if total else 0
11+
filled = int(_BAR_WIDTH * pct / 100)
12+
return "█" * filled + "░" * (_BAR_WIDTH - filled), pct
13+
14+
15+
def format_progress(
16+
completed: int, total: int, overall_completed: int, overall_total: int
17+
) -> str:
18+
"""Render a one-line display of topic progress and overall progress."""
19+
topic_bar, topic_pct = _bar(completed, total)
20+
overall_bar, overall_pct = _bar(overall_completed, overall_total)
21+
return (
22+
f"Topic: {topic_bar} {completed}/{total} ({topic_pct:.0f}%)"
23+
f" Overall: {overall_bar} {overall_completed}/{overall_total} ({overall_pct:.0f}%)"
24+
)
25+
626

727
class ProgressBar(Static):
828
DEFAULT_CSS = ""
929

10-
def update_progress(self, completed: int, total: int) -> None:
11-
pct = (completed / total * 100) if total else 0
12-
bar_width = 20
13-
filled = int(bar_width * pct / 100)
14-
bar = "█" * filled + "░" * (bar_width - filled)
15-
self.update(f"Progress: {bar} {completed}/{total} ({pct:.0f}%)")
30+
def update_progress(
31+
self,
32+
completed: int,
33+
total: int,
34+
overall_completed: int,
35+
overall_total: int,
36+
) -> None:
37+
self.update(
38+
format_progress(completed, total, overall_completed, overall_total)
39+
)

tests/tui/test_app_pilot.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,9 @@
2020
def _work_copy(tmp_path: Path) -> Path:
2121
work = tmp_path / "work"
2222
shutil.copytree(MULTI, work, ignore=shutil.ignore_patterns(".pythonlings"))
23+
# These tests exercise the returning-user flow, so start past the
24+
# first-launch welcome overlay (covered in test_welcome_pilot.py).
25+
save_state(work, State(seen_intro=True))
2326
return work
2427

2528

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

7073
@pytest.mark.asyncio
7174
async def test_picker_shows_first_run_start_banner(tmp_path: Path) -> None:
72-
app = PythonlingsApp(root=_work_copy(tmp_path), force_picker=True)
75+
# Needs genuine first-run state (no seen_intro seed) for the banner.
76+
work = tmp_path / "work"
77+
shutil.copytree(MULTI, work, ignore=shutil.ignore_patterns(".pythonlings"))
78+
app = PythonlingsApp(root=work, force_picker=True)
7379
async with app.run_test() as pilot:
7480
await _settle(pilot)
7581
banner = str(app.screen.query_one("#topic-banner").content)

tests/tui/test_welcome_pilot.py

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
import shutil
2+
from pathlib import Path
3+
4+
import pytest
5+
from textual.worker import WorkerCancelled
6+
7+
from pythonlings.app import PythonlingsApp
8+
from pythonlings.core.state import State, load as load_state, save as save_state
9+
from pythonlings.screens.track import TrackScreen
10+
from pythonlings.screens.welcome import WelcomeScreen
11+
12+
MULTI = Path(__file__).parent.parent / "fixtures" / "multi_topic"
13+
14+
15+
def _work_copy(tmp_path: Path) -> Path:
16+
work = tmp_path / "work"
17+
shutil.copytree(MULTI, work, ignore=shutil.ignore_patterns(".pythonlings"))
18+
return work
19+
20+
21+
async def _settle(pilot) -> None:
22+
for _ in range(6):
23+
try:
24+
await pilot.app.workers.wait_for_complete()
25+
except WorkerCancelled:
26+
pass
27+
await pilot.pause()
28+
29+
30+
@pytest.mark.asyncio
31+
async def test_welcome_shown_on_first_launch(tmp_path: Path) -> None:
32+
app = PythonlingsApp(root=_work_copy(tmp_path))
33+
async with app.run_test() as pilot:
34+
await _settle(pilot)
35+
assert isinstance(app.screen, WelcomeScreen)
36+
37+
38+
@pytest.mark.asyncio
39+
async def test_welcome_not_shown_after_intro_seen(tmp_path: Path) -> None:
40+
work = _work_copy(tmp_path)
41+
save_state(work, State(seen_intro=True))
42+
43+
app = PythonlingsApp(root=work)
44+
async with app.run_test() as pilot:
45+
await _settle(pilot)
46+
assert not isinstance(app.screen, WelcomeScreen)
47+
assert isinstance(app.screen, TrackScreen)
48+
49+
50+
@pytest.mark.asyncio
51+
async def test_dismissing_welcome_persists_seen_intro(tmp_path: Path) -> None:
52+
work = _work_copy(tmp_path)
53+
54+
app = PythonlingsApp(root=work)
55+
async with app.run_test() as pilot:
56+
await _settle(pilot)
57+
assert isinstance(app.screen, WelcomeScreen)
58+
await pilot.press("enter")
59+
await _settle(pilot)
60+
assert not isinstance(app.screen, WelcomeScreen)
61+
62+
assert load_state(work).seen_intro is True

tests/unit/test_celebration.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
from __future__ import annotations
2+
3+
from pythonlings.screens.track import celebration_message
4+
5+
6+
def test_celebration_message_includes_count_and_is_celebratory() -> None:
7+
msg = celebration_message(292)
8+
9+
assert "292" in msg
10+
assert "🎉" in msg

tests/unit/test_progress.py

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
from __future__ import annotations
2+
3+
from pythonlings.core.state import completed_count
4+
from pythonlings.widgets.progress import format_progress
5+
6+
7+
def test_format_progress_shows_topic_and_overall() -> None:
8+
line = format_progress(2, 5, 10, 100)
9+
10+
assert "2/5" in line
11+
assert "40%" in line # topic 2/5
12+
assert "10/100" in line
13+
assert "10%" in line # overall 10/100
14+
assert "Overall" in line
15+
16+
17+
def test_format_progress_handles_zero_totals() -> None:
18+
line = format_progress(0, 0, 0, 0)
19+
20+
assert "0/0" in line
21+
assert "0%" in line
22+
23+
24+
def test_completed_count_ignores_names_not_in_curriculum() -> None:
25+
# Stale state (renamed/removed exercises) must not inflate the count.
26+
assert completed_count(["a", "b", "c"], {"a", "b", "ghost"}) == 2
27+
28+
29+
def test_completed_count_empty() -> None:
30+
assert completed_count([], set()) == 0

0 commit comments

Comments
 (0)