From fefec259a068fe9382c8527a81e0937f75c1eb8e Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Wed, 29 Jan 2025 16:22:50 +0000 Subject: [PATCH 1/5] :heavy_plus_sign: Add textual-fspicker as a dependency --- pyproject.toml | 1 + requirements-dev.lock | 3 +++ requirements.lock | 3 +++ 3 files changed, 7 insertions(+) diff --git a/pyproject.toml b/pyproject.toml index 600a971..d91783e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -12,6 +12,7 @@ dependencies = [ "typing-extensions>=4.12.2", "packaging>=24.2", "humanize>=4.11.0", + "textual-fspicker>=0.1.1", ] readme = "README.md" requires-python = ">= 3.9" diff --git a/requirements-dev.lock b/requirements-dev.lock index 778c0a9..bdd0aa3 100644 --- a/requirements-dev.lock +++ b/requirements-dev.lock @@ -104,8 +104,11 @@ sniffio==1.3.1 textual==1.0.0 # via peplum # via textual-dev + # via textual-fspicker # via textual-serve textual-dev==1.7.0 +textual-fspicker==0.1.1 + # via peplum textual-serve==1.1.1 # via textual-dev typing-extensions==4.12.2 diff --git a/requirements.lock b/requirements.lock index 9f9c035..62b974c 100644 --- a/requirements.lock +++ b/requirements.lock @@ -48,6 +48,9 @@ sniffio==1.3.1 # via anyio textual==1.0.0 # via peplum + # via textual-fspicker +textual-fspicker==0.1.1 + # via peplum typing-extensions==4.12.2 # via peplum # via textual From 8616c9370a12b18949a7fcccbcce82b52d57b9fe Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Wed, 29 Jan 2025 16:39:08 +0000 Subject: [PATCH 2/5] :sparkles: Add support for saving the source of a PEP to a file --- src/peplum/app/screens/pep_viewer.py | 24 +++++++++++++++++++++++- 1 file changed, 23 insertions(+), 1 deletion(-) diff --git a/src/peplum/app/screens/pep_viewer.py b/src/peplum/app/screens/pep_viewer.py index 3ef6930..439b7f3 100644 --- a/src/peplum/app/screens/pep_viewer.py +++ b/src/peplum/app/screens/pep_viewer.py @@ -12,6 +12,10 @@ from textual.screen import ModalScreen from textual.widgets import Button, TextArea +############################################################################## +# Textual fspicker imports. +from textual_fspicker import FileSave + ############################################################################## # Local imports. from ...peps import API @@ -58,7 +62,12 @@ class PEPViewer(ModalScreen[None]): } """ - BINDINGS = [("escape", "close"), ("ctrl+r", "refresh"), ("ctrl+c", "copy")] + BINDINGS = [ + ("ctrl+c", "copy"), + ("ctrl+r", "refresh"), + ("ctrl+s", "save"), + ("escape", "close"), + ] def __init__(self, pep: PEP) -> None: """Initialise the dialog. @@ -80,6 +89,7 @@ def compose(self) -> ComposeResult: yield TextViewer() with Horizontal(id="buttons"): yield Button(f"Copy [{key_colour}]\\[^c][/]", id="copy") + yield Button(f"Save [{key_colour}]\\[^s][/]", id="save") yield Button(f"Refresh [{key_colour}]\\[^r][/]", id="refresh") yield Button(f"Close [{key_colour}]\\[Esc][/]", id="close") @@ -147,5 +157,17 @@ async def action_copy(self) -> None: """Copy PEP text to the clipboard.""" await self.query_one(TextArea).run_action("copy") + @on(Button.Pressed, "#save") + @work + async def action_save(self) -> None: + """Save the source of the PEP to a file.""" + if target := await self.app.push_screen_wait(FileSave()): + try: + target.write_text(self.query_one(TextArea).text, encoding="utf-8") + except IOError as error: + self.notify(str(error), title="Save Failed", severity="error") + return + self.notify(str(target), title="Saved") + ### pep_viewer.py ends here From c4d5c129106a2b561128b1f2d6e1507df40780a8 Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Wed, 29 Jan 2025 16:51:31 +0000 Subject: [PATCH 3/5] :sparkles: Add a confirmation dialog --- src/peplum/app/screens/confirm.py | 101 ++++++++++++++++++++++++++++++ 1 file changed, 101 insertions(+) create mode 100644 src/peplum/app/screens/confirm.py diff --git a/src/peplum/app/screens/confirm.py b/src/peplum/app/screens/confirm.py new file mode 100644 index 0000000..04395c7 --- /dev/null +++ b/src/peplum/app/screens/confirm.py @@ -0,0 +1,101 @@ +"""Provides a confirmation dialog.""" + +############################################################################## +# Textual imports. +from textual import on +from textual.app import ComposeResult +from textual.containers import Horizontal, Vertical +from textual.screen import ModalScreen +from textual.widgets import Button, Label + + +############################################################################## +class Confirm(ModalScreen[bool]): + """A modal dialog for confirming things.""" + + CSS = """ + Confirm { + align: center middle; + + &> Vertical { + padding: 1 2; + height: auto; + width: auto; + max-width: 80vw; + background: $surface; + border: panel $error; + border-title-color: $text; + + &> Horizontal { + height: auto; + width: 100%; + align-horizontal: center; + } + } + + Label { + width: auto; + max-width: 70vw; + padding-left: 1; + padding-right: 1; + margin-bottom: 1; + } + + Button { + margin-right: 1; + } + } + """ + + BINDINGS = [ + ("escape", "no"), + ("f2", "yes"), + ("left", "focus_previous"), + ("right", "focus_next"), + ] + + def __init__( + self, title: str, question: str, yes_text: str = "Yes", no_text: str = "No" + ) -> None: + """Initialise the dialog. + + Args: + title: The title for the dialog. + question: The question to ask the user. + yes_text: The text for the yes button. + no_text: The text for the no button. + """ + super().__init__() + self._title = title + """The title for the dialog.""" + self._question = question + """The question to ask the user.""" + self._yes = yes_text + """The text of the yes button.""" + self._no = no_text + """The text of the no button.""" + + def compose(self) -> ComposeResult: + """Compose the layout of the dialog.""" + key_colour = ( + "dim" if self.app.current_theme is None else self.app.current_theme.accent + ) + with Vertical() as dialog: + dialog.border_title = self._title + yield Label(self._question) + with Horizontal(): + yield Button(f"{self._no} [{key_colour}]\\[Esc][/]", id="no") + yield Button(f"{self._yes} [{key_colour}]\\[F2][/]", id="yes") + + @on(Button.Pressed, "#yes") + def action_yes(self) -> None: + """Send back the positive response.""" + self.dismiss(True) + + @on(Button.Pressed, "#no") + def action_no(self) -> None: + """Send back the negative response.""" + self.dismiss(False) + + +### confirm.py ends here From 039678ad72c9a9293f37325c0dbfdcea8c652da4 Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Wed, 29 Jan 2025 16:52:16 +0000 Subject: [PATCH 4/5] :sparkles: Confirm with the user when they ask to overwrite a file --- src/peplum/app/screens/pep_viewer.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/peplum/app/screens/pep_viewer.py b/src/peplum/app/screens/pep_viewer.py index 439b7f3..63e07a3 100644 --- a/src/peplum/app/screens/pep_viewer.py +++ b/src/peplum/app/screens/pep_viewer.py @@ -21,6 +21,7 @@ from ...peps import API from ..data import PEP, cache_dir from ..widgets import TextViewer +from .confirm import Confirm ############################################################################## @@ -162,6 +163,12 @@ async def action_copy(self) -> None: async def action_save(self) -> None: """Save the source of the PEP to a file.""" if target := await self.app.push_screen_wait(FileSave()): + if target.exists() and not await self.app.push_screen_wait( + Confirm( + "Overwrite?", f"{target}\n\nAre you sure you want to overwrite?" + ) + ): + return try: target.write_text(self.query_one(TextArea).text, encoding="utf-8") except IOError as error: From a13e1e197decf36f721c4dbce8a3e29fb7c696e3 Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Wed, 29 Jan 2025 16:54:38 +0000 Subject: [PATCH 5/5] :books: Update the ChangeLog --- ChangeLog.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/ChangeLog.md b/ChangeLog.md index f347c16..5eab1ea 100644 --- a/ChangeLog.md +++ b/ChangeLog.md @@ -11,6 +11,8 @@ ([#18](https://github.com/davep/peplum/pull/18)) - Dropped Python 3.8 as a supported Python version. ([#19](https://github.com/davep/peplum/pull/19)) +- Added support for saving the source of a PEP to a file. + (#20[](https://github.com/davep/peplum/pull/20)) ## v0.2.0