diff --git a/pythonlings/screens/docs.py b/pythonlings/screens/docs.py index d5002b5..5b7f3bf 100644 --- a/pythonlings/screens/docs.py +++ b/pythonlings/screens/docs.py @@ -5,6 +5,7 @@ from urllib.parse import urldefrag from rich.markup import escape +from textual import events from textual.app import ComposeResult from textual.binding import Binding from textual.containers import Vertical, VerticalScroll @@ -14,6 +15,20 @@ from pythonlings.core.docs import load_snippet from pythonlings.core.exercise import Exercise +_LONG_FOOTER_TEXT = "O Open official docs | Esc Close" +_COMPACT_FOOTER_TEXT = "Esc Close | O Docs" + + +class _DocsFooter(Static): + def on_resize(self, event: events.Resize) -> None: + self.update(self.text_for_width(event.size.width)) + + @staticmethod + def text_for_width(width: int) -> str: + if width < len(_LONG_FOOTER_TEXT): + return _COMPACT_FOOTER_TEXT + return _LONG_FOOTER_TEXT + class DocsScreen(ModalScreen[None]): """Small in-app reference window for the current exercise.""" @@ -34,7 +49,7 @@ def compose(self) -> ComposeResult: Markdown(self._reference_markdown(), id="docs-content", open_links=False), id="docs-scroll", ), - Static("O Open official docs | Esc Close", id="docs-footer"), + _DocsFooter(_COMPACT_FOOTER_TEXT, id="docs-footer"), id="docs-window", ) diff --git a/pythonlings/screens/track.py b/pythonlings/screens/track.py index 4f6bafa..fce440d 100644 --- a/pythonlings/screens/track.py +++ b/pythonlings/screens/track.py @@ -1,6 +1,7 @@ # pythonlings/screens/track.py from __future__ import annotations +from textual import events from textual.app import ComposeResult from textual.binding import Binding from textual.containers import Horizontal @@ -17,6 +18,33 @@ from pythonlings.widgets.progress import ProgressBar _DEBOUNCE_SECONDS = 0.6 +_FULL_FOOTER_WIDTH = 70 + + +class _TrackFooter(Footer): + def __init__(self) -> None: + super().__init__() + self._prioritize_navigation = False + + def compose(self) -> ComposeResult: + children = list(super().compose()) + if self._prioritize_navigation: + priority = {"topics": 0, "docs": 1, "quit": 2} + children.sort( + key=lambda child: priority.get(getattr(child, "action", ""), 3) + ) + yield from children + + def on_resize(self, event: events.Resize) -> None: + show_command_palette = event.size.width >= _FULL_FOOTER_WIDTH + prioritize_navigation = not show_command_palette + if ( + self.show_command_palette != show_command_palette + or self._prioritize_navigation != prioritize_navigation + ): + self.show_command_palette = show_command_palette + self._prioritize_navigation = prioritize_navigation + self.call_after_refresh(self.recompose) def celebration_message(total: int) -> str: @@ -61,7 +89,7 @@ def compose(self) -> ComposeResult: OutputPanel(id="output"), id="main", ) - yield Footer() + yield _TrackFooter() def on_mount(self) -> None: self.app.sub_title = f"topic: {self.topic}" diff --git a/tests/tui/test_app_pilot.py b/tests/tui/test_app_pilot.py index fdeec06..f055980 100644 --- a/tests/tui/test_app_pilot.py +++ b/tests/tui/test_app_pilot.py @@ -4,7 +4,7 @@ from pathlib import Path import pytest -from textual.widgets import Markdown, Static, TextArea +from textual.widgets import Footer, Markdown, Static, TextArea from textual.worker import WorkerCancelled from pythonlings.app import PythonlingsApp @@ -161,6 +161,97 @@ async def test_f4_returns_to_picker(tmp_path: Path) -> None: assert isinstance(app.screen, TopicPickerScreen) +@pytest.mark.asyncio +async def test_narrow_layout_keeps_navigation_discoverable_and_usable( + tmp_path: Path, +) -> None: + app = PythonlingsApp(root=_work_copy(tmp_path), start_topic="alpha") + async with app.run_test(size=(40, 15)) as pilot: + await _settle(pilot) + assert isinstance(app.screen, TrackScreen) + + footer = app.screen.query_one(Footer) + keys = {key.key: key for key in footer.query("FooterKey")} + assert "ctrl+p" not in keys + for key_name in ("f4", "f5", "escape"): + key = keys[key_name] + assert key.region.x >= footer.region.x + assert key.region.right <= footer.region.right + + await pilot.press("f5") + await pilot.pause() + assert isinstance(app.screen, DocsScreen) + docs_footer = str(app.screen.query_one("#docs-footer", Static).content) + assert docs_footer.startswith("Esc Close") + + await pilot.press("escape") + await pilot.pause() + assert isinstance(app.screen, TrackScreen) + + await pilot.press("f4") + await pilot.pause() + assert isinstance(app.screen, TopicPickerScreen) + picker_footer = app.screen.query_one(Footer) + picker_keys = { + key.key: key for key in picker_footer.query("FooterKey") + } + escape_key = picker_keys["escape"] + assert escape_key.region.x >= picker_footer.region.x + assert escape_key.region.right <= picker_footer.region.right + + await pilot.press("escape") + await pilot.pause() + assert app.return_code == 0 + + +@pytest.mark.asyncio +async def test_normal_width_footer_keeps_all_track_labels(tmp_path: Path) -> None: + app = PythonlingsApp(root=_work_copy(tmp_path), start_topic="alpha") + async with app.run_test(size=(80, 24)) as pilot: + await _settle(pilot) + footer = app.screen.query_one(Footer) + footer_keys = list(footer.query("FooterKey")) + keys = {key.key: key for key in footer_keys} + assert [ + key.key + for key in footer_keys + if key.key in {"f1", "f2", "f3", "f4", "f5", "escape"} + ] == ["f1", "f2", "f3", "f4", "f5", "escape"] + assert keys["ctrl+p"].description == "palette" + assert { + key_name: keys[key_name].description + for key_name in ("f1", "f2", "f3", "f4", "f5", "escape") + } == { + "f1": "Hint", + "f2": "Reset", + "f3": "List", + "f4": "Topics", + "f5": "Docs", + "escape": "Quit", + } + assert all( + key.region.right <= footer.region.right for key in keys.values() + ) + + +@pytest.mark.asyncio +async def test_docs_footer_uses_its_laid_out_width_at_41_columns( + tmp_path: Path, +) -> None: + app = PythonlingsApp(root=_work_copy(tmp_path), start_topic="alpha") + async with app.run_test(size=(41, 15)) as pilot: + await _settle(pilot) + await pilot.press("f5") + await pilot.pause() + assert isinstance(app.screen, DocsScreen) + + docs_footer = app.screen.query_one("#docs-footer", Static) + footer_text = str(docs_footer.content) + assert docs_footer.size.width == 23 + assert footer_text == "Esc Close | O Docs" + assert len(footer_text) <= docs_footer.size.width + + @pytest.mark.asyncio async def test_escape_quits_from_track_screen(tmp_path: Path) -> None: app = PythonlingsApp(root=_work_copy(tmp_path), start_topic="alpha")