diff --git a/README.md b/README.md index cd9f278..37816ad 100644 --- a/README.md +++ b/README.md @@ -12,21 +12,23 @@ This implementation utilizes Python and Textual -- but no ChatGPT. ## Features -* discover podcasts through iTunes-based search -* play podcast episodes directly from source -* pause podcasts during play -* *more (still in development)* +- discover podcasts through iTunes-based search +- play podcast episodes directly from source +- pause podcasts during play +- *more (still in development)* ## Installation and Running You must: -* have Python installed -* have `pip` installed (if not included with Python) -* Use: - * a virtual environment - * or, install Python modules from your environment (e.g. apt repository) - * or, use `--break-system-packages` +- have Python installed +- have `pip` installed (if not included with Python) +- Use: + - a virtual environment + - or, install Python modules from your environment (e.g. apt repository) + - or, use `--break-system-packages` + +*If you are using Windows, you may need the **Visual Studio Build Tools** to build `miniaudio` during the `pip` install.* ```bash $ git clone https://github.com/mwhickson/tuipod.git @@ -38,24 +40,25 @@ Also provided are batch (`tuipod.bat`) and shell (`tuipod.sh`) files to simplify ## Usage Guide -* type search criteria into the search box and press `ENTER` to fetch and display podcast results - * if no errors occur and podcast results are found, focus will automatically shift to podcast list -* select a podcast item of interest and press `ENTER` to fetch and display a list of episodes for the selected podcast - * if no errors occur and episode results are found, focus will automatically shift to the episode list -* select an episode item of interest and press `ENTER` to begin playing the episode - * if no errors occur, the episode will being playing (the `play` button text will change to `pause` and the button will become green) +- type search criteria into the search box and press `ENTER` to fetch and display podcast results + - if no errors occur and podcast results are found, focus will automatically shift to podcast list +- select a podcast item of interest and press `ENTER` to fetch and display a list of episodes for the selected podcast + - if no errors occur and episode results are found, focus will automatically shift to the episode list +- select an episode item of interest and press `ENTER` to begin playing the episode + - if no errors occur, the episode will being playing (the `play` button text will change to `pause` and the button will become green) ### Additional keyboard shortcuts: -* `TAB` and `SHIFT`+`TAB` will move the cursor focus between sections (e.g. search, podcast list, and episode list) -* while not focused on the search input: - * `q` will quit the application - * `CTRL`+`p` will show the textual command palette - * when an episode is selected: - * `SPACE` will toggle between playing and paused - * `i` will show the episode information -* when a modal screen (episode information/error information) is displayed: - * `ESC` will close the window +- `ESC` will close a modal window +- `F1` will show the About dialog +- `TAB` and `SHIFT`+`TAB` will move the cursor focus between sections (e.g. search, podcast list, and episode list) +- `CTRL`+`Q` will quit the application +- `CTRL`+`P` will show the textual command palette +- when an episode is selected: + - `I` will show the episode information + - `SPACE` will toggle between episode playing/paused state + +*NOTE: Some keystrokes depend on application state (e.g. not actively searching, episode playing, etc.)* ## Screenshots diff --git a/tuipod/ui/about_info.py b/tuipod/ui/about_info.py new file mode 100644 index 0000000..ca98361 --- /dev/null +++ b/tuipod/ui/about_info.py @@ -0,0 +1,103 @@ +from textual.app import ComposeResult +from textual.containers import Container +from textual.screen import ModalScreen +from textual.widgets import Button, MarkdownViewer, Static + + +class AboutInfoScreen(ModalScreen): + BINDINGS = [ + ("escape", "close_modal", "Close modal") + ] + + DEFAULT_CSS = """ + AboutInfoScreen { + align: center middle; + height: auto; + width: auto; + } + + #modalContainer { + height: 60%; + width: 80%; + } + + #modalTitle { + background: $secondary; + color: $background; + dock: top; + text-align: center; + } + + #contentContainer { + padding: 1; + } + + #aboutDetail { + padding: 1 0; + height: 1fr; + } + + #buttonContainer { + dock: bottom; + height: 3; + padding: 1 2; + } + + #closeInfoButton { + background: $secondary; + border: none; + color: $background; + } + """ + + # indentation modified to avoid leading spaces, which would cause markdown to be processed as preformatted text (i.e. shown as raw markdown) + # NOPE: also, for simplicity, omitting the conditional, non-CTRL options out (condition users to do it the way that will work everywhere) + # CTRL+SPACE doesn't work; CTRL+I behaves like TAB; CTRL+Q seems okay... + ABOUT_INFO = """\ +## tuipod + +A simple podcast player with a text-based user interface. + +Available at [github.com/mwhickson/tuipod](https://github.com/mwhickson/tuipod) + +## Keystrokes + +- `ENTER` - submit a search query, or select a list item +- `ESC` - close a modal screen +- `F1` - show this dialog +- `CTRL` + `P` - show the textual command palette +- `CTRL` + `Q` - quit the application +- `D` - toggle dark mode +- `I` - show episode information +- `SPACE` - play/pause an episode after selection +- `TAB` / `SHIFT` + `TAB` - move cursor from section to section + +NOTE: Some keystrokes depend on application state (e.g. not actively searching, episode playing, etc.) +""" + +# TODO: - `S` - subscribe to the current podcast + + def __init__(self) -> None: + super().__init__() + self.detail = self.ABOUT_INFO + + def compose(self) -> ComposeResult: + yield Container( + Static("About", id="modalTitle"), + Container( + MarkdownViewer(id="aboutDetail", markdown=self.detail, show_table_of_contents=False), + id="contentContainer" + ), + Container( + Button("close", id="closeInfoButton"), + id="buttonContainer" + ), + id="modalContainer" + ) + + def on_button_pressed(self, event: Button.Pressed) -> None: + if event.button.id == "closeInfoButton": + self.app.pop_screen() + + def action_close_modal(self) -> None: + self.app.pop_screen() diff --git a/tuipod/ui/episode_info.py b/tuipod/ui/episode_info.py index 37a1dc4..fd6b6c2 100644 --- a/tuipod/ui/episode_info.py +++ b/tuipod/ui/episode_info.py @@ -1,7 +1,7 @@ from textual.app import ComposeResult from textual.containers import Container from textual.screen import ModalScreen -from textual.widgets import Button, Link, Markdown, Static +from textual.widgets import Button, Link, MarkdownViewer, Static class EpisodeInfoScreen(ModalScreen): @@ -72,7 +72,7 @@ def compose(self) -> ComposeResult: Container( Static(self.title, id="episodeTitle"), Link(self.url, id="episodeLink"), - Markdown(self.detail, id="episodeDetail"), + MarkdownViewer(id="episodeDetail", markdown=self.detail, show_table_of_contents=False), id="contentContainer" ), Container( diff --git a/tuipod/ui/error_info.py b/tuipod/ui/error_info.py index 1207680..2992df3 100644 --- a/tuipod/ui/error_info.py +++ b/tuipod/ui/error_info.py @@ -1,7 +1,7 @@ from textual.app import ComposeResult from textual.containers import Container from textual.screen import ModalScreen -from textual.widgets import Button, Markdown, Static +from textual.widgets import Button, MarkdownViewer, Static class ErrorInfoScreen(ModalScreen): @@ -58,7 +58,7 @@ def compose(self) -> ComposeResult: yield Container( Static("Error Information", id="modalTitle"), Container( - Markdown(self.detail, id="errorDetail"), + MarkdownViewer(id="errorDetail", markdown=self.detail, show_table_of_contents=False), id="contentContainer" ), Container( diff --git a/tuipod/ui/podcast_app.py b/tuipod/ui/podcast_app.py index 50b3093..933607e 100644 --- a/tuipod/ui/podcast_app.py +++ b/tuipod/ui/podcast_app.py @@ -2,9 +2,11 @@ from textual import on from textual.app import App, ComposeResult +from textual.binding import Binding from textual.widgets import Button, DataTable, Header, Input, Static from tuipod.models.search import Search +from tuipod.ui.about_info import AboutInfoScreen from tuipod.ui.episode_info import EpisodeInfoScreen from tuipod.ui.episode_list import EpisodeList from tuipod.ui.error_info import ErrorInfoScreen @@ -17,10 +19,17 @@ class PodcastApp(App): BINDINGS = [ - ("space", "toggle_play", "Play/Pause"), - ("d", "toggle_dark", "Toggle dark mode"), - ("i", "display_info", "Display information"), - ("q", "quit_application", "Quit application") + Binding("f1", "display_about", "Display about information", priority=True), + + # don't let search swallow input (but don't prioritize standard text entry characters to hamper search) + Binding("ctrl+q", "quit_application", "Quit application", priority=True), + + # dupes, but lets us avoid the need to CTRL chord keys for the most part (these will be 'undocumented' to avoid confusion re: the conditions necessary for these to work) + # TODO: Binding("space", "toggle_play", "Play/Pause"), + Binding("d", "toggle_dark", "Toggle dark mode"), + Binding("i", "display_info", "Display information"), + Binding("q", "quit_application", "Quit application"), + Binding("s", "subscribe_to_podcast", "Subscribe to Podcast") ] TITLE = APPLICATION_NAME SUB_TITLE = "version {0}".format(APPLICATION_VERSION) @@ -140,9 +149,15 @@ def action_toggle_play(self) -> None: play_button.styles.background = "green" play_button.styles.color = "white" + def action_display_about(self) -> None: + self.app.push_screen(AboutInfoScreen()) + def action_display_info(self) -> None: if not self.current_episode is None: self.app.push_screen(EpisodeInfoScreen(self.current_episode.title, self.current_episode.url, self.current_episode.description)) def action_quit_application(self) -> None: self.exit() + + # def action_subscribe_to_podcast(self) -> None: + # pass