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
17 changes: 16 additions & 1 deletion pythonlings/screens/docs.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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."""
Expand All @@ -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",
)

Expand Down
30 changes: 29 additions & 1 deletion pythonlings/screens/track.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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:
Expand Down Expand Up @@ -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}"
Expand Down
93 changes: 92 additions & 1 deletion tests/tui/test_app_pilot.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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")
Expand Down
Loading