From 036dc765f9b3b14b55ebc9be669496fbdcad5724 Mon Sep 17 00:00:00 2001 From: xjcway123 Date: Wed, 19 Aug 2026 17:31:08 +0800 Subject: [PATCH 1/2] fix(tui): keep navigation bindings visible in narrow terminals --- pythonlings/screens/docs.py | 16 ++++++++- pythonlings/screens/track.py | 18 +++++++--- tests/tui/test_app_pilot.py | 69 +++++++++++++++++++++++++++++++++++- 3 files changed, 97 insertions(+), 6 deletions(-) diff --git a/pythonlings/screens/docs.py b/pythonlings/screens/docs.py index d5002b5..ac4291b 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,8 @@ from pythonlings.core.docs import load_snippet from pythonlings.core.exercise import Exercise +_NARROW_SCREEN_WIDTH = 40 + class DocsScreen(ModalScreen[None]): """Small in-app reference window for the current exercise.""" @@ -34,10 +37,21 @@ 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"), + Static(self._footer_text(self.app.size.width), id="docs-footer"), id="docs-window", ) + def on_resize(self, event: events.Resize) -> None: + self.query_one("#docs-footer", Static).update( + self._footer_text(event.size.width) + ) + + @staticmethod + def _footer_text(width: int) -> str: + if width <= _NARROW_SCREEN_WIDTH: + return "Esc Close | O Open docs" + return "O Open official docs | Esc Close" + def action_close(self) -> None: self.dismiss() diff --git a/pythonlings/screens/track.py b/pythonlings/screens/track.py index 4f6bafa..2854ccc 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,15 @@ from pythonlings.widgets.progress import ProgressBar _DEBOUNCE_SECONDS = 0.6 +_FULL_FOOTER_WIDTH = 70 + + +class _TrackFooter(Footer): + def on_resize(self, event: events.Resize) -> None: + show_command_palette = event.size.width >= _FULL_FOOTER_WIDTH + if self.show_command_palette != show_command_palette: + self.show_command_palette = show_command_palette + self.call_after_refresh(self.recompose) def celebration_message(total: int) -> str: @@ -34,12 +44,12 @@ class TrackScreen(Screen[None]): """One topic's linear track: editor + output + auto-save loop.""" BINDINGS = [ - Binding("f1", "toggle_hint", "Hint"), - Binding("f2", "reset", "Reset"), - Binding("f3", "toggle_list", "List"), Binding("f4", "topics", "Topics"), Binding("f5", "docs", "Docs"), Binding("escape", "quit", "Quit", priority=True), + Binding("f1", "toggle_hint", "Hint"), + Binding("f2", "reset", "Reset"), + Binding("f3", "toggle_list", "List"), Binding("ctrl+q", "quit", "Quit"), ] @@ -61,7 +71,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..123a100 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,73 @@ 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) + keys = {key.key: key for key in footer.query("FooterKey")} + 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_escape_quits_from_track_screen(tmp_path: Path) -> None: app = PythonlingsApp(root=_work_copy(tmp_path), start_topic="alpha") From dcc65acc1d5ded7def5a766aa460740d81e79996 Mon Sep 17 00:00:00 2001 From: xjcway123 Date: Thu, 20 Aug 2026 10:37:25 +0800 Subject: [PATCH 2/2] fix(tui): address narrow footer review feedback --- pythonlings/screens/docs.py | 27 ++++++++++++++------------- pythonlings/screens/track.py | 26 ++++++++++++++++++++++---- tests/tui/test_app_pilot.py | 26 +++++++++++++++++++++++++- 3 files changed, 61 insertions(+), 18 deletions(-) diff --git a/pythonlings/screens/docs.py b/pythonlings/screens/docs.py index ac4291b..5b7f3bf 100644 --- a/pythonlings/screens/docs.py +++ b/pythonlings/screens/docs.py @@ -15,7 +15,19 @@ from pythonlings.core.docs import load_snippet from pythonlings.core.exercise import Exercise -_NARROW_SCREEN_WIDTH = 40 +_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]): @@ -37,21 +49,10 @@ def compose(self) -> ComposeResult: Markdown(self._reference_markdown(), id="docs-content", open_links=False), id="docs-scroll", ), - Static(self._footer_text(self.app.size.width), id="docs-footer"), + _DocsFooter(_COMPACT_FOOTER_TEXT, id="docs-footer"), id="docs-window", ) - def on_resize(self, event: events.Resize) -> None: - self.query_one("#docs-footer", Static).update( - self._footer_text(event.size.width) - ) - - @staticmethod - def _footer_text(width: int) -> str: - if width <= _NARROW_SCREEN_WIDTH: - return "Esc Close | O Open docs" - return "O Open official docs | Esc Close" - def action_close(self) -> None: self.dismiss() diff --git a/pythonlings/screens/track.py b/pythonlings/screens/track.py index 2854ccc..fce440d 100644 --- a/pythonlings/screens/track.py +++ b/pythonlings/screens/track.py @@ -22,10 +22,28 @@ 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 - if self.show_command_palette != show_command_palette: + 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) @@ -44,12 +62,12 @@ class TrackScreen(Screen[None]): """One topic's linear track: editor + output + auto-save loop.""" BINDINGS = [ - Binding("f4", "topics", "Topics"), - Binding("f5", "docs", "Docs"), - Binding("escape", "quit", "Quit", priority=True), Binding("f1", "toggle_hint", "Hint"), Binding("f2", "reset", "Reset"), Binding("f3", "toggle_list", "List"), + Binding("f4", "topics", "Topics"), + Binding("f5", "docs", "Docs"), + Binding("escape", "quit", "Quit", priority=True), Binding("ctrl+q", "quit", "Quit"), ] diff --git a/tests/tui/test_app_pilot.py b/tests/tui/test_app_pilot.py index 123a100..f055980 100644 --- a/tests/tui/test_app_pilot.py +++ b/tests/tui/test_app_pilot.py @@ -210,7 +210,13 @@ async def test_normal_width_footer_keeps_all_track_labels(tmp_path: Path) -> Non async with app.run_test(size=(80, 24)) as pilot: await _settle(pilot) footer = app.screen.query_one(Footer) - keys = {key.key: key for key in footer.query("FooterKey")} + 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 @@ -228,6 +234,24 @@ async def test_normal_width_footer_keeps_all_track_labels(tmp_path: Path) -> Non ) +@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")