Skip to content
Merged
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
36 changes: 36 additions & 0 deletions src/typehero/content/i18n/en.yaml
Original file line number Diff line number Diff line change
@@ -1,5 +1,41 @@
menu.locked: "locked"
menu.completed: "done"
menu.tagline: "typehero v{version} · {cleared}/{total} lessons cleared"
footer.benchmark: "Benchmark"
footer.progress: "Progress"
footer.achievements: "Achievements"
footer.settings: "Settings"
footer.quit: "Quit"
footer.back: "Back"
footer.continue: "Continue"
footer.abandon: "Abandon"
footer.palette: "palette"
results.passed: "Lesson passed!"
results.failed: "Not yet — try again"
results.wpm: "WPM"
results.accuracy: "Accuracy"
results.errors: "Errors"
results.xp_earned: "XP earned"
results.level: "Level"
results.level_up: "up!"
results.streak: "Streak"
results.unlocked: "Unlocked"
baseline.yes: "Yes"
baseline.skip: "Skip"
palette.search: "Search for commands…"
palette.theme: "Theme"
palette.theme.help: "Change the current theme"
palette.quit: "Quit"
palette.quit.help: "Quit the application as soon as possible"
palette.keys: "Keys"
palette.keys_show.help: "Show help for the focused widget and a summary of available keys"
palette.keys_hide.help: "Hide the keys and widget help panel"
palette.maximize: "Maximize"
palette.maximize.help: "Maximize the focused widget"
palette.minimize: "Minimize"
palette.minimize.help: "Minimize the widget and restore to normal size"
palette.screenshot: "Screenshot"
palette.screenshot.help: "Save an SVG 'screenshot' of the current screen"
benchmark.intro: "Benchmark — this does not give XP and cannot be failed"
benchmark.baseline_prompt: "Take a baseline benchmark first? It measures your starting point. (y / s to skip)"
language.en: "English"
Expand Down
36 changes: 36 additions & 0 deletions src/typehero/content/i18n/ru.yaml
Original file line number Diff line number Diff line change
@@ -1,5 +1,41 @@
menu.locked: "закрыт"
menu.completed: "пройден"
menu.tagline: "typehero v{version} · пройдено уроков: {cleared}/{total}"
footer.benchmark: "Бенчмарк"
footer.progress: "Прогресс"
footer.achievements: "Достижения"
footer.settings: "Настройки"
footer.quit: "Выход"
footer.back: "Назад"
footer.continue: "Продолжить"
footer.abandon: "Прервать"
footer.palette: "палитра"
results.passed: "Урок пройден!"
results.failed: "Пока нет — попробуйте снова"
results.wpm: "сл/мин"
results.accuracy: "Точность"
results.errors: "Ошибки"
results.xp_earned: "Получено XP"
results.level: "Уровень"
results.level_up: "новый!"
results.streak: "Серия"
results.unlocked: "Открыто"
baseline.yes: "Да"
baseline.skip: "Пропустить"
palette.search: "Поиск команд…"
palette.theme: "Тема"
palette.theme.help: "Сменить текущую тему"
palette.quit: "Выход"
palette.quit.help: "Выйти из приложения как можно скорее"
palette.keys: "Клавиши"
palette.keys_show.help: "Показать справку по активному виджету и список горячих клавиш"
palette.keys_hide.help: "Скрыть панель справки по клавишам и виджету"
palette.maximize: "Развернуть"
palette.maximize.help: "Развернуть активный виджет"
palette.minimize: "Свернуть"
palette.minimize.help: "Свернуть виджет до обычного размера"
palette.screenshot: "Скриншот"
palette.screenshot.help: "Сохранить SVG-скриншот текущего экрана"
benchmark.intro: "Бенчмарк — не даёт XP и не может быть провален"
benchmark.baseline_prompt: "Сначала снять базовый замер? Он зафиксирует стартовую точку. (y — да / s — пропустить)"
language.en: "English"
Expand Down
57 changes: 55 additions & 2 deletions src/typehero/tui/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,17 +5,41 @@
import random
import sys
import time
from collections.abc import Callable
from collections.abc import Callable, Iterable
from datetime import date
from pathlib import Path

from textual.app import App
from textual.app import App, SystemCommand
from textual.command import CommandPalette
from textual.screen import Screen

from typehero.content_loader import ContentError
from typehero.paths import content_dir, profile_path
from typehero.tui.screens.menu import MenuScreen
from typehero.tui.state import AppState, load_app_state

_SYSTEM_COMMAND_KEYS: dict[str, tuple[str, str]] = {
"Change the current theme": ("palette.theme", "palette.theme.help"),
"Quit the application as soon as possible": ("palette.quit", "palette.quit.help"),
"Show help for the focused widget and a summary of available keys": (
"palette.keys",
"palette.keys_show.help",
),
"Hide the keys and widget help panel": ("palette.keys", "palette.keys_hide.help"),
"Maximize the focused widget": ("palette.maximize", "palette.maximize.help"),
"Minimize the widget and restore to normal size": ("palette.minimize", "palette.minimize.help"),
"Save an SVG 'screenshot' of the current screen": (
"palette.screenshot",
"palette.screenshot.help",
),
}
"""Maps a built-in system command's English help text (unique per command) to the
i18n keys for its translated title and help, so the command palette localizes
without re-implementing Textual's stateful callbacks. The "Keys" command appears
in two mutually-exclusive states (show vs hide help panel), so both share the
`palette.keys` title but differ in help. The keys are verbatim copies of
Textual's strings; `tests/tui/test_app.py` guards them against drift on upgrade."""


class TypeHeroApp(App):
"""Root app. Holds the loaded `AppState` and starts on the menu."""
Expand All @@ -31,6 +55,35 @@ def on_mount(self) -> None:
for notice in self.state.startup_notices:
self.notify(notice, severity="warning")

def get_system_commands(self, screen: Screen) -> Iterable[SystemCommand]:
"""Yield Textual's built-in palette commands with localized title/help.

Each command's callback is preserved; only its display strings are swapped
for the active UI locale. Unmapped commands pass through unchanged."""
translator = self.state.translator
locale = self.state.progress.ui_locale
for command in super().get_system_commands(screen):
keys = _SYSTEM_COMMAND_KEYS.get(command.help)
if keys is None:
yield command
continue
title_key, help_key = keys
yield command._replace(
title=translator.t(title_key, locale),
help=translator.t(help_key, locale),
)

def action_command_palette(self) -> None:
"""Open the command palette with a localized search placeholder.

Mirrors `App.action_command_palette` as of Textual 8.x; only the
placeholder differs. Textual exposes no cleaner injection point for it,
so the guard (`use_command_palette`, `CommandPalette.is_open`) and the
`--command-palette` id must track the upstream method on a version bump."""
if self.use_command_palette and not CommandPalette.is_open(self):
placeholder = self.state.translator.t("palette.search", self.state.progress.ui_locale)
self.push_screen(CommandPalette(placeholder=placeholder, id="--command-palette"))


def build_app(
content_root: Path | None = None,
Expand Down
48 changes: 48 additions & 0 deletions src/typehero/tui/screens/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,33 @@

from __future__ import annotations

from dataclasses import replace
from typing import TYPE_CHECKING, cast

from textual.binding import ActiveBinding
from textual.screen import Screen

if TYPE_CHECKING:
from typehero.tui.app import TypeHeroApp
from typehero.tui.state import AppState

_FOOTER_I18N: dict[str, str] = {
"Benchmark": "footer.benchmark",
"Progress": "footer.progress",
"Achievements": "footer.achievements",
"Settings": "footer.settings",
"Quit": "footer.quit",
"Back": "footer.back",
"Continue": "footer.continue",
"Abandon": "footer.abandon",
"palette": "footer.palette",
}
"""Maps a binding's English description (matching its en.yaml value) to the i18n
key used to translate the footer label for the current UI locale. The keys come
from each screen's `BINDINGS` literals, plus `palette` from Textual's built-in
command-palette binding (`ctrl+p`), which is injected by the framework rather
than declared here. `tests/tui/test_app.py` guards this map against drift."""


class AppScreen(Screen):
"""A `Screen` that exposes the app's `AppState` without a per-call cast."""
Expand All @@ -19,6 +38,35 @@ def app_state(self) -> AppState:
"""The running app's shared state."""
return cast("TypeHeroApp", self.app).state

def t(self, key: str) -> str:
"""Translate a UI string `key` for the active UI locale.

Binds the screen's translator to `progress.ui_locale` so call sites do
not repeat the translator/locale lookup. View-models that must stay
testable without a running app take an injected translator instead.
"""
return self.app_state.translator.t(key, self.app_state.progress.ui_locale)

@property
def active_bindings(self) -> dict[str, ActiveBinding]:
"""Footer bindings with descriptions translated to the current UI locale.

`BINDINGS` descriptions are static class literals fixed at import time, so
the footer would otherwise always render English. The `Footer` reads this
property on every (re)compose, so translating here keeps the labels in
sync with the active locale without mutating the static bindings. Unmapped
descriptions pass through unchanged."""
bindings = super().active_bindings
translated: dict[str, ActiveBinding] = {}
for key, active in bindings.items():
i18n_key = _FOOTER_I18N.get(active.binding.description)
if i18n_key is None:
translated[key] = active
continue
binding = replace(active.binding, description=self.t(i18n_key))
translated[key] = active._replace(binding=binding)
return translated

def save_profile(self) -> bool:
"""Persist the profile, surfacing a write failure as a toast.

Expand Down
8 changes: 5 additions & 3 deletions src/typehero/tui/screens/baseline_prompt.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,15 +13,17 @@ class BaselinePrompt(ModalScreen[bool]):

BINDINGS = [("y", "take", "Yes"), ("s", "skip", "Skip")]

def __init__(self, message: str) -> None:
def __init__(self, message: str, yes_label: str = "Yes", skip_label: str = "Skip") -> None:
super().__init__()
self._message = message
self._yes_label = yes_label
self._skip_label = skip_label

def compose(self) -> ComposeResult:
with Vertical(id="baseline-dialog"):
yield Static(self._message)
yield Button("Yes", id="take", variant="primary")
yield Button("Skip", id="skip")
yield Button(self._yes_label, id="take", variant="primary")
yield Button(self._skip_label, id="skip")

def on_button_pressed(self, event: Button.Pressed) -> None:
self.dismiss(event.button.id == "take")
Expand Down
34 changes: 22 additions & 12 deletions src/typehero/tui/screens/menu.py
Original file line number Diff line number Diff line change
Expand Up @@ -69,18 +69,27 @@ def dashboard_tagline(self) -> str:
"""Pure view-model for the muted status line under the banner."""
rows = self.lesson_rows()
cleared = sum(1 for row in rows if row.completed)
return f"typehero v{__version__} · {cleared}/{len(rows)} lessons cleared"
template = self.t("menu.tagline")
return template.format(version=__version__, cleared=cleared, total=len(rows))

def on_screen_resume(self) -> None:
async def on_screen_resume(self) -> None:
"""Rebuild the list whenever this screen is resumed, so a lesson cleared
or a typing language switched while it was hidden is reflected: a freshly
cleared lesson shows as completed and unlocks its successor."""
cleared lesson shows as completed and unlocks its successor.

`clear`/`extend` are awaited so the index is restored against the rebuilt
rows, and the index is cleared first: re-assigning the same value is a
no-op on the reactive, which would leave no row carrying `-highlight` and
the selection invisible. A rebuild with no prior selection defaults to
row 0 so the menu always shows a highlighted lesson to act on."""
lessons = self.query_one("#lessons", ListView)
index = lessons.index
lessons.clear()
lessons.extend(self._list_items())
lessons.index = index
await lessons.clear()
await lessons.extend(self._list_items())
lessons.index = None
lessons.index = index if index is not None else 0
self.query_one("#tagline", Static).update(self.dashboard_tagline())
self.refresh_bindings()

@property
def _course(self) -> Course:
Expand All @@ -104,14 +113,13 @@ def lesson_rows(self) -> list[MenuRow]:

def _list_items(self) -> list[ListItem]:
locale = self.app_state.progress.ui_locale
translator = self.app_state.translator
items: list[ListItem] = []
for row in self.lesson_rows():
title = pick_locale(row.lesson.title, locale)
if row.completed:
suffix = translator.t("menu.completed", locale)
suffix = self.t("menu.completed")
elif not row.unlocked:
suffix = translator.t("menu.locked", locale)
suffix = self.t("menu.locked")
else:
suffix = ""
label = f"{title} ({suffix})" if suffix else title
Expand All @@ -133,10 +141,12 @@ def on_list_view_selected(self, event: ListView.Selected) -> None:
):
from typehero.tui.screens.baseline_prompt import BaselinePrompt

locale = self.app_state.progress.ui_locale
message = self.app_state.translator.t("benchmark.baseline_prompt", locale)
self.app.push_screen(
BaselinePrompt(message),
BaselinePrompt(
self.t("benchmark.baseline_prompt"),
yes_label=self.t("baseline.yes"),
skip_label=self.t("baseline.skip"),
),
lambda take: self._after_baseline_choice(bool(take), row.lesson),
)
return
Expand Down
39 changes: 26 additions & 13 deletions src/typehero/tui/screens/results.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,16 +3,17 @@
from __future__ import annotations

from textual.app import ComposeResult
from textual.screen import Screen
from textual.widgets import Footer, Header, Static

from typehero.domain.ids import CourseId
from typehero.domain.lesson import LessonOutcome
from typehero.domain.progress import BenchmarkKind
from typehero.gamification.rewards import RewardSummary
from typehero.localization import Translator
from typehero.tui.screens.base import AppScreen


class ResultsScreen(Screen):
class ResultsScreen(AppScreen):
"""Renders the outcome of a lesson attempt; Enter/Esc returns to the menu."""

BINDINGS = [("enter,escape", "to_menu", "Continue")]
Expand All @@ -28,27 +29,39 @@ def __init__(
self._summary = summary
self._final_course_id = final_course_id

def summary_lines(self) -> list[str]:
"""Pure view-model: the lines shown to the player."""
def summary_lines(self, translator: Translator, locale: str) -> list[str]:
"""Pure view-model: the lines shown to the player, localized to `locale`.

The translator is injected rather than read from the app so the lines
stay unit-testable without a running TUI."""
metrics = self._outcome.metrics
summary = self._summary
headline = "Lesson passed!" if summary.passed else "Not yet — try again"

def t(key: str) -> str:
return translator.t(key, locale)

headline = t("results.passed") if summary.passed else t("results.failed")
level = f"{summary.level}"
if summary.leveled_up:
level = f"{level} ({t('results.level_up')})"
lines = [
headline,
f"WPM: {metrics.net_wpm:.0f}",
f"Accuracy: {metrics.accuracy * 100:.0f}%",
f"Errors: {metrics.errors}",
f"XP earned: {summary.earned_xp}",
f"Level: {summary.level}{' (up!)' if summary.leveled_up else ''}",
f"Streak: {summary.streak}",
f"{t('results.wpm')}: {metrics.net_wpm:.0f}",
f"{t('results.accuracy')}: {metrics.accuracy * 100:.0f}%",
f"{t('results.errors')}: {metrics.errors}",
f"{t('results.xp_earned')}: {summary.earned_xp}",
f"{t('results.level')}: {level}",
f"{t('results.streak')}: {summary.streak}",
]
for unlocked in summary.newly_unlocked:
lines.append(f"Unlocked: {unlocked}")
lines.append(f"{t('results.unlocked')}: {unlocked}")
return lines

def compose(self) -> ComposeResult:
translator = self.app_state.translator
locale = self.app_state.progress.ui_locale
yield Header()
yield Static("\n".join(self.summary_lines()))
yield Static("\n".join(self.summary_lines(translator, locale)))
yield Footer()

def action_to_menu(self) -> None:
Expand Down
Loading