From bef0dd14ae255240a71615c4d1152400eb9ef1c6 Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Tue, 28 Jan 2025 08:08:43 +0000 Subject: [PATCH 01/22] :sparkles: Add an API call for downloading the source of a PEP --- src/peplum/peps/api.py | 71 ++++++++++++++++++++++++++++++++++++------ 1 file changed, 61 insertions(+), 10 deletions(-) diff --git a/src/peplum/peps/api.py b/src/peplum/peps/api.py index 3f74066..e9f34bb 100644 --- a/src/peplum/peps/api.py +++ b/src/peplum/peps/api.py @@ -7,7 +7,7 @@ ############################################################################## # HTTPX imports. -from httpx import AsyncClient, HTTPStatusError, RequestError +from httpx import AsyncClient, HTTPStatusError, RequestError, Response ############################################################################## @@ -38,19 +38,20 @@ def _client(self) -> AsyncClient: self._client_ = AsyncClient() return self._client_ - async def get_peps(self) -> dict[int, dict[str, Any]]: - """Download a fresh list of all known PEPs. + async def _get(self, url: str) -> Response: + """Make a GET request. + + Args: + url: The URL to make the request of. Returns: - The PEP JSON data. + The response. Raises: - RequestError: If there was a problem getting the PEPS. + RequestError: If there was some sort of error. """ try: - response = await self._client.get( - self._URL, headers={"user-agent": self.AGENT} - ) + response = await self._client.get(url, headers={"user-agent": self.AGENT}) except (RequestError, SSLCertVerificationError) as error: raise self.RequestError(str(error)) from None @@ -59,10 +60,60 @@ async def get_peps(self) -> dict[int, dict[str, Any]]: except HTTPStatusError as error: raise self.RequestError(str(error)) from None - if isinstance(raw_data := response.json(), dict): - return raw_data + return response + + async def get_peps(self) -> dict[int, dict[str, Any]]: + """Download a fresh list of all known PEPs. + + Returns: + The PEP JSON data. + Raises: + RequestError: If there was a problem getting the PEPS. + """ + if isinstance( + raw_data := ( + await self._get("https://peps.python.org/api/peps.json") + ).json(), + dict, + ): + return raw_data raise RequestError("Unexpected data received from the PEP API") + @staticmethod + def pep_file(pep: int) -> str: + """Generate the name of the source file of a PEP. + + Args: + pep: The number of the PEP. + + Returns: + The name of the source file for that PEP. + """ + return f"pep-{pep:04}.rst" + + @classmethod + def pep_url(cls, pep: int) -> str: + """Generate the URL for the source of a PEP. + + Args: + pep: The number of the PEP. + + Returns: + The URL for the source of the PEP. + """ + return f"https://raw.githubusercontent.com/python/peps/refs/heads/main/peps/{cls.pep_file(pep)}" + + async def get_pep(self, pep: int) -> str: + """Download the text of a given PEP. + + Args: + pep: The number of the PEP to download. + + Returns: + The text for the PEP. + """ + return (await self._get(self.pep_url(pep))).text + ### api.py ends here From cd68210749afa28b83091898e127a35360cdce98 Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Tue, 28 Jan 2025 08:43:36 +0000 Subject: [PATCH 02/22] :sparkles: Add the core framework of a PEP source viewer screen See #2. --- src/peplum/app/commands/__init__.py | 2 + src/peplum/app/commands/main.py | 8 +++ src/peplum/app/providers/main.py | 2 + src/peplum/app/screens/main.py | 14 ++++ src/peplum/app/screens/pep_viewer.py | 101 +++++++++++++++++++++++++++ 5 files changed, 127 insertions(+) create mode 100644 src/peplum/app/screens/pep_viewer.py diff --git a/src/peplum/app/commands/__init__.py b/src/peplum/app/commands/__init__.py index 92073b8..d964eea 100644 --- a/src/peplum/app/commands/__init__.py +++ b/src/peplum/app/commands/__init__.py @@ -25,6 +25,7 @@ Quit, RedownloadPEPs, TogglePEPDetails, + ViewPEP, ) from .navigation_sorting import ( ToggleAuthorsSortOrder, @@ -59,6 +60,7 @@ "TogglePythonVersionsSortOrder", "ToggleStatusesSortOrder", "ToggleTypesSortOrder", + "ViewPEP", ] ### __init__.py ends here diff --git a/src/peplum/app/commands/main.py b/src/peplum/app/commands/main.py index 5d121db..cdf007f 100644 --- a/src/peplum/app/commands/main.py +++ b/src/peplum/app/commands/main.py @@ -61,4 +61,12 @@ class TogglePEPDetails(Command): BINDING_KEY = "f3" +############################################################################## +class ViewPEP(Command): + """View the source of the currently-highlighted PEP""" + + FOOTER_TEXT = "View" + BINDING_KEY = "f4" + + ### main.py ends here diff --git a/src/peplum/app/providers/main.py b/src/peplum/app/providers/main.py index fc88c25..1736c0a 100644 --- a/src/peplum/app/providers/main.py +++ b/src/peplum/app/providers/main.py @@ -24,6 +24,7 @@ TogglePythonVersionsSortOrder, ToggleStatusesSortOrder, ToggleTypesSortOrder, + ViewPEP, ) from .commands_provider import CommandHits, CommandsProvider @@ -59,6 +60,7 @@ def commands(self) -> CommandHits: yield TogglePythonVersionsSortOrder() yield ToggleStatusesSortOrder() yield ToggleTypesSortOrder() + yield ViewPEP() ### main.py ends here diff --git a/src/peplum/app/screens/main.py b/src/peplum/app/screens/main.py index a3a6bf9..efdf61d 100644 --- a/src/peplum/app/screens/main.py +++ b/src/peplum/app/screens/main.py @@ -44,6 +44,7 @@ TogglePythonVersionsSortOrder, ToggleStatusesSortOrder, ToggleTypesSortOrder, + ViewPEP, ) from ..data import ( PEP, @@ -78,6 +79,7 @@ from ..widgets import Navigation, PEPDetails, PEPsView from .help import HelpScreen from .notes_editor import NotesEditor +from .pep_viewer import PEPViewer from .search_input import SearchInput @@ -148,6 +150,7 @@ class Main(Screen[None]): Help, EditNotes, TogglePEPDetails, + ViewPEP, Quit, RedownloadPEPs, # Everything else. @@ -529,5 +532,16 @@ async def action_edit_notes_command(self) -> None: self.all_peps.patch_pep(self.selected_pep.annotate(notes=notes)) ) + @on(ViewPEP) + def action_view_pep_command(self) -> None: + """View the currently-highlighted PEP's source.""" + if self.selected_pep is None: + self.notify("Highlight a PEP to view it.", severity="warning") + return + if self.selected_pep.number == 0: + self.notify("PEP0 has no source to view.", severity="warning") + return + self.app.push_screen(PEPViewer(self.selected_pep)) + ### main.py ends here diff --git a/src/peplum/app/screens/pep_viewer.py b/src/peplum/app/screens/pep_viewer.py new file mode 100644 index 0000000..fb9668c --- /dev/null +++ b/src/peplum/app/screens/pep_viewer.py @@ -0,0 +1,101 @@ +"""A dialog for viewing the text of a PEP.""" + +############################################################################## +# Textual imports. +from textual import on, work +from textual.app import ComposeResult +from textual.containers import Horizontal, Vertical, VerticalScroll +from textual.screen import ModalScreen +from textual.widgets import Button, Static + +############################################################################## +# Local imports. +from ...peps import API +from ..data import PEP + + +############################################################################## +class PEPViewer(ModalScreen[None]): + """A modal screen for viewing a PEP's source.""" + + CSS = """ + PEPViewer { + align: center middle; + + &> Vertical { + width: 80%; + height: 80%; + max-height: 80%; + background: $panel; + border: panel $border; + } + + #viewer { + height: 1fr; + } + + #text { + padding: 0 1; + } + + #buttons { + height: auto; + margin-top: 1; + align-horizontal: right; + } + + Button { + margin-right: 1; + } + } + """ + + BINDINGS = [("escape", "close")] + + def __init__(self, pep: PEP) -> None: + """Initialise the dialog. + + Args: + pep: The PEP to view. + """ + super().__init__() + self._pep = pep + """The PEP to view.""" + + def compose(self) -> ComposeResult: + """Compose the dialog's content.""" + with Vertical() as dialog: + dialog.border_title = f"PEP{self._pep.number}" + yield VerticalScroll(Static(id="text"), id="viewer") + with Horizontal(id="buttons"): + yield Button("Close [dim]\\[Esc][/]") + + @work + async def _download_text(self) -> None: + """Download the text of the PEP.""" + try: + self.query_one("#text", Static).update( + await API().get_pep(self._pep.number) + ) + except API.RequestError as error: + self.query_one("#text", Static).update("[error]Error[/]") + self.notify( + str(error), title="Error downloading PEP source", severity="error" + ) + return + finally: + self.query_one("#viewer").loading = False + self.set_focus(self.query_one("#viewer")) + + def on_mount(self) -> None: + """Populate the dialog once the""" + self.query_one("#viewer").loading = True + self._download_text() + + @on(Button.Pressed) + def action_close(self) -> None: + """Close the dialog.""" + self.dismiss(None) + + +### pep_viewer.py ends here From 495f94b04bc5e522ff8f3f3f0e833cae6cfba4e0 Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Tue, 28 Jan 2025 09:23:45 +0000 Subject: [PATCH 03/22] :lipstick: Improve the look of the PEP viewer --- src/peplum/app/peplum.py | 5 +++++ src/peplum/app/screens/pep_viewer.py | 12 +++++++++++- 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/src/peplum/app/peplum.py b/src/peplum/app/peplum.py index 131d49d..4a1a7ef 100644 --- a/src/peplum/app/peplum.py +++ b/src/peplum/app/peplum.py @@ -33,6 +33,11 @@ class Peplum(App[None]): } } + /* Make the LoadingIndicator look less like it was just slapped on. */ + LoadingIndicator { + background: transparent; + } + /* Remove cruft from the Header. */ Header { /* The header icon is ugly and pointless. Remove it. */ diff --git a/src/peplum/app/screens/pep_viewer.py b/src/peplum/app/screens/pep_viewer.py index fb9668c..307a9c1 100644 --- a/src/peplum/app/screens/pep_viewer.py +++ b/src/peplum/app/screens/pep_viewer.py @@ -32,6 +32,15 @@ class PEPViewer(ModalScreen[None]): #viewer { height: 1fr; + scrollbar-background: $panel; + scrollbar-background-hover: $panel; + scrollbar-background-active: $panel; + Static { + color: $text-muted; + } + &:focus Static { + color: $text; + } } #text { @@ -42,6 +51,7 @@ class PEPViewer(ModalScreen[None]): height: auto; margin-top: 1; align-horizontal: right; + border-top: round $border; } Button { @@ -68,7 +78,7 @@ def compose(self) -> ComposeResult: dialog.border_title = f"PEP{self._pep.number}" yield VerticalScroll(Static(id="text"), id="viewer") with Horizontal(id="buttons"): - yield Button("Close [dim]\\[Esc][/]") + yield Button("Close [dim]\\[Esc][/]", variant="default") @work async def _download_text(self) -> None: From dd2bcc3c849aa51b572d08a1a7eaba505c621884 Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Tue, 28 Jan 2025 11:13:13 +0000 Subject: [PATCH 04/22] :hammer: Have API.pep_file return a Path rather than a string --- src/peplum/peps/api.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/peplum/peps/api.py b/src/peplum/peps/api.py index e9f34bb..5eb7fdd 100644 --- a/src/peplum/peps/api.py +++ b/src/peplum/peps/api.py @@ -2,6 +2,7 @@ ############################################################################## # Python imports. +from pathlib import Path from ssl import SSLCertVerificationError from typing import Any, Final @@ -81,7 +82,7 @@ async def get_peps(self) -> dict[int, dict[str, Any]]: raise RequestError("Unexpected data received from the PEP API") @staticmethod - def pep_file(pep: int) -> str: + def pep_file(pep: int) -> Path: """Generate the name of the source file of a PEP. Args: @@ -90,7 +91,7 @@ def pep_file(pep: int) -> str: Returns: The name of the source file for that PEP. """ - return f"pep-{pep:04}.rst" + return Path(f"pep-{pep:04}.rst") @classmethod def pep_url(cls, pep: int) -> str: From b204d51acbe91680f11e6bdfcccd182f4bc32b50 Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Tue, 28 Jan 2025 11:17:11 +0000 Subject: [PATCH 05/22] :hammer: Simplify the CSS for the PEP viewer --- src/peplum/app/screens/pep_viewer.py | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/src/peplum/app/screens/pep_viewer.py b/src/peplum/app/screens/pep_viewer.py index 307a9c1..b304938 100644 --- a/src/peplum/app/screens/pep_viewer.py +++ b/src/peplum/app/screens/pep_viewer.py @@ -35,18 +35,15 @@ class PEPViewer(ModalScreen[None]): scrollbar-background: $panel; scrollbar-background-hover: $panel; scrollbar-background-active: $panel; - Static { + #text { + padding: 0 1; color: $text-muted; } - &:focus Static { + &:focus #text { color: $text; } } - #text { - padding: 0 1; - } - #buttons { height: auto; margin-top: 1; From 6611517487229e153ac7278e7bcbd17bb294f540 Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Tue, 28 Jan 2025 16:52:57 +0000 Subject: [PATCH 06/22] :sparkles: Add support for a cache location --- src/peplum/app/data/__init__.py | 1 + src/peplum/app/data/locations.py | 15 +++++++++++++++ 2 files changed, 16 insertions(+) diff --git a/src/peplum/app/data/__init__.py b/src/peplum/app/data/__init__.py index 333c1b9..a459d1a 100644 --- a/src/peplum/app/data/__init__.py +++ b/src/peplum/app/data/__init__.py @@ -8,6 +8,7 @@ save_configuration, update_configuration, ) +from .locations import cache_dir from .notes import Notes from .pep import PEP, PEPStatus, PEPType, PostHistory from .peps import ( diff --git a/src/peplum/app/data/locations.py b/src/peplum/app/data/locations.py index ae4c6cb..7d6deed 100644 --- a/src/peplum/app/data/locations.py +++ b/src/peplum/app/data/locations.py @@ -41,6 +41,21 @@ def data_dir() -> Path: return _app_dir(xdg_data_home()) +############################################################################## +def cache_dir() -> Path: + """The path to the cache directory for the application. + + Returns: + The path to the cache directory for the application. + + Note: + If the directory doesn't exist, it will be created as a side-effect + of calling this function. + """ + (cache := data_dir() / "cache").mkdir(parents=True, exist_ok=True) + return cache + + ############################################################################## def config_dir() -> Path: """The path to the configuration directory for the application. From 1b8f02c10aeef6f12a1f8da6c02f8ef3720c0fb3 Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Tue, 28 Jan 2025 16:53:23 +0000 Subject: [PATCH 07/22] :art: Tidy the order of the exports --- src/peplum/app/data/__init__.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/peplum/app/data/__init__.py b/src/peplum/app/data/__init__.py index a459d1a..e65a2c7 100644 --- a/src/peplum/app/data/__init__.py +++ b/src/peplum/app/data/__init__.py @@ -29,17 +29,18 @@ ############################################################################## # Exports. __all__ = [ - "pep_data", "AuthorCount", + "cache_dir", "Configuration", "Containing", "load_configuration", "Notes", "PEP", - "PEPStatus", - "PEPType", + "pep_data", "PEPCount", "PEPs", + "PEPStatus", + "PEPType", "PostHistory", "PythonVersionCount", "save_configuration", From 073589a6ea98ae440a6472f1d15d06f74ff5caff Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Tue, 28 Jan 2025 16:54:08 +0000 Subject: [PATCH 08/22] :sparkles: Add a cache facility to the PEP source viewer screen --- src/peplum/app/screens/pep_viewer.py | 71 +++++++++++++++++++++------- 1 file changed, 54 insertions(+), 17 deletions(-) diff --git a/src/peplum/app/screens/pep_viewer.py b/src/peplum/app/screens/pep_viewer.py index b304938..1e10985 100644 --- a/src/peplum/app/screens/pep_viewer.py +++ b/src/peplum/app/screens/pep_viewer.py @@ -1,5 +1,9 @@ """A dialog for viewing the text of a PEP.""" +############################################################################## +# Python imports. +from pathlib import Path + ############################################################################## # Textual imports. from textual import on, work @@ -11,7 +15,7 @@ ############################################################################## # Local imports. from ...peps import API -from ..data import PEP +from ..data import PEP, cache_dir ############################################################################## @@ -57,7 +61,7 @@ class PEPViewer(ModalScreen[None]): } """ - BINDINGS = [("escape", "close")] + BINDINGS = [("escape", "close"), ("ctrl+r", "refresh")] def __init__(self, pep: PEP) -> None: """Initialise the dialog. @@ -75,23 +79,46 @@ def compose(self) -> ComposeResult: dialog.border_title = f"PEP{self._pep.number}" yield VerticalScroll(Static(id="text"), id="viewer") with Horizontal(id="buttons"): - yield Button("Close [dim]\\[Esc][/]", variant="default") + yield Button("Refresh [dim]\\[^r][/]", id="refresh") + yield Button("Close [dim]\\[Esc][/]", id="close") + + @property + def _cache_name(self) -> Path: + """The name of the file that is the cached version of the PEP source.""" + return cache_dir() / API.pep_file(self._pep.number) @work async def _download_text(self) -> None: - """Download the text of the PEP.""" - try: - self.query_one("#text", Static).update( - await API().get_pep(self._pep.number) - ) - except API.RequestError as error: - self.query_one("#text", Static).update("[error]Error[/]") - self.notify( - str(error), title="Error downloading PEP source", severity="error" - ) - return - finally: - self.query_one("#viewer").loading = False + """Download the text of the PEP. + + Notes: + Once downloaded a local copy will be saved. Subsequently, when + attempting to download the PEP, this local copy will be used + instead. + """ + pep_source = "" + if self._cache_name.exists(): + try: + pep_source = self._cache_name.read_text(encoding="utf-8") + except IOError: + pass + + if not pep_source: + try: + self._cache_name.write_text( + pep_source := await API().get_pep(self._pep.number), + encoding="utf-8", + ) + except IOError: + pass + except API.RequestError as error: + pep_source = "Error downloading PEP source" + self.notify( + str(error), title="Error downloading PEP source", severity="error" + ) + + self.query_one("#text", Static).update(pep_source) + self.query_one("#viewer").loading = False self.set_focus(self.query_one("#viewer")) def on_mount(self) -> None: @@ -99,10 +126,20 @@ def on_mount(self) -> None: self.query_one("#viewer").loading = True self._download_text() - @on(Button.Pressed) + @on(Button.Pressed, "#close") def action_close(self) -> None: """Close the dialog.""" self.dismiss(None) + @on(Button.Pressed, "#refresh") + def action_refresh(self) -> None: + """Refresh the PEP source.""" + try: + self._cache_name.unlink(missing_ok=True) + except IOError: + pass + self.query_one("#viewer").loading = True + self._download_text() + ### pep_viewer.py ends here From f32faa481cb983d727db95595ea393879952a580 Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Tue, 28 Jan 2025 17:08:08 +0000 Subject: [PATCH 09/22] :books: Add mention of the cache files to the "File locations" in README --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index 8d391fc..54761a1 100644 --- a/README.md +++ b/README.md @@ -50,6 +50,7 @@ Expanding for the common locations, the files normally created are: - `~/.config/peplum/configuration.json` -- The configuration file. - `~/.local/share/peplum/*.json` -- The locally-held PEP data. +- `~/.local/share/peplum/cache/*.rst` -- The locally-cached PEP source files. ## Getting help From 29e963df0be6a68b34aff82f6c1afb708be55c78 Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Wed, 29 Jan 2025 07:12:42 +0000 Subject: [PATCH 10/22] :hammer: Only set loading in one place --- src/peplum/app/screens/pep_viewer.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/peplum/app/screens/pep_viewer.py b/src/peplum/app/screens/pep_viewer.py index 1e10985..1e6b193 100644 --- a/src/peplum/app/screens/pep_viewer.py +++ b/src/peplum/app/screens/pep_viewer.py @@ -96,7 +96,9 @@ async def _download_text(self) -> None: attempting to download the PEP, this local copy will be used instead. """ + self.query_one("#viewer").loading = True pep_source = "" + if self._cache_name.exists(): try: pep_source = self._cache_name.read_text(encoding="utf-8") @@ -123,7 +125,6 @@ async def _download_text(self) -> None: def on_mount(self) -> None: """Populate the dialog once the""" - self.query_one("#viewer").loading = True self._download_text() @on(Button.Pressed, "#close") @@ -138,7 +139,6 @@ def action_refresh(self) -> None: self._cache_name.unlink(missing_ok=True) except IOError: pass - self.query_one("#viewer").loading = True self._download_text() From 4f2fd6a5d89e24a0f7b6f20134705baf85230d7f Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Wed, 29 Jan 2025 07:39:35 +0000 Subject: [PATCH 11/22] :hammer: Swap to using a ScrollableContainer --- src/peplum/app/screens/pep_viewer.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/peplum/app/screens/pep_viewer.py b/src/peplum/app/screens/pep_viewer.py index 1e6b193..b90a5d5 100644 --- a/src/peplum/app/screens/pep_viewer.py +++ b/src/peplum/app/screens/pep_viewer.py @@ -8,7 +8,7 @@ # Textual imports. from textual import on, work from textual.app import ComposeResult -from textual.containers import Horizontal, Vertical, VerticalScroll +from textual.containers import Horizontal, ScrollableContainer, Vertical from textual.screen import ModalScreen from textual.widgets import Button, Static @@ -77,7 +77,7 @@ def compose(self) -> ComposeResult: """Compose the dialog's content.""" with Vertical() as dialog: dialog.border_title = f"PEP{self._pep.number}" - yield VerticalScroll(Static(id="text"), id="viewer") + yield ScrollableContainer(Static(id="text"), id="viewer") with Horizontal(id="buttons"): yield Button("Refresh [dim]\\[^r][/]", id="refresh") yield Button("Close [dim]\\[Esc][/]", id="close") From d533a82e1022b6498884378da29d1ffca7ff053b Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Wed, 29 Jan 2025 07:47:35 +0000 Subject: [PATCH 12/22] :hammer: Swap to using a read-only TextArea to view the PEP source --- src/peplum/app/screens/pep_viewer.py | 26 +++++++++++++------------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/src/peplum/app/screens/pep_viewer.py b/src/peplum/app/screens/pep_viewer.py index b90a5d5..a1fd115 100644 --- a/src/peplum/app/screens/pep_viewer.py +++ b/src/peplum/app/screens/pep_viewer.py @@ -8,9 +8,9 @@ # Textual imports. from textual import on, work from textual.app import ComposeResult -from textual.containers import Horizontal, ScrollableContainer, Vertical +from textual.containers import Horizontal, Vertical from textual.screen import ModalScreen -from textual.widgets import Button, Static +from textual.widgets import Button, TextArea ############################################################################## # Local imports. @@ -34,17 +34,17 @@ class PEPViewer(ModalScreen[None]): border: panel $border; } - #viewer { + #text { + color: $text-muted; height: 1fr; + background: transparent; scrollbar-background: $panel; scrollbar-background-hover: $panel; scrollbar-background-active: $panel; - #text { - padding: 0 1; - color: $text-muted; - } - &:focus #text { + border: none; + &:focus { color: $text; + border: none; } } @@ -77,7 +77,7 @@ def compose(self) -> ComposeResult: """Compose the dialog's content.""" with Vertical() as dialog: dialog.border_title = f"PEP{self._pep.number}" - yield ScrollableContainer(Static(id="text"), id="viewer") + yield TextArea(id="text", read_only=True) with Horizontal(id="buttons"): yield Button("Refresh [dim]\\[^r][/]", id="refresh") yield Button("Close [dim]\\[Esc][/]", id="close") @@ -96,7 +96,7 @@ async def _download_text(self) -> None: attempting to download the PEP, this local copy will be used instead. """ - self.query_one("#viewer").loading = True + (text := self.query_one("#text", TextArea)).loading = True pep_source = "" if self._cache_name.exists(): @@ -119,9 +119,9 @@ async def _download_text(self) -> None: str(error), title="Error downloading PEP source", severity="error" ) - self.query_one("#text", Static).update(pep_source) - self.query_one("#viewer").loading = False - self.set_focus(self.query_one("#viewer")) + text.text = pep_source + text.loading = False + self.set_focus(text) def on_mount(self) -> None: """Populate the dialog once the""" From 94f1c7c89db63012cf922c333e2e5ceb6341ce11 Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Wed, 29 Jan 2025 08:10:24 +0000 Subject: [PATCH 13/22] :sparkles: Add a text viewing widget --- src/peplum/app/widgets/__init__.py | 3 +- src/peplum/app/widgets/text_viewer.py | 59 +++++++++++++++++++++++++++ 2 files changed, 61 insertions(+), 1 deletion(-) create mode 100644 src/peplum/app/widgets/text_viewer.py diff --git a/src/peplum/app/widgets/__init__.py b/src/peplum/app/widgets/__init__.py index 913221b..a0d2c6e 100644 --- a/src/peplum/app/widgets/__init__.py +++ b/src/peplum/app/widgets/__init__.py @@ -5,10 +5,11 @@ from .navigation import Navigation from .pep_details import PEPDetails from .peps_view import PEPsView +from .text_viewer import TextViewer ############################################################################## # Exports. -__all__ = ["Navigation", "PEPDetails", "PEPsView"] +__all__ = ["Navigation", "PEPDetails", "PEPsView", "TextViewer"] ### __init__.py ends here diff --git a/src/peplum/app/widgets/text_viewer.py b/src/peplum/app/widgets/text_viewer.py new file mode 100644 index 0000000..2fc48b4 --- /dev/null +++ b/src/peplum/app/widgets/text_viewer.py @@ -0,0 +1,59 @@ +"""A widget for viewing text.""" + +############################################################################## +# Textual imports. +from textual.widgets import TextArea + + +############################################################################## +class TextViewer(TextArea): + """A widget for viewing text.""" + + DEFAULT_CSS = """ + TextViewer { + background: transparent; + border: none; + &:focus { + border: none; + } + } + """ + + def __init__( + self, + text: str = "", + *, + name: str | None = None, + id: str | None = None, + classes: str | None = None, + disabled: bool = False, + ) -> None: + """Initialise the object. + + Args: + text: The text to view. + name: The name of the TextViewer. + id: The ID of the TextViewer in the DOM. + classes: The CSS classes of the TextViewer. + disabled: Whether the TextViewer is disabled or not. + """ + super().__init__( + text, + read_only=True, + name=name, + id=id, + classes=classes, + disabled=disabled, + ) + + def action_copy(self) -> None: + """Action for copying text to the clipboard.""" + if for_clipboard := self.selected_text: + self.notify("Selected text copied to the clipboard.") + else: + for_clipboard = self.text + self.notify("All text copied to the clipboard.") + self.app.copy_to_clipboard(for_clipboard) + + +### text_viewer.py ends here From 801925832f5041ac86e218f3c2e2dd46903e1fa4 Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Wed, 29 Jan 2025 08:14:14 +0000 Subject: [PATCH 14/22] :sparkles: Swap the PEP source viewer over to the TextViewer widget --- src/peplum/app/screens/pep_viewer.py | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/src/peplum/app/screens/pep_viewer.py b/src/peplum/app/screens/pep_viewer.py index a1fd115..6ebec8c 100644 --- a/src/peplum/app/screens/pep_viewer.py +++ b/src/peplum/app/screens/pep_viewer.py @@ -16,6 +16,7 @@ # Local imports. from ...peps import API from ..data import PEP, cache_dir +from ..widgets import TextViewer ############################################################################## @@ -34,17 +35,14 @@ class PEPViewer(ModalScreen[None]): border: panel $border; } - #text { + TextViewer { color: $text-muted; height: 1fr; - background: transparent; scrollbar-background: $panel; scrollbar-background-hover: $panel; scrollbar-background-active: $panel; - border: none; &:focus { color: $text; - border: none; } } @@ -61,7 +59,7 @@ class PEPViewer(ModalScreen[None]): } """ - BINDINGS = [("escape", "close"), ("ctrl+r", "refresh")] + BINDINGS = [("escape", "close"), ("ctrl+r", "refresh"), ("ctrl+c", "copy")] def __init__(self, pep: PEP) -> None: """Initialise the dialog. @@ -77,8 +75,9 @@ def compose(self) -> ComposeResult: """Compose the dialog's content.""" with Vertical() as dialog: dialog.border_title = f"PEP{self._pep.number}" - yield TextArea(id="text", read_only=True) + yield TextViewer() with Horizontal(id="buttons"): + yield Button("Copy [dim]\\[^c][/]", id="copy") yield Button("Refresh [dim]\\[^r][/]", id="refresh") yield Button("Close [dim]\\[Esc][/]", id="close") @@ -96,7 +95,7 @@ async def _download_text(self) -> None: attempting to download the PEP, this local copy will be used instead. """ - (text := self.query_one("#text", TextArea)).loading = True + (text := self.query_one(TextViewer)).loading = True pep_source = "" if self._cache_name.exists(): @@ -141,5 +140,10 @@ def action_refresh(self) -> None: pass self._download_text() + @on(Button.Pressed, "#copy") + async def action_copy(self) -> None: + """Copy PEP text to the clipboard.""" + await self.query_one(TextArea).run_action("copy") + ### pep_viewer.py ends here From cc75e1bc3e10f30d3a98c1bc518e4cd1cccd25fb Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Wed, 29 Jan 2025 08:20:51 +0000 Subject: [PATCH 15/22] :books: Update the ChangeLog --- ChangeLog.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/ChangeLog.md b/ChangeLog.md index be635c1..2f31e92 100644 --- a/ChangeLog.md +++ b/ChangeLog.md @@ -1,5 +1,12 @@ # Peplum ChangeLog +## Unreleased + +**Released: WiP** + +- Added the ability to view the source of a PEP. + ([#17](https://github.com/davep/peplum/pull/17)) + ## v0.2.0 **Released: 2025-01-27** From b816a143571635399607ad685a12c2cde646bbcf Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Wed, 29 Jan 2025 08:54:07 +0000 Subject: [PATCH 16/22] :lipstick: Remove the line highlight from the TextViewer widget --- src/peplum/app/widgets/text_viewer.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/peplum/app/widgets/text_viewer.py b/src/peplum/app/widgets/text_viewer.py index 2fc48b4..d450b88 100644 --- a/src/peplum/app/widgets/text_viewer.py +++ b/src/peplum/app/widgets/text_viewer.py @@ -16,6 +16,9 @@ class TextViewer(TextArea): &:focus { border: none; } + & > .text-area--cursor-line { + background: transparent; + } } """ From a5a96c9799736445d7efc3a8e58c36a040a59d45 Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Wed, 29 Jan 2025 09:12:35 +0000 Subject: [PATCH 17/22] :sparkles: Add some movement tweaks to the TextViewer --- src/peplum/app/widgets/text_viewer.py | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/src/peplum/app/widgets/text_viewer.py b/src/peplum/app/widgets/text_viewer.py index d450b88..ea1f761 100644 --- a/src/peplum/app/widgets/text_viewer.py +++ b/src/peplum/app/widgets/text_viewer.py @@ -22,6 +22,11 @@ class TextViewer(TextArea): } """ + BINDINGS = [ + ("<", "cursor_line_start"), + (">", "cursor_line_end"), + ] + def __init__( self, text: str = "", @@ -58,5 +63,20 @@ def action_copy(self) -> None: self.notify("All text copied to the clipboard.") self.app.copy_to_clipboard(for_clipboard) + def action_cursor_line_start(self, select: bool = False) -> None: + """Add a slightly smarter use of going 'home'.""" + if self.cursor_at_start_of_line: + self.move_cursor((0, 0), select=select) + else: + super().action_cursor_line_start(select) + + def action_cursor_line_end(self, select: bool = False) -> None: + """Add a slightly smarter use of going 'end'.""" + if self.cursor_at_end_of_line: + self.move_cursor((self.document.line_count, 0), select=select) + pass + else: + super().action_cursor_line_end(select) + ### text_viewer.py ends here From 94330a6dc13198e2b184518934f60c5eeb50a3c1 Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Wed, 29 Jan 2025 09:32:44 +0000 Subject: [PATCH 18/22] :sparkles: Add all the options for copying --- src/peplum/app/widgets/text_viewer.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/peplum/app/widgets/text_viewer.py b/src/peplum/app/widgets/text_viewer.py index ea1f761..e6f026a 100644 --- a/src/peplum/app/widgets/text_viewer.py +++ b/src/peplum/app/widgets/text_viewer.py @@ -25,6 +25,7 @@ class TextViewer(TextArea): BINDINGS = [ ("<", "cursor_line_start"), (">", "cursor_line_end"), + ("c, C", "copy"), ] def __init__( From 19ef6790537931c1c9209984882977b6459d4cdb Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Wed, 29 Jan 2025 09:36:00 +0000 Subject: [PATCH 19/22] :hammer: Correctly identify the start and end of the text to view Also remove a rogue `pass` that got left in, from when I was writing the code earlier. --- src/peplum/app/widgets/text_viewer.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/peplum/app/widgets/text_viewer.py b/src/peplum/app/widgets/text_viewer.py index e6f026a..1bc3ff2 100644 --- a/src/peplum/app/widgets/text_viewer.py +++ b/src/peplum/app/widgets/text_viewer.py @@ -67,15 +67,14 @@ def action_copy(self) -> None: def action_cursor_line_start(self, select: bool = False) -> None: """Add a slightly smarter use of going 'home'.""" if self.cursor_at_start_of_line: - self.move_cursor((0, 0), select=select) + self.move_cursor(self.document.start, select=select) else: super().action_cursor_line_start(select) def action_cursor_line_end(self, select: bool = False) -> None: """Add a slightly smarter use of going 'end'.""" if self.cursor_at_end_of_line: - self.move_cursor((self.document.line_count, 0), select=select) - pass + self.move_cursor(self.document.end, select=select) else: super().action_cursor_line_end(select) From 759b67d8d8ed5e2ed26dfceac2f29dc6933ef01c Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Wed, 29 Jan 2025 10:43:45 +0000 Subject: [PATCH 20/22] :books: Fix a documentation typo --- src/peplum/app/screens/pep_viewer.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/peplum/app/screens/pep_viewer.py b/src/peplum/app/screens/pep_viewer.py index 6ebec8c..5499e2e 100644 --- a/src/peplum/app/screens/pep_viewer.py +++ b/src/peplum/app/screens/pep_viewer.py @@ -123,7 +123,7 @@ async def _download_text(self) -> None: self.set_focus(text) def on_mount(self) -> None: - """Populate the dialog once the""" + """Populate the dialog once the DOM is ready.""" self._download_text() @on(Button.Pressed, "#close") From 03ea80a2ce9e1fd47e9813725be05c2af3d2a986 Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Wed, 29 Jan 2025 13:51:06 +0000 Subject: [PATCH 21/22] :lipstick: Improve the cosmetics of the PEP source viewer --- src/peplum/app/screens/pep_viewer.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/peplum/app/screens/pep_viewer.py b/src/peplum/app/screens/pep_viewer.py index 5499e2e..097cab7 100644 --- a/src/peplum/app/screens/pep_viewer.py +++ b/src/peplum/app/screens/pep_viewer.py @@ -32,7 +32,7 @@ class PEPViewer(ModalScreen[None]): height: 80%; max-height: 80%; background: $panel; - border: panel $border; + border: solid $border; } TextViewer { @@ -48,9 +48,8 @@ class PEPViewer(ModalScreen[None]): #buttons { height: auto; - margin-top: 1; align-horizontal: right; - border-top: round $border; + border-top: solid $border; } Button { From 86414da3a934a5dab3cac9e743a44a3e143d497a Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Wed, 29 Jan 2025 13:55:12 +0000 Subject: [PATCH 22/22] :lipstick: Use the accent colour to highlight the keys in buttons --- src/peplum/app/screens/pep_viewer.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/src/peplum/app/screens/pep_viewer.py b/src/peplum/app/screens/pep_viewer.py index 097cab7..3ef6930 100644 --- a/src/peplum/app/screens/pep_viewer.py +++ b/src/peplum/app/screens/pep_viewer.py @@ -72,13 +72,16 @@ def __init__(self, pep: PEP) -> None: def compose(self) -> ComposeResult: """Compose the dialog's content.""" + key_colour = ( + "dim" if self.app.current_theme is None else self.app.current_theme.accent + ) with Vertical() as dialog: dialog.border_title = f"PEP{self._pep.number}" yield TextViewer() with Horizontal(id="buttons"): - yield Button("Copy [dim]\\[^c][/]", id="copy") - yield Button("Refresh [dim]\\[^r][/]", id="refresh") - yield Button("Close [dim]\\[Esc][/]", id="close") + yield Button(f"Copy [{key_colour}]\\[^c][/]", id="copy") + yield Button(f"Refresh [{key_colour}]\\[^r][/]", id="refresh") + yield Button(f"Close [{key_colour}]\\[Esc][/]", id="close") @property def _cache_name(self) -> Path: