├── .gitignore ├── .pre-commit-config.yaml ├── .pylintrc ├── ChangeLog.md ├── LICENSE ├── Makefile ├── README.md ├── frogmouth ├── __init__.py ├── __main__.py ├── app │ ├── __init__.py │ └── app.py ├── data │ ├── __init__.py │ ├── bookmarks.py │ ├── config.py │ ├── data_directory.py │ └── history.py ├── dialogs │ ├── __init__.py │ ├── error.py │ ├── help_dialog.py │ ├── information.py │ ├── input_dialog.py │ ├── text_dialog.py │ └── yes_no_dialog.py ├── screens │ ├── __init__.py │ └── main.py ├── utility │ ├── __init__.py │ ├── advertising.py │ ├── forge.py │ └── type_tests.py └── widgets │ ├── __init__.py │ ├── navigation.py │ ├── navigation_panes │ ├── __init__.py │ ├── bookmarks.py │ ├── history.py │ ├── local_files.py │ ├── navigation_pane.py │ └── table_of_contents.py │ ├── omnibox.py │ └── viewer.py ├── poetry.lock └── pyproject.toml /.gitignore: -------------------------------------------------------------------------------- 1 | __pycache__ 2 | dist/ 3 | 4 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/psf/black 3 | rev: 23.3.0 4 | hooks: 5 | - id: black 6 | -------------------------------------------------------------------------------- /.pylintrc: -------------------------------------------------------------------------------- 1 | [FORMAT] 2 | max-line-length=120 3 | -------------------------------------------------------------------------------- /ChangeLog.md: -------------------------------------------------------------------------------- 1 | # Frogmouth ChangeLog 2 | 3 | ## Unreleased 4 | 5 | ### Added 6 | 7 | - Front matter is now ignored when viewing a file. 8 | [#15](https://github.com/Textualize/frogmouth/issues/15) 9 | - Added support for jumping to an internal anchor. 10 | [#91](https://github.com/Textualize/frogmouth/issues/91) 11 | 12 | ## [0.9.2] - 2023-11-28 13 | 14 | ### Changed 15 | 16 | - Bumped to Textual v0.41.0. 17 | 18 | ## [0.9.1] - 2023-11-02 19 | 20 | ### Changed 21 | 22 | - Bumped to Textual v0.41.0. 23 | 24 | ## [0.9.0] - 2023-08-07 25 | 26 | ### Fixed 27 | 28 | - Fixed local documents no longer loading. 29 | [#74](https://github.com/Textualize/frogmouth/issues/74) 30 | 31 | ## [0.8.0] - 2023-07-20 32 | 33 | ### Changed 34 | 35 | - Updated to work with [Textual](https://github.com/Textualize/textual) 36 | v0.30.0 or greater. 37 | 38 | ### Fixed 39 | 40 | - Fixed the look and scrolling of the history navigation pane after recent 41 | changes to base Textual styling broke it. 42 | [#66](https://github.com/Textualize/frogmouth/issues/66) 43 | - Fixed the look and scrolling of the bookmark navigation pane after recent 44 | changes to base Textual styling broke it. 45 | [#66](https://github.com/Textualize/frogmouth/issues/66) 46 | 47 | ## [0.7.0] - 2023-06-27 48 | 49 | ### Added 50 | 51 | - Added support for using Ctrl+r to reload the current 52 | document. 53 | 54 | ### Fixed 55 | 56 | - Added some extra error capture when attempting to build a forge URL while 57 | inferring the main branch name. 58 | - Fixed following local file links where the file is document-relative and 59 | you're visiting with a CWD other than the document's. 60 | [#52](https://github.com/Textualize/frogmouth/issues/52) 61 | 62 | ## [0.6.0] - 2023-05-24 63 | 64 | ### Added 65 | 66 | - Added Codeberg as a recognised forge for the "forge quick view". 67 | 68 | ### Changed 69 | 70 | - Relaxed the required [Textual](https://github.com/Textualize/textual) 71 | dependency version requirement. 72 | 73 | ## [0.5.0] - 2023-05-08 74 | 75 | ### Changed 76 | 77 | - Updated to work with [Textual](https://github.com/Textualize/textual) v0.24.0. 78 | 79 | ### Added 80 | 81 | - Added a `changelog` command -- loads the Frogmouth ChangeLog from the 82 | repository for viewing. 83 | - Added the ability to delete a single item of history from the history 84 | list. ([#34](https://github.com/Textualize/frogmouth/pull/34)) 85 | - Added the ability to clear down the whole of history. 86 | ([#34](https://github.com/Textualize/frogmouth/pull/34)) 87 | - Added toggling the navigation sidebar between left or right dock. 88 | ([#37](https://github.com/Textualize/frogmouth/pull/37)) 89 | 90 | ### Changed 91 | 92 | - Calling any navigation pane is now a toggle operation. If it isn't 93 | visible, it's made visible; if it's visible, the navigation sidebar is 94 | closed. 95 | 96 | ## [0.4.0] - 2023-05-03 97 | 98 | ### Added 99 | 100 | - Added support for using j and k to scroll through 101 | the document. 102 | - Added support for using w and s to scroll through the document. 103 | - Added support for space to scroll through the document. 104 | - Added : as a keypress for quickly getting to the input bar. 105 | ([#19](https://github.com/Textualize/frogmouth/pull/19)) 106 | 107 | ### Changed 108 | 109 | - Internal changes to the workings of the input dialogs, using the newer 110 | Textual screen result returning facility. 111 | ([#23](https://github.com/Textualize/frogmouth/pull/23)) 112 | 113 | ## [0.3.2] - 2023-04-30 114 | 115 | - Initial release 116 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Textualize, Inc 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to 7 | deal in the Software without restriction, including without limitation the 8 | rights to use, copy, modify, merge, publish, distribute, sublicense, and/or 9 | sell copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING 20 | FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS 21 | IN THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | ############################################################################## 2 | # Common make values. 3 | package := frogmouth 4 | run := poetry run 5 | python := $(run) python 6 | textual := $(run) textual 7 | lint := $(run) pylint 8 | mypy := $(run) mypy 9 | black := $(run) black 10 | isort := $(run) isort 11 | 12 | ############################################################################## 13 | # Methods of running the application. 14 | .PHONY: run 15 | run: # Run the application 16 | $(python) -m $(package) 17 | 18 | .PHONY: debug 19 | debug: # Run the application in debug mode 20 | TEXTUAL=devtools make run 21 | 22 | ############################################################################## 23 | # Setup/update packages the system requires. 24 | .PHONY: setup 25 | setup: # Set up the development environment 26 | poetry install 27 | $(run) pre-commit install 28 | 29 | .PHONY: update 30 | update: # Update the development environment 31 | poetry update 32 | 33 | ############################################################################## 34 | # Package building and distribution. 35 | .PHONY: build 36 | build: # Build the package for distribution 37 | poetry build 38 | 39 | .PHONY: clean 40 | clean: # Clean up the package builds 41 | rm -rf dist 42 | 43 | ############################################################################## 44 | # Textual tools. 45 | .PHONY: borders 46 | borders: # Preview the Textual borders 47 | $(textual) borders 48 | 49 | .PHONY: colours 50 | colours: # Preview the Textual colours 51 | $(textual) colors 52 | 53 | .PHONY: colour colors color 54 | colour: colours 55 | colors: colours 56 | color: colours 57 | 58 | .PHONY: console 59 | console: # Run the textual console 60 | $(textual) console 61 | 62 | .PHONY: diagnose 63 | diagnose: # Print the Textual diagnosis information 64 | $(textual) diagnose 65 | 66 | .PHONY: easing 67 | easing: # Preview the Textual easing functions 68 | $(textual) easing 69 | 70 | .PHONY: keys 71 | keys: # Run the textual keys utility 72 | $(textual) keys 73 | 74 | ############################################################################## 75 | # Reformatting tools. 76 | .PHONY: black 77 | black: # Run black over the code 78 | $(black) $(package) 79 | 80 | .PHONY: isort 81 | isort: # Run isort over the code 82 | $(isort) --profile black $(package) 83 | 84 | .PHONY: reformat 85 | reformat: isort black # Run all the formatting tools over the code 86 | 87 | ############################################################################## 88 | # Checking/testing/linting/etc. 89 | .PHONY: lint 90 | lint: # Run Pylint over the library 91 | $(lint) $(package) 92 | 93 | .PHONY: typecheck 94 | typecheck: # Perform static type checks with mypy 95 | $(mypy) --scripts-are-modules $(package) 96 | 97 | .PHONY: stricttypecheck 98 | stricttypecheck: # Perform strict static type checks with mypy 99 | $(mypy) --scripts-are-modules --strict $(package) 100 | 101 | .PHONY: checkall 102 | checkall: lint stricttypecheck # Check all the things 103 | 104 | ############################################################################## 105 | # Utility. 106 | .PHONY: repl 107 | repl: # Start a Python REPL 108 | $(python) 109 | 110 | .PHONY: shell 111 | shell: # Create a shell within the virtual environment 112 | poetry shell 113 | 114 | .PHONY: help 115 | help: # Display this help 116 | @grep -Eh "^[a-z]+:.+# " $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.+# "}; {printf "%-20s %s\n", $$1, $$2}' 117 | 118 | ############################################################################## 119 | # Housekeeping tasks. 120 | .PHONY: housekeeping 121 | housekeeping: # Perform some git housekeeping 122 | git fsck 123 | git gc --aggressive 124 | git remote update --prune 125 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 |

3 | 4 |

5 | 6 | [![Discord](https://img.shields.io/discord/1026214085173461072)](https://discord.gg/Enf6Z3qhVr) 7 | 8 | 9 | 10 | # Frogmouth 11 | 12 | 13 | Frogmouth is a Markdown viewer / browser for your terminal, built with [Textual](https://github.com/Textualize/textual). 14 | 15 | Frogmouth can open `*.md` files locally or via a URL. 16 | There is a familiar browser-like navigation stack, history, bookmarks, and table of contents. 17 | 18 |
19 | 🎬 Demonstration 20 |
21 | 22 | A quick video tour of Frogmouth. 23 | 24 | 25 | 26 | 27 | https://user-images.githubusercontent.com/554369/235305502-2699a70e-c9a6-495e-990e-67606d84bbfa.mp4 28 | 29 | (thanks [Screen Studio](https://www.screen.studio/)) 30 | 31 | 32 |
33 | 34 | ## Screenshots 35 | 36 | 37 | 38 | 39 | 42 | 43 | 46 | 47 | 48 | 49 | 50 | 53 | 54 | 57 | 58 | 59 | 60 |
40 | Screenshot 2023-04-28 at 15 14 53 41 | 44 | Screenshot 2023-04-28 at 15 17 56 45 |
51 | Screenshot 2023-04-28 at 15 18 36 52 | 55 | Screenshot 2023-04-28 at 15 16 39 56 |
61 | 62 | 63 | ## Compatibility 64 | 65 | Frogmouth runs on Linux, macOS, and Windows. Frogmouth requires Python **3.8** or above. 66 | 67 | 68 | ## Installing 69 | 70 | The easiest way to install Frogmouth is with [pipx](https://pypa.github.io/pipx/) (particularly if you aren't a Python developer). 71 | 72 | ``` 73 | pipx install frogmouth 74 | ``` 75 | 76 | You can also install Frogmouth with `pip`: 77 | 78 | ``` 79 | pip install frogmouth 80 | ``` 81 | 82 | On systems using Homebrew, you can tap into the Textualize tap and install Frogmouth with `brew`: 83 | 84 | ``` 85 | brew tap textualize/homebrew 86 | brew install frogmouth 87 | ``` 88 | 89 | Whichever method you use, you should have a `frogmouth` command on your path. 90 | 91 | ## Running 92 | 93 | Enter `frogmouth` at the prompt to run the app, optionally followed by a path to a Markdown file: 94 | 95 | ``` 96 | frogmouth README.md 97 | ``` 98 | 99 | You can navigate with the mouse or the keyboard. 100 | Use tab and shift+tab to navigate between the various controls on screen. 101 | 102 | ## Features 103 | 104 | You can load README files direct from GitHub repositories with the `gh` command. 105 | Use the following syntax: 106 | 107 | ``` 108 | frogmouth gh textualize/textual 109 | ``` 110 | 111 | This also works with the address bar in the app. 112 | See the help (F1) in the app for details. 113 | 114 | ## Follow this project 115 | 116 | If this app interests you, you may want to join the Textual [Discord server](https://discord.gg/Enf6Z3qhVr). 117 | -------------------------------------------------------------------------------- /frogmouth/__init__.py: -------------------------------------------------------------------------------- 1 | """A terminal-based Markdown document viewer, written in Textual.""" 2 | 3 | __author__ = "Textualize, Inc" 4 | __copyright__ = "Copyright Textualize, Inc" 5 | __credits__ = ["Dave Pearson"] 6 | __maintainer__ = "Dave Pearson" 7 | __email__ = "dave@textualize.io" 8 | __version__ = "0.9.1" 9 | __licence__ = "MIT" 10 | -------------------------------------------------------------------------------- /frogmouth/__main__.py: -------------------------------------------------------------------------------- 1 | """The package entry point into the application.""" 2 | 3 | from .app import run 4 | 5 | if __name__ == "__main__": 6 | run() 7 | -------------------------------------------------------------------------------- /frogmouth/app/__init__.py: -------------------------------------------------------------------------------- 1 | """The main application code.""" 2 | 3 | from .app import run 4 | 5 | __all__ = ["run"] 6 | -------------------------------------------------------------------------------- /frogmouth/app/app.py: -------------------------------------------------------------------------------- 1 | """The main application class for the viewer.""" 2 | 3 | from argparse import ArgumentParser, Namespace 4 | from webbrowser import open as open_url 5 | 6 | from textual import __version__ as textual_version # pylint: disable=no-name-in-module 7 | from textual.app import App 8 | 9 | from .. import __version__ 10 | from ..data import load_config 11 | from ..screens import Main 12 | from ..utility.advertising import APPLICATION_TITLE, PACKAGE_NAME 13 | 14 | 15 | class MarkdownViewer(App[None]): 16 | """The main application class.""" 17 | 18 | TITLE = APPLICATION_TITLE 19 | """The main title for the application.""" 20 | 21 | ENABLE_COMMAND_PALETTE = False 22 | 23 | def __init__(self, cli_args: Namespace) -> None: 24 | """Initialise the application. 25 | 26 | Args: 27 | cli_args: The command line arguments. 28 | """ 29 | super().__init__() 30 | self._args = cli_args 31 | self.dark = not load_config().light_mode 32 | 33 | def on_mount(self) -> None: 34 | """Set up the application after the DOM is ready.""" 35 | self.push_screen(Main(" ".join(self._args.file) if self._args.file else None)) 36 | 37 | def action_visit(self, url: str) -> None: 38 | """Visit the given URL, via the operating system. 39 | 40 | Args: 41 | url: The URL to visit. 42 | """ 43 | open_url(url) 44 | 45 | 46 | def get_args() -> Namespace: 47 | """Parse and return the command line arguments. 48 | 49 | Returns: 50 | The result of parsing the arguments. 51 | """ 52 | 53 | # Create the parser object. 54 | parser = ArgumentParser( 55 | prog=PACKAGE_NAME, 56 | description=f"{APPLICATION_TITLE} -- A Markdown viewer for the terminal.", 57 | epilog=f"v{__version__}", 58 | ) 59 | 60 | # Add --version 61 | parser.add_argument( 62 | "-v", 63 | "--version", 64 | help="Show version information.", 65 | action="version", 66 | version=f"%(prog)s {__version__} (Textual v{textual_version})", 67 | ) 68 | 69 | # The remainder is the file to view. 70 | parser.add_argument("file", help="The Markdown file to view", nargs="*") 71 | 72 | # Finally, parse the command line. 73 | return parser.parse_args() 74 | 75 | 76 | def run() -> None: 77 | """Run the application.""" 78 | MarkdownViewer(get_args()).run() 79 | -------------------------------------------------------------------------------- /frogmouth/data/__init__.py: -------------------------------------------------------------------------------- 1 | """Provides tools for saving and loading application data.""" 2 | 3 | from .bookmarks import Bookmark, load_bookmarks, save_bookmarks 4 | from .config import Config, load_config, save_config 5 | from .history import load_history, save_history 6 | 7 | __all__ = [ 8 | "Bookmark", 9 | "Config", 10 | "load_bookmarks", 11 | "load_config", 12 | "load_history", 13 | "save_bookmarks", 14 | "save_config", 15 | "save_history", 16 | ] 17 | -------------------------------------------------------------------------------- /frogmouth/data/bookmarks.py: -------------------------------------------------------------------------------- 1 | """Provides code for saving and loading bookmarks.""" 2 | 3 | from __future__ import annotations 4 | 5 | from json import JSONEncoder, dumps, loads 6 | from pathlib import Path 7 | from typing import Any, NamedTuple 8 | 9 | from httpx import URL 10 | 11 | from ..utility import is_likely_url 12 | from .data_directory import data_directory 13 | 14 | 15 | class Bookmark(NamedTuple): 16 | """A bookmark.""" 17 | 18 | title: str 19 | """The title of the bookmark.""" 20 | location: Path | URL 21 | """The location of the bookmark.""" 22 | 23 | 24 | def bookmarks_file() -> Path: 25 | """Get the location of the bookmarks file. 26 | 27 | Returns: 28 | The location of the bookmarks file. 29 | """ 30 | return data_directory() / "bookmarks.json" 31 | 32 | 33 | class BookmarkEncoder(JSONEncoder): 34 | """JSON encoder for the bookmark data.""" 35 | 36 | def default(self, o: object) -> Any: 37 | """Handle the Path and URL values. 38 | 39 | Args: 40 | o: The object to handle. 41 | 42 | Return: 43 | The encoded object. 44 | """ 45 | return str(o) if isinstance(o, (Path, URL)) else o 46 | 47 | 48 | def save_bookmarks(bookmarks: list[Bookmark]) -> None: 49 | """Save the given bookmarks. 50 | 51 | Args: 52 | bookmarks: The bookmarks to save. 53 | """ 54 | bookmarks_file().write_text(dumps(bookmarks, indent=4, cls=BookmarkEncoder)) 55 | 56 | 57 | def load_bookmarks() -> list[Bookmark]: 58 | """Load the bookmarks. 59 | 60 | Returns: 61 | The bookmarks. 62 | """ 63 | return ( 64 | [ 65 | Bookmark( 66 | title, URL(location) if is_likely_url(location) else Path(location) 67 | ) 68 | for (title, location) in loads(bookmarks.read_text()) 69 | ] 70 | if (bookmarks := bookmarks_file()).exists() 71 | else [] 72 | ) 73 | -------------------------------------------------------------------------------- /frogmouth/data/config.py: -------------------------------------------------------------------------------- 1 | """Provides code for loading/saving configuration.""" 2 | 3 | from __future__ import annotations 4 | 5 | from dataclasses import asdict, dataclass, field 6 | from functools import lru_cache 7 | from json import dumps, loads 8 | from pathlib import Path 9 | 10 | from xdg import xdg_config_home 11 | 12 | from ..utility.advertising import ORGANISATION_NAME, PACKAGE_NAME 13 | 14 | 15 | @dataclass 16 | class Config: 17 | """The markdown viewer configuration.""" 18 | 19 | light_mode: bool = False 20 | """Should we run in light mode?""" 21 | 22 | markdown_extensions: list[str] = field(default_factory=lambda: [".md", ".markdown"]) 23 | """What Markdown extensions will we look for?""" 24 | 25 | navigation_left: bool = True 26 | """Should navigation be docked to the left side of the screen?""" 27 | 28 | 29 | def config_file() -> Path: 30 | """Get the path to the configuration file. 31 | 32 | Returns: 33 | The path to the configuration file. 34 | 35 | Note: 36 | As a side-effect, the configuration directory will be created if it 37 | does not exist. 38 | """ 39 | (config_dir := xdg_config_home() / ORGANISATION_NAME / PACKAGE_NAME).mkdir( 40 | parents=True, exist_ok=True 41 | ) 42 | return config_dir / "configuration.json" 43 | 44 | 45 | def save_config(config: Config) -> Config: 46 | """Save the given configuration to storage. 47 | 48 | Args: 49 | config: The configuration to save. 50 | 51 | Returns: 52 | The configuration. 53 | """ 54 | # Ensure any cached copy of the config is cleaned up. 55 | load_config.cache_clear() 56 | # Dump the given config to storage. 57 | config_file().write_text(dumps(asdict(config), indent=4)) 58 | # Finally, load it up again. This is to make sure that the updated 59 | # version is in the cache. 60 | return load_config() 61 | 62 | 63 | @lru_cache(maxsize=None) 64 | def load_config() -> Config: 65 | """Load the configuration from storage. 66 | 67 | Returns: 68 | The configuration. 69 | 70 | Note: 71 | As a side-effect, if the configuration doesn't exist a default one 72 | will be saved to storage. 73 | 74 | This function is designed so that it's safe and low-cost to 75 | repeatedly call it. The configuration is cached and will only be 76 | loaded from storage when necessary. 77 | """ 78 | source_file = config_file() 79 | return ( 80 | Config(**loads(source_file.read_text())) 81 | if source_file.exists() 82 | else save_config(Config()) 83 | ) 84 | -------------------------------------------------------------------------------- /frogmouth/data/data_directory.py: -------------------------------------------------------------------------------- 1 | """Provides a function for working out the data directory location.""" 2 | 3 | from pathlib import Path 4 | 5 | from xdg import xdg_data_home 6 | 7 | from ..utility.advertising import ORGANISATION_NAME, PACKAGE_NAME 8 | 9 | 10 | def data_directory() -> Path: 11 | """Get the location of the data directory. 12 | 13 | Returns: 14 | The location of the data directory. 15 | 16 | Note: 17 | As a side effect, if the directory doesn't exist it will be created. 18 | """ 19 | (target_directory := xdg_data_home() / ORGANISATION_NAME / PACKAGE_NAME).mkdir( 20 | parents=True, exist_ok=True 21 | ) 22 | return target_directory 23 | -------------------------------------------------------------------------------- /frogmouth/data/history.py: -------------------------------------------------------------------------------- 1 | """Provides code for saving and loading the history.""" 2 | 3 | from __future__ import annotations 4 | 5 | from json import JSONEncoder, dumps, loads 6 | from pathlib import Path 7 | from typing import Any 8 | 9 | from httpx import URL 10 | 11 | from ..utility import is_likely_url 12 | from .data_directory import data_directory 13 | 14 | 15 | def history_file() -> Path: 16 | """Get the location of the history file. 17 | 18 | Returns: 19 | The location of the history file. 20 | """ 21 | return data_directory() / "history.json" 22 | 23 | 24 | class HistoryEncoder(JSONEncoder): 25 | """JSON encoder for the history data.""" 26 | 27 | def default(self, o: object) -> Any: 28 | """Handle the Path and URL values. 29 | 30 | Args: 31 | o: The object to handle. 32 | 33 | Return: 34 | The encoded object. 35 | """ 36 | return str(o) if isinstance(o, (Path, URL)) else o 37 | 38 | 39 | def save_history(history: list[Path | URL]) -> None: 40 | """Save the given history. 41 | 42 | Args: 43 | history: The history to save. 44 | """ 45 | history_file().write_text(dumps(history, indent=4, cls=HistoryEncoder)) 46 | 47 | 48 | def load_history() -> list[Path | URL]: 49 | """Load the history. 50 | 51 | Returns: 52 | The history. 53 | """ 54 | return ( 55 | [ 56 | URL(location) if is_likely_url(location) else Path(location) 57 | for location in loads(history.read_text()) 58 | ] 59 | if (history := history_file()).exists() 60 | else [] 61 | ) 62 | -------------------------------------------------------------------------------- /frogmouth/dialogs/__init__.py: -------------------------------------------------------------------------------- 1 | """Provides useful dialogs for the application.""" 2 | 3 | from .error import ErrorDialog 4 | from .help_dialog import HelpDialog 5 | from .information import InformationDialog 6 | from .input_dialog import InputDialog 7 | from .yes_no_dialog import YesNoDialog 8 | 9 | __all__ = [ 10 | "ErrorDialog", 11 | "InformationDialog", 12 | "InputDialog", 13 | "HelpDialog", 14 | "YesNoDialog", 15 | ] 16 | -------------------------------------------------------------------------------- /frogmouth/dialogs/error.py: -------------------------------------------------------------------------------- 1 | """Provides an error dialog.""" 2 | 3 | from textual.widgets._button import ButtonVariant 4 | 5 | from .text_dialog import TextDialog 6 | 7 | 8 | class ErrorDialog(TextDialog): 9 | """Modal dialog for showing errors.""" 10 | 11 | DEFAULT_CSS = """ 12 | ErrorDialog > Vertical { 13 | background: $error 15%; 14 | border: thick $error 50%; 15 | } 16 | 17 | ErrorDialog #message { 18 | border-top: solid $panel; 19 | border-bottom: solid $panel; 20 | } 21 | """ 22 | 23 | @property 24 | def button_style(self) -> ButtonVariant: 25 | """The style for the dialog's button.""" 26 | return "error" 27 | -------------------------------------------------------------------------------- /frogmouth/dialogs/help_dialog.py: -------------------------------------------------------------------------------- 1 | """The main help dialog for the application.""" 2 | 3 | import webbrowser 4 | 5 | from textual.app import ComposeResult 6 | from textual.binding import Binding 7 | from textual.containers import Center, Vertical, VerticalScroll 8 | from textual.screen import ModalScreen 9 | from textual.widgets import Button, Markdown 10 | from typing_extensions import Final 11 | 12 | from .. import __version__ 13 | from ..utility.advertising import APPLICATION_TITLE 14 | 15 | HELP: Final[ 16 | str 17 | ] = f"""\ 18 | # {APPLICATION_TITLE} v{__version__} Help 19 | 20 | Welcome to {APPLICATION_TITLE} Help! 21 | 22 | {APPLICATION_TITLE} was built with [Textual](https://github.com/Textualize/textual). 23 | 24 | 25 | ## Navigation keys 26 | 27 | | Key | Command | 28 | | -- | -- | 29 | | `/` | Focus the address bar (`ctrl+u` to clear address bar) | 30 | | `Escape` | Return to address bar / clear address bar / quit | 31 | | `Ctrl+n` | Show/hide the navigation | 32 | | `Ctrl+b` | Show the bookmarks | 33 | | `Ctrl+l` | Show the local file browser | 34 | | `Ctrl+t` | Show the table of contents | 35 | | `Ctrl+y` | Show the history | 36 | | `Ctrl+left` | Go backward in history | 37 | | `Ctrl+right` | Go forward in history | 38 | 39 | ## General keys 40 | 41 | | Key | Command | 42 | | -- | -- | 43 | | `Ctrl+d` | Add the current document to the bookmarks | 44 | | `Ctrl+r` | Reload the current document | 45 | | `Ctrl+q` | Quit the application | 46 | | `F1` | This help | 47 | | `F2` | Details about {APPLICATION_TITLE} | 48 | | `F10` | Toggle dark/light theme | 49 | 50 | ## Commands 51 | 52 | Press `/` or click the address bar, then enter any of the following commands: 53 | 54 | | Command | Aliases | Arguments | Command | 55 | | -- | -- | -- | -- | 56 | | `about` | `a` | | Show details about the application | 57 | | `bookmarks` | `b`, `bm` | | Show the bookmarks list | 58 | | `bitbucket` | `bb` | `` | View a file on BitBucket (see below) | 59 | | `codeberg` | `cb` | `` | View a file on Codeberg (see below) | 60 | | `changelog` | `cl` | | View the Frogmouth ChangeLog | 61 | | `chdir` | `cd` | `` | Switch the local file browser to a new directory | 62 | | `contents` | `c`, `toc` | | Show the table of contents for the document | 63 | | `discord` | | | Visit the Textualize Discord server | 64 | | `github` | `gh` | `` | View a file on GitHub (see below) | 65 | | `gitlab` | `gl` | `` | View a file on GitLab (see below) | 66 | | `help` | `?` | | Show this document | 67 | | `history` | `h` | | Show the history | 68 | | `local` | `l` | | Show the local file browser | 69 | | `quit` | `q` | | Quit the viewer | 70 | 71 | ## Git forge quick view 72 | 73 | The git forge quick view command can be used to quickly view a file on a git 74 | forge such as GitHub or GitLab. Various forms of specifying the repository, 75 | branch and file are supported. For example: 76 | 77 | - ``/`` 78 | - ``/`` `` 79 | - `` `` 80 | - `` `` `` 81 | - ``/``:`` 82 | - ``/``:`` `` 83 | - `` ``:`` 84 | - `` ``:`` `` 85 | 86 | Anywhere where `` is omitted it is assumed `README.md` is desired. 87 | 88 | Anywhere where `` is omitted a test is made for the desired file on 89 | first a `main` and then a `master` branch. 90 | """ 91 | """The main help text for the application.""" 92 | 93 | 94 | class HelpDialog(ModalScreen[None]): 95 | """Modal dialog that shows the application's help.""" 96 | 97 | DEFAULT_CSS = """ 98 | HelpDialog { 99 | align: center middle; 100 | } 101 | 102 | HelpDialog > Vertical { 103 | border: thick $primary 50%; 104 | width: 80%; 105 | height: 80%; 106 | background: $boost; 107 | } 108 | 109 | HelpDialog > Vertical > VerticalScroll { 110 | height: 1fr; 111 | margin: 1 2; 112 | } 113 | 114 | HelpDialog > Vertical > Center { 115 | padding: 1; 116 | height: auto; 117 | } 118 | """ 119 | 120 | BINDINGS = [ 121 | Binding("escape,f1", "dismiss(None)", "", show=False), 122 | ] 123 | """Bindings for the help dialog.""" 124 | 125 | def compose(self) -> ComposeResult: 126 | """Compose the help screen.""" 127 | with Vertical(): 128 | with VerticalScroll(): 129 | yield Markdown(HELP) 130 | with Center(): 131 | yield Button("Close", variant="primary") 132 | 133 | def on_mount(self) -> None: 134 | """Configure the help screen once the DOM is ready.""" 135 | # It seems that some things inside Markdown can still grab focus; 136 | # which might not be right. Let's ensure that can't happen here. 137 | self.query_one(Markdown).can_focus_children = False 138 | self.query_one("Vertical > VerticalScroll").focus() 139 | 140 | def on_button_pressed(self) -> None: 141 | """React to button press.""" 142 | self.dismiss(None) 143 | 144 | def on_markdown_link_clicked(self, event: Markdown.LinkClicked) -> None: 145 | """A link was clicked in the help. 146 | 147 | Args: 148 | event: The link click event to handle. 149 | """ 150 | webbrowser.open(event.href) 151 | -------------------------------------------------------------------------------- /frogmouth/dialogs/information.py: -------------------------------------------------------------------------------- 1 | """Provides an information dialog.""" 2 | 3 | from .text_dialog import TextDialog 4 | 5 | 6 | class InformationDialog(TextDialog): 7 | """Modal dialog that shows information.""" 8 | 9 | DEFAULT_CSS = """ 10 | InformationDialog > Vertical { 11 | border: thick $primary 50%; 12 | } 13 | """ 14 | -------------------------------------------------------------------------------- /frogmouth/dialogs/input_dialog.py: -------------------------------------------------------------------------------- 1 | """Provides a modal dialog for getting a value from the user.""" 2 | 3 | from __future__ import annotations 4 | 5 | from textual import on 6 | from textual.app import ComposeResult 7 | from textual.binding import Binding 8 | from textual.containers import Horizontal, Vertical 9 | from textual.screen import ModalScreen 10 | from textual.widgets import Button, Input, Label 11 | 12 | 13 | class InputDialog(ModalScreen[str]): 14 | """A modal dialog for getting a single input from the user.""" 15 | 16 | DEFAULT_CSS = """ 17 | InputDialog { 18 | align: center middle; 19 | } 20 | 21 | InputDialog > Vertical { 22 | background: $panel; 23 | height: auto; 24 | width: auto; 25 | border: thick $primary; 26 | } 27 | 28 | InputDialog > Vertical > * { 29 | width: auto; 30 | height: auto; 31 | } 32 | 33 | InputDialog Input { 34 | width: 40; 35 | margin: 1; 36 | } 37 | 38 | InputDialog Label { 39 | margin-left: 2; 40 | } 41 | 42 | InputDialog Button { 43 | margin-right: 1; 44 | } 45 | 46 | InputDialog #buttons { 47 | width: 100%; 48 | align-horizontal: right; 49 | padding-right: 1; 50 | } 51 | """ 52 | """The default styling for the input dialog.""" 53 | 54 | BINDINGS = [ 55 | Binding("escape", "app.pop_screen", "", show=False), 56 | ] 57 | """Bindings for the dialog.""" 58 | 59 | def __init__(self, prompt: str, initial: str | None = None) -> None: 60 | """Initialise the input dialog. 61 | 62 | Args: 63 | prompt: The prompt for the input. 64 | initial: The initial value for the input. 65 | """ 66 | super().__init__() 67 | self._prompt = prompt 68 | """The prompt to display for the input.""" 69 | self._initial = initial 70 | """The initial value to use for the input.""" 71 | 72 | def compose(self) -> ComposeResult: 73 | """Compose the child widgets.""" 74 | with Vertical(): 75 | with Vertical(id="input"): 76 | yield Label(self._prompt) 77 | yield Input(self._initial or "") 78 | with Horizontal(id="buttons"): 79 | yield Button("OK", id="ok", variant="primary") 80 | yield Button("Cancel", id="cancel") 81 | 82 | def on_mount(self) -> None: 83 | """Set up the dialog once the DOM is ready.""" 84 | self.query_one(Input).focus() 85 | 86 | @on(Button.Pressed, "#cancel") 87 | def cancel_input(self) -> None: 88 | """Cancel the input operation.""" 89 | self.app.pop_screen() 90 | 91 | @on(Input.Submitted) 92 | @on(Button.Pressed, "#ok") 93 | def accept_input(self) -> None: 94 | """Accept and return the input.""" 95 | if value := self.query_one(Input).value.strip(): 96 | self.dismiss(value) 97 | -------------------------------------------------------------------------------- /frogmouth/dialogs/text_dialog.py: -------------------------------------------------------------------------------- 1 | """Provides a base modal dialog for showing text to the user.""" 2 | 3 | from rich.text import TextType 4 | from textual.app import ComposeResult 5 | from textual.binding import Binding 6 | from textual.containers import Center, Vertical 7 | from textual.screen import ModalScreen 8 | from textual.widgets import Button, Static 9 | from textual.widgets._button import ButtonVariant 10 | 11 | 12 | class TextDialog(ModalScreen[None]): 13 | """Base modal dialog for showing information.""" 14 | 15 | DEFAULT_CSS = """ 16 | TextDialog { 17 | align: center middle; 18 | } 19 | 20 | TextDialog Center { 21 | width: 100%; 22 | } 23 | 24 | TextDialog > Vertical { 25 | background: $boost; 26 | min-width: 30%; 27 | width: auto; 28 | height: auto; 29 | border: round $primary; 30 | } 31 | 32 | TextDialog Static { 33 | width: auto; 34 | } 35 | 36 | TextDialog .spaced { 37 | padding: 1 4; 38 | } 39 | 40 | TextDialog #message { 41 | min-width: 100%; 42 | } 43 | """ 44 | """Default CSS for the base text modal dialog.""" 45 | 46 | BINDINGS = [ 47 | Binding("escape", "dismiss(None)", "", show=False), 48 | ] 49 | """Bindings for the base text modal dialog.""" 50 | 51 | def __init__(self, title: TextType, message: TextType) -> None: 52 | """Initialise the dialog. 53 | 54 | Args: 55 | title: The title for the dialog. 56 | message: The message to show. 57 | """ 58 | super().__init__() 59 | self._title = title 60 | self._message = message 61 | 62 | @property 63 | def button_style(self) -> ButtonVariant: 64 | """The style for the dialog's button.""" 65 | return "primary" 66 | 67 | def compose(self) -> ComposeResult: 68 | """Compose the content of the modal dialog.""" 69 | with Vertical(): 70 | with Center(): 71 | yield Static(self._title, classes="spaced") 72 | yield Static(self._message, id="message", classes="spaced") 73 | with Center(classes="spaced"): 74 | yield Button("OK", variant=self.button_style) 75 | 76 | def on_mount(self) -> None: 77 | """Configure the dialog once the DOM has loaded.""" 78 | self.query_one(Button).focus() 79 | 80 | def on_button_pressed(self) -> None: 81 | """Handle the OK button being pressed.""" 82 | self.dismiss(None) 83 | -------------------------------------------------------------------------------- /frogmouth/dialogs/yes_no_dialog.py: -------------------------------------------------------------------------------- 1 | """Provides a dialog for getting a yes/no response from the user.""" 2 | 3 | from __future__ import annotations 4 | 5 | from textual.app import ComposeResult 6 | from textual.binding import Binding 7 | from textual.containers import Center, Horizontal, Vertical 8 | from textual.screen import ModalScreen 9 | from textual.widgets import Button, Static 10 | 11 | 12 | class YesNoDialog(ModalScreen[bool]): 13 | """A dialog for asking a user a yes/no question.""" 14 | 15 | DEFAULT_CSS = """ 16 | YesNoDialog { 17 | align: center middle; 18 | } 19 | 20 | YesNoDialog > Vertical { 21 | background: $panel; 22 | height: auto; 23 | width: auto; 24 | border: thick $primary; 25 | } 26 | 27 | YesNoDialog > Vertical > * { 28 | width: auto; 29 | height: auto; 30 | } 31 | 32 | YesNoDialog Static { 33 | width: auto; 34 | } 35 | 36 | YesNoDialog .spaced { 37 | padding: 1; 38 | } 39 | 40 | YesNoDialog #question { 41 | min-width: 100%; 42 | border-top: solid $primary; 43 | border-bottom: solid $primary; 44 | } 45 | 46 | YesNoDialog Button { 47 | margin-right: 1; 48 | } 49 | 50 | YesNoDialog #buttons { 51 | width: 100%; 52 | align-horizontal: right; 53 | padding-right: 1; 54 | } 55 | """ 56 | """The default CSS for the yes/no dialog.""" 57 | 58 | BINDINGS = [ 59 | Binding("left,up", "focus_previous", "", show=False), 60 | Binding("right,down", "focus_next", "", show=False), 61 | Binding("escape", "app.pop_screen", "", show=False), 62 | ] 63 | """Bindings for the yes/no dialog.""" 64 | 65 | def __init__( # pylint:disable=too-many-arguments 66 | self, 67 | title: str, 68 | question: str, 69 | yes_label: str = "Yes", 70 | no_label: str = "No", 71 | yes_first: bool = True, 72 | ) -> None: 73 | """Initialise the yes/no dialog. 74 | 75 | Args: 76 | requester: The widget requesting the input. 77 | title: The title for the dialog. 78 | question: The question to ask. 79 | yes_label: The optional label for the yes button. 80 | no_label: The optional label for the no button. 81 | yes_first: Should the yes button come first? 82 | cargo: Any cargo value for the question. 83 | id: The ID for the dialog. 84 | """ 85 | super().__init__() 86 | self._title = title 87 | """The title for the dialog.""" 88 | self._question = question 89 | """The question to ask the user.""" 90 | self._aye = yes_label 91 | """The label for the yes button.""" 92 | self._naw = no_label 93 | """The label for the no button.""" 94 | self._aye_first = yes_first 95 | """Should the positive button come first?""" 96 | 97 | def compose(self) -> ComposeResult: 98 | """Compose the content of the dialog.""" 99 | with Vertical(): 100 | with Center(): 101 | yield Static(self._title, classes="spaced") 102 | yield Static(self._question, id="question", classes="spaced") 103 | with Horizontal(id="buttons"): 104 | aye = Button(self._aye, id="yes") 105 | naw = Button(self._naw, id="no") 106 | if self._aye_first: 107 | aye.variant = "primary" 108 | yield aye 109 | yield naw 110 | else: 111 | naw.variant = "primary" 112 | yield naw 113 | yield aye 114 | 115 | def on_mount(self) -> None: 116 | """Configure the dialog once the DOM is ready.""" 117 | self.query(Button).first().focus() 118 | 119 | def on_button_pressed(self, event: Button.Pressed) -> None: 120 | """Handle a button being pressed on the dialog. 121 | 122 | Args: 123 | event: The event to handle. 124 | """ 125 | self.dismiss(event.button.id == "yes") 126 | -------------------------------------------------------------------------------- /frogmouth/screens/__init__.py: -------------------------------------------------------------------------------- 1 | """The screens for the application.""" 2 | 3 | from .main import Main 4 | 5 | __all__ = ["Main"] 6 | -------------------------------------------------------------------------------- /frogmouth/screens/main.py: -------------------------------------------------------------------------------- 1 | """The main screen for the application.""" 2 | 3 | from __future__ import annotations 4 | 5 | from functools import partial 6 | from pathlib import Path 7 | from typing import Awaitable, Callable 8 | from webbrowser import open as open_url 9 | 10 | from httpx import URL 11 | from textual.app import ComposeResult 12 | from textual.binding import Binding 13 | from textual.containers import Horizontal 14 | from textual.events import Paste 15 | from textual.screen import Screen 16 | from textual.widgets import Footer, Markdown 17 | 18 | from .. import __version__ 19 | from ..data import load_config, load_history, save_config, save_history 20 | from ..dialogs import ErrorDialog, HelpDialog, InformationDialog, InputDialog 21 | from ..utility import ( 22 | build_raw_bitbucket_url, 23 | build_raw_codeberg_url, 24 | build_raw_github_url, 25 | build_raw_gitlab_url, 26 | is_likely_url, 27 | maybe_markdown, 28 | ) 29 | from ..utility.advertising import ( 30 | APPLICATION_TITLE, 31 | ORGANISATION_NAME, 32 | ORGANISATION_TITLE, 33 | ORGANISATION_URL, 34 | PACKAGE_NAME, 35 | TEXTUAL_URL, 36 | ) 37 | from ..widgets import Navigation, Omnibox, Viewer 38 | from ..widgets.navigation_panes import Bookmarks, History, LocalFiles 39 | 40 | 41 | class Main(Screen[None]): # pylint:disable=too-many-public-methods 42 | """The main screen for the application.""" 43 | 44 | DEFAULT_CSS = """ 45 | .focusable { 46 | border: blank; 47 | } 48 | 49 | .focusable:focus { 50 | border: heavy $accent !important; 51 | } 52 | 53 | 54 | Screen Tabs { 55 | border: blank; 56 | height: 5; 57 | } 58 | 59 | Screen Tabs:focus { 60 | border: heavy $accent !important; 61 | height: 5; 62 | } 63 | 64 | Screen TabbedContent TabPane { 65 | padding: 0 1; 66 | border: blank; 67 | } 68 | 69 | Screen TabbedContent TabPane:focus-within { 70 | border: heavy $accent !important; 71 | } 72 | """ 73 | 74 | BINDINGS = [ 75 | Binding("/,:", "omnibox", "Omnibox", show=False), 76 | Binding("ctrl+b", "bookmarks", "", show=False), 77 | Binding("ctrl+d", "bookmark_this", "", show=False), 78 | Binding("ctrl+l", "local_files", "", show=False), 79 | Binding("ctrl+left", "backward", "", show=False), 80 | Binding("ctrl+right", "forward", "", show=False), 81 | Binding("ctrl+r", "reload", "", show=False), 82 | Binding("ctrl+t", "table_of_contents", "", show=False), 83 | Binding("ctrl+y", "history", "", show=False), 84 | Binding("escape", "escape", "", show=False), 85 | Binding("f1", "help", "Help"), 86 | Binding("f2", "about", "About"), 87 | Binding("ctrl+n", "navigation", "Navigation"), 88 | Binding("ctrl+q", "app.quit", "Quit"), 89 | Binding("f10", "toggle_theme", "", show=False), 90 | ] 91 | """The keyboard bindings for the main screen.""" 92 | 93 | def __init__(self, initial_location: str | None = None) -> None: 94 | """Initialise the main screen. 95 | 96 | Args: 97 | initial_location: The initial location to view. 98 | """ 99 | super().__init__() 100 | self._initial_location = initial_location 101 | 102 | def compose(self) -> ComposeResult: 103 | """Compose the main screen. 104 | 105 | Returns: 106 | The result of composing the screen. 107 | """ 108 | yield Omnibox(classes="focusable") 109 | with Horizontal(): 110 | yield Navigation() 111 | yield Viewer(classes="focusable") 112 | yield Footer() 113 | 114 | def visit(self, location: Path | URL, remember: bool = True) -> None: 115 | """Visit the given location. 116 | 117 | Args: 118 | location: The location to visit. 119 | remember: Should the visit be added to the history? 120 | """ 121 | # If the location we've been given looks like it is markdown, be it 122 | # locally in the filesystem or out on the web... 123 | if maybe_markdown(location): 124 | # ...attempt to visit it in the viewer. 125 | self.query_one(Viewer).visit(location, remember) 126 | elif isinstance(location, Path): 127 | # So, it's not Markdown, but it *is* a Path of some sort. If the 128 | # resource seems to exist... 129 | if location.exists(): 130 | # ...ask the OS to open it. 131 | open_url(f"file:///{location.absolute()}") 132 | else: 133 | # It's a Path but it doesn't exist, there's not much else we 134 | # can do with it. 135 | self.app.push_screen( 136 | ErrorDialog( 137 | "Does not exist", 138 | f"Unable to open {location} because it does not exist.", 139 | ) 140 | ) 141 | else: 142 | # By this point all that's left is it's a URL that, on the 143 | # surface, doesn't look like a Markdown file. Let's hand off to 144 | # the operating system anyway. 145 | open_url(str(location), new=2, autoraise=True) 146 | 147 | async def on_mount(self) -> None: 148 | """Set up the main screen once the DOM is ready.""" 149 | 150 | # Currently Textual's Markdown can steal focus, which gets confusing 151 | # as it's not obvious *what* is focused. So let's stop it from 152 | # allowing the content to get focus. 153 | # 154 | # https://github.com/Textualize/textual/issues/2380 155 | self.query_one(Markdown).can_focus_children = False 156 | 157 | # Load up any history that might be saved. 158 | if history := load_history(): 159 | self.query_one(Viewer).load_history(history) 160 | 161 | # If we've not been tasked to start up looking at a very specific 162 | # location (in other words if no location was passed on the command 163 | # line), and if there is some history... 164 | if self._initial_location is None and history: 165 | # ...start up revisiting the last location the user was looking 166 | # at. 167 | self.query_one(Viewer).visit(history[-1], remember=False) 168 | self.query_one(Omnibox).value = str(history[-1]) 169 | elif self._initial_location is not None: 170 | # Seems there is an initial location; so let's start up looking 171 | # at that. 172 | (omnibox := self.query_one(Omnibox)).value = self._initial_location 173 | await omnibox.action_submit() 174 | 175 | def on_navigation_hidden(self) -> None: 176 | """React to the navigation sidebar being hidden.""" 177 | self.query_one(Viewer).focus() 178 | 179 | def on_omnibox_local_view_command(self, event: Omnibox.LocalViewCommand) -> None: 180 | """Handle the omnibox asking us to view a particular file. 181 | 182 | Args: 183 | event: The local view command event. 184 | """ 185 | self.visit(event.path) 186 | 187 | def on_omnibox_remote_view_command(self, event: Omnibox.RemoteViewCommand) -> None: 188 | """Handle the omnibox asking us to view a particular URL. 189 | 190 | Args: 191 | event: The remote view command event. 192 | """ 193 | self.visit(event.url) 194 | 195 | def on_omnibox_contents_command(self) -> None: 196 | """Handle being asked to show the table of contents.""" 197 | self.action_table_of_contents() 198 | 199 | def on_omnibox_local_files_command(self) -> None: 200 | """Handle being asked to view the local files picker.""" 201 | self.action_local_files() 202 | 203 | def on_omnibox_bookmarks_command(self) -> None: 204 | """Handle being asked to view the bookmarks.""" 205 | self.action_bookmarks() 206 | 207 | def on_omnibox_local_chdir_command(self, event: Omnibox.LocalChdirCommand) -> None: 208 | """Handle being asked to view a new directory in the local files picker. 209 | 210 | Args: 211 | event: The chdir command event to handle. 212 | """ 213 | if not event.target.exists(): 214 | self.app.push_screen( 215 | ErrorDialog("No such directory", f"{event.target} does not exist.") 216 | ) 217 | elif not event.target.is_dir(): 218 | self.app.push_screen( 219 | ErrorDialog("Not a directory", f"{event.target} is not a directory.") 220 | ) 221 | else: 222 | self.query_one(Navigation).jump_to_local_files(event.target) 223 | 224 | def on_omnibox_history_command(self) -> None: 225 | """Handle being asked to view the history.""" 226 | self.action_history() 227 | 228 | async def _from_forge( 229 | self, 230 | forge: str, 231 | event: Omnibox.ForgeCommand, 232 | builder: Callable[[str, str, str | None, str | None], Awaitable[URL | None]], 233 | ) -> None: 234 | """Build a URL for getting a file from a given forge. 235 | 236 | Args: 237 | forge: The display name of the forge. 238 | event: The event that contains the request information for the file. 239 | builder: The function that builds the URL. 240 | """ 241 | if url := await builder( 242 | event.owner, event.repository, event.branch, event.desired_file 243 | ): 244 | self.visit(url) 245 | else: 246 | self.app.push_screen( 247 | ErrorDialog( 248 | f"Unable to work out a {forge} URL", 249 | f"After trying a few options it hasn't been possible to work out the {forge} URL.\n\n" 250 | "Perhaps the file you're after is on an unusual branch, or the spelling is wrong?", 251 | ) 252 | ) 253 | 254 | async def on_omnibox_git_hub_command(self, event: Omnibox.GitHubCommand) -> None: 255 | """Handle a GitHub file shortcut command. 256 | 257 | Args: 258 | event: The GitHub shortcut command event to handle. 259 | """ 260 | await self._from_forge("GitHub", event, build_raw_github_url) 261 | 262 | async def on_omnibox_git_lab_command(self, event: Omnibox.GitLabCommand) -> None: 263 | """Handle a GitLab file shortcut command. 264 | 265 | Args: 266 | event: The GitLab shortcut command event to handle. 267 | """ 268 | await self._from_forge("GitLab", event, build_raw_gitlab_url) 269 | 270 | async def on_omnibox_bit_bucket_command( 271 | self, event: Omnibox.BitBucketCommand 272 | ) -> None: 273 | """Handle a BitBucket shortcut command. 274 | 275 | Args: 276 | event: The BitBucket shortcut command event to handle. 277 | """ 278 | await self._from_forge("BitBucket", event, build_raw_bitbucket_url) 279 | 280 | async def on_omnibox_codeberg_command(self, event: Omnibox.CodebergCommand) -> None: 281 | """Handle a Codeberg shortcut command. 282 | 283 | Args: 284 | event: The Codeberg shortcut command event to handle. 285 | """ 286 | await self._from_forge("Codeberg", event, build_raw_codeberg_url) 287 | 288 | def on_omnibox_about_command(self) -> None: 289 | """Handle being asked to show the about dialog.""" 290 | self.action_about() 291 | 292 | def on_omnibox_help_command(self) -> None: 293 | """Handle being asked to show the help document.""" 294 | self.action_help() 295 | 296 | def on_omnibox_quit_command(self) -> None: 297 | """Handle being asked to quit.""" 298 | self.app.exit() 299 | 300 | def on_local_files_goto(self, event: LocalFiles.Goto) -> None: 301 | """Visit a local file in the viewer. 302 | 303 | Args: 304 | event: The local file visit request event. 305 | """ 306 | self.visit(event.location) 307 | 308 | def on_history_goto(self, event: History.Goto) -> None: 309 | """Handle a request to go to a location from history. 310 | 311 | Args: 312 | event: The event to handle. 313 | """ 314 | self.visit( 315 | event.location, remember=event.location != self.query_one(Viewer).location 316 | ) 317 | 318 | def on_history_delete(self, event: History.Delete) -> None: 319 | """Handle a request to delete an item from history. 320 | 321 | Args: 322 | event: The event to handle. 323 | """ 324 | self.query_one(Viewer).delete_history(event.history_id) 325 | 326 | def on_history_clear(self) -> None: 327 | """handle a request to clear down all of history.""" 328 | self.query_one(Viewer).clear_history() 329 | 330 | def on_bookmarks_goto(self, event: Bookmarks.Goto) -> None: 331 | """Handle a request to go to a bookmark. 332 | 333 | Args: 334 | event: The event to handle. 335 | """ 336 | self.visit(event.bookmark.location) 337 | 338 | def on_viewer_location_changed(self, event: Viewer.LocationChanged) -> None: 339 | """Update for the location being changed. 340 | 341 | Args: 342 | event: The location change event. 343 | """ 344 | # Update the omnibox with whatever is appropriate for the new location. 345 | self.query_one(Omnibox).visiting = ( 346 | str(event.viewer.location) if event.viewer.location is not None else "" 347 | ) 348 | # Having safely arrived at a new location, that implies that we want 349 | # to focus on the viewer. 350 | self.query_one(Viewer).focus() 351 | 352 | def on_viewer_history_updated(self, event: Viewer.HistoryUpdated) -> None: 353 | """Handle the viewer updating the history. 354 | 355 | Args: 356 | event: The history update event. 357 | """ 358 | self.query_one(Navigation).history.update_from(event.viewer.history.locations) 359 | save_history(event.viewer.history.locations) 360 | 361 | def on_markdown_table_of_contents_updated( 362 | self, event: Markdown.TableOfContentsUpdated 363 | ) -> None: 364 | """Handle the table of contents of the document being updated. 365 | 366 | Args: 367 | event: The table of contents update event to handle. 368 | """ 369 | # We don't handle this, the navigation pane does. Bounce the event 370 | # over there. 371 | self.query_one(Navigation).table_of_contents.on_table_of_contents_updated(event) 372 | 373 | def on_markdown_table_of_contents_selected( 374 | self, event: Markdown.TableOfContentsSelected 375 | ) -> None: 376 | """Handle the user selecting something from the table of contents. 377 | 378 | Args: 379 | event: The table of contents selection event to handle. 380 | """ 381 | self.query_one(Viewer).scroll_to_block(event.block_id) 382 | 383 | def on_markdown_link_clicked(self, event: Markdown.LinkClicked) -> None: 384 | """Handle a link being clicked in the Markdown document. 385 | 386 | Args: 387 | event: The Markdown link click event to handle. 388 | """ 389 | # We'll be using the current location to help work out some relative 390 | # things. 391 | current_location = self.query_one(Viewer).location 392 | # If the link we're to handle obviously looks like URL... 393 | if is_likely_url(event.href): 394 | # ...handle it as such. No point in trying to do anything else. 395 | self.visit(URL(event.href)) 396 | elif isinstance(current_location, URL): 397 | # Seems we're currently visiting a remote location, and the href 398 | # looks like a simple file path, so let's make a best effort to 399 | # visit the file at the remote location. 400 | self.visit(current_location.copy_with().join(event.href)) 401 | elif (local_file := Path(event.href)).exists(): 402 | # It looks like a local file and it exists... 403 | self.visit(local_file) 404 | elif ( 405 | isinstance(current_location, Path) 406 | and (local_file := (current_location.parent / Path(event.href))) 407 | .absolute() 408 | .exists() 409 | ): 410 | # It looks like a local file, and tested relative to the 411 | # document we found it exists in the local filesystem, so let's 412 | # assume that's what we're supposed to handle. 413 | self.visit(local_file) 414 | elif event.href.startswith("#") and event.markdown.goto_anchor(event.href[1:]): 415 | # The href started with a # and the remains of it were satisfied 416 | # as an anchor within the document of the Markdown. We should 417 | # have scrolled to about the right spot in the document so we 418 | # don't need to do anything else. 419 | pass 420 | else: 421 | # Yeah, not sure *what* this link is. Rather than silently fail, 422 | # let's let the user know we don't know how to process this. 423 | self.app.push_screen( 424 | ErrorDialog( 425 | "Unable to handle link", 426 | f"Unable to work out how to handle this link:\n\n{event.href}", 427 | ) 428 | ) 429 | 430 | def on_paste(self, event: Paste) -> None: 431 | """Handle a paste event. 432 | 433 | Args: 434 | event: The paste event. 435 | 436 | This method is here to capture paste events that look like the name 437 | of a local file (later I may add URL support too). The main purpose 438 | of this is to handle drag/drop into the terminal. 439 | """ 440 | if (candidate_file := Path(event.text)).exists(): 441 | self.visit(candidate_file) 442 | 443 | def action_navigation(self) -> None: 444 | """Toggle the availability of the navigation sidebar.""" 445 | self.query_one(Navigation).toggle() 446 | 447 | def action_escape(self) -> None: 448 | """Process the escape key.""" 449 | # Escape is designed to work backwards out of the application. If 450 | # the viewer is focused, the omnibox gets focused, if omnibox has 451 | # focus but it isn't empty, it gets emptied, if it's empty we exit 452 | # the application. The idea being that folk who use this often want 453 | # to build up muscle memory on the keyboard will know to camp on the 454 | # escape key until they get to where they want to be. 455 | if (omnibox := self.query_one(Omnibox)).has_focus: 456 | if omnibox.value: 457 | omnibox.value = "" 458 | else: 459 | self.app.exit() 460 | else: 461 | if self.query("Navigation:focus-within"): 462 | self.query_one(Navigation).popped_out = False 463 | omnibox.focus() 464 | 465 | def action_omnibox(self) -> None: 466 | """Jump to the omnibox.""" 467 | self.query_one(Omnibox).focus() 468 | 469 | def action_table_of_contents(self) -> None: 470 | """Display and focus the table of contents pane.""" 471 | self.query_one(Navigation).jump_to_contents() 472 | 473 | def action_local_files(self) -> None: 474 | """Display and focus the local files selection pane.""" 475 | self.query_one(Navigation).jump_to_local_files() 476 | 477 | def action_bookmarks(self) -> None: 478 | """Display and focus the bookmarks selection pane.""" 479 | self.query_one(Navigation).jump_to_bookmarks() 480 | 481 | def action_history(self) -> None: 482 | """Display and focus the history pane.""" 483 | self.query_one(Navigation).jump_to_history() 484 | 485 | def action_backward(self) -> None: 486 | """Go backward in the history.""" 487 | self.query_one(Viewer).back() 488 | 489 | def action_forward(self) -> None: 490 | """Go forward in the history.""" 491 | self.query_one(Viewer).forward() 492 | 493 | def action_help(self) -> None: 494 | """Show the help.""" 495 | self.app.push_screen(HelpDialog()) 496 | 497 | def action_about(self) -> None: 498 | """Show the about dialog.""" 499 | self.app.push_screen( 500 | InformationDialog( 501 | f"{APPLICATION_TITLE} [b dim]v{__version__}", 502 | f"Built with [@click=app.visit('{TEXTUAL_URL}')]Textual[/] " 503 | f"by [@click=app.visit('{ORGANISATION_URL}')]{ORGANISATION_TITLE}[/].\n\n" 504 | f"[@click=app.visit('https://github.com/{ORGANISATION_NAME}/{PACKAGE_NAME}')]" 505 | f"https://github.com/{ORGANISATION_NAME}/{PACKAGE_NAME}[/]", 506 | ) 507 | ) 508 | 509 | def add_bookmark(self, location: Path | URL, bookmark: str) -> None: 510 | """Handle adding the bookmark. 511 | 512 | Args: 513 | location: The location to bookmark. 514 | bookmark: The bookmark to add. 515 | """ 516 | self.query_one(Navigation).bookmarks.add_bookmark(bookmark, location) 517 | 518 | def action_bookmark_this(self) -> None: 519 | """Add a bookmark for the currently-viewed file.""" 520 | 521 | location = self.query_one(Viewer).location 522 | 523 | # Only allow bookmarking if we're actually viewing something that 524 | # can be bookmarked. 525 | if not isinstance(location, (Path, URL)): 526 | self.app.push_screen( 527 | ErrorDialog( 528 | "Not a bookmarkable location", 529 | "The current view can't be bookmarked.", 530 | ) 531 | ) 532 | return 533 | 534 | # To make a bookmark, we need a title and a location. We've got a 535 | # location; let's make the filename the default title. 536 | title = (location if isinstance(location, Path) else Path(location.path)).name 537 | 538 | # Give the user a chance to edit the title. 539 | self.app.push_screen( 540 | InputDialog("Bookmark title:", title), 541 | partial(self.add_bookmark, location), 542 | ) 543 | 544 | def action_toggle_theme(self) -> None: 545 | """Toggle the light/dark mode theme.""" 546 | config = load_config() 547 | config.light_mode = not config.light_mode 548 | save_config(config) 549 | # pylint:disable=attribute-defined-outside-init 550 | self.app.dark = not config.light_mode 551 | 552 | def action_reload(self) -> None: 553 | """Reload the current document.""" 554 | self.query_one(Viewer).reload() 555 | -------------------------------------------------------------------------------- /frogmouth/utility/__init__.py: -------------------------------------------------------------------------------- 1 | """General utility and support code.""" 2 | 3 | from .forge import ( 4 | build_raw_bitbucket_url, 5 | build_raw_codeberg_url, 6 | build_raw_github_url, 7 | build_raw_gitlab_url, 8 | ) 9 | from .type_tests import is_likely_url, maybe_markdown 10 | 11 | __all__ = [ 12 | "build_raw_bitbucket_url", 13 | "build_raw_codeberg_url", 14 | "build_raw_github_url", 15 | "build_raw_gitlab_url", 16 | "is_likely_url", 17 | "maybe_markdown", 18 | ] 19 | -------------------------------------------------------------------------------- /frogmouth/utility/advertising.py: -------------------------------------------------------------------------------- 1 | """Provides the 'branding' for the application.""" 2 | 3 | from typing_extensions import Final 4 | 5 | from .. import __version__ 6 | 7 | ORGANISATION_NAME: Final[str] = "textualize" 8 | """The organisation name to use when creating namespaced resources.""" 9 | 10 | ORGANISATION_TITLE: Final[str] = "Textualize" 11 | """The organisation title.""" 12 | 13 | ORGANISATION_URL: Final[str] = "https://www.textualize.io/" 14 | """The organisation URL.""" 15 | 16 | PACKAGE_NAME: Final[str] = "frogmouth" 17 | """The name of the package.""" 18 | 19 | APPLICATION_TITLE: Final[str] = "Frogmouth" 20 | """The title of the application.""" 21 | 22 | USER_AGENT: Final[str] = f"{PACKAGE_NAME} v{__version__}" 23 | """The user agent to use when making web requests.""" 24 | 25 | DISCORD: Final[str] = "https://discord.gg/Enf6Z3qhVr" 26 | """The link to the Textualize Discord server.""" 27 | 28 | TEXTUAL_URL: Final[str] = "https://textual.textualize.io/" 29 | """The URL people should visit to find out more about Textual.""" 30 | -------------------------------------------------------------------------------- /frogmouth/utility/forge.py: -------------------------------------------------------------------------------- 1 | """Code for getting files from a forge.""" 2 | 3 | from __future__ import annotations 4 | 5 | from httpx import URL, AsyncClient, HTTPStatusError, RequestError 6 | 7 | from .advertising import USER_AGENT 8 | 9 | 10 | async def build_raw_forge_url( 11 | url_format: str, 12 | owner: str, 13 | repository: str, 14 | branch: str | None = None, 15 | desired_file: str | None = None, 16 | ) -> URL | None: 17 | """Attempt to get raw forge URL for the given file. 18 | 19 | Args: 20 | owner: The owner of the repository to look in. 21 | repository: The repository to look in. 22 | branch: The optional branch to look in. 23 | desired_file: Optional name of the file to go looking for. 24 | 25 | Returns: 26 | The URL for the file, or `None` if none could be guessed. 27 | 28 | If the branch isn't supplied then `main` and `master` will be tested. 29 | 30 | If the target file isn't supplied it's assumed that `README.md` is the 31 | target. 32 | """ 33 | desired_file = desired_file or "README.md" 34 | async with AsyncClient() as client: 35 | for test_branch in (branch,) if branch else ("main", "master"): 36 | url = url_format.format( 37 | owner=owner, 38 | repository=repository, 39 | branch=test_branch, 40 | file=desired_file, 41 | ) 42 | try: 43 | response = await client.head( 44 | url, 45 | follow_redirects=True, 46 | headers={"user-agent": USER_AGENT}, 47 | ) 48 | except RequestError: 49 | # We've failed to even make the request, there's no point in 50 | # trying to build anything here. 51 | return None 52 | try: 53 | response.raise_for_status() 54 | return URL(url) 55 | except HTTPStatusError: 56 | pass 57 | return None 58 | 59 | 60 | async def build_raw_github_url( 61 | owner: str, 62 | repository: str, 63 | branch: str | None = None, 64 | desired_file: str | None = None, 65 | ) -> URL | None: 66 | """Attempt to get the GitHub raw URL for the given file. 67 | 68 | Args: 69 | owner: The owner of the repository to look in. 70 | repository: The repository to look in. 71 | branch: The optional branch to look in. 72 | desired_file: Optional name of the file to go looking for. 73 | 74 | Returns: 75 | The URL for the file, or `None` if none could be guessed. 76 | 77 | If the branch isn't supplied then `main` and `master` will be tested. 78 | 79 | If the target file isn't supplied it's assumed that `README.md` is the 80 | target. 81 | """ 82 | return await build_raw_forge_url( 83 | "https://raw.githubusercontent.com/{owner}/{repository}/{branch}/{file}", 84 | owner, 85 | repository, 86 | branch, 87 | desired_file, 88 | ) 89 | 90 | 91 | async def build_raw_gitlab_url( 92 | owner: str, 93 | repository: str, 94 | branch: str | None = None, 95 | desired_file: str | None = None, 96 | ) -> URL | None: 97 | """Attempt to get the GitLab raw URL for the given file. 98 | 99 | Args: 100 | owner: The owner of the repository to look in. 101 | repository: The repository to look in. 102 | branch: The optional branch to look in. 103 | desired_file: Optional name of the file to go looking for. 104 | 105 | Returns: 106 | The URL for the file, or `None` if none could be guessed. 107 | 108 | If the branch isn't supplied then `main` and `master` will be tested. 109 | 110 | If the target file isn't supplied it's assumed that `README.md` is the 111 | target. 112 | """ 113 | return await build_raw_forge_url( 114 | "https://gitlab.com/{owner}/{repository}/-/raw/{branch}/{file}", 115 | owner, 116 | repository, 117 | branch, 118 | desired_file, 119 | ) 120 | 121 | 122 | async def build_raw_bitbucket_url( 123 | owner: str, 124 | repository: str, 125 | branch: str | None = None, 126 | desired_file: str | None = None, 127 | ) -> URL | None: 128 | """Attempt to get the BitBucket raw URL for the given file. 129 | 130 | Args: 131 | owner: The owner of the repository to look in. 132 | repository: The repository to look in. 133 | branch: The optional branch to look in. 134 | desired_file: Optional name of the file to go looking for. 135 | 136 | Returns: 137 | The URL for the file, or `None` if none could be guessed. 138 | 139 | If the branch isn't supplied then `main` and `master` will be tested. 140 | 141 | If the target file isn't supplied it's assumed that `README.md` is the 142 | target. 143 | """ 144 | return await build_raw_forge_url( 145 | "https://bitbucket.org/{owner}/{repository}/raw/{branch}/{file}", 146 | owner, 147 | repository, 148 | branch, 149 | desired_file, 150 | ) 151 | 152 | 153 | async def build_raw_codeberg_url( 154 | owner: str, 155 | repository: str, 156 | branch: str | None = None, 157 | desired_file: str | None = None, 158 | ) -> URL | None: 159 | """Attempt to get the Codeberg raw URL for the given file. 160 | 161 | Args: 162 | owner: The owner of the repository to look in. 163 | repository: The repository to look in. 164 | branch: The optional branch to look in. 165 | desired_file: Optional name of the file to go looking for. 166 | 167 | Returns: 168 | The URL for the file, or `None` if none could be guessed. 169 | 170 | If the branch isn't supplied then `main` and `master` will be tested. 171 | 172 | If the target file isn't supplied it's assumed that `README.md` is the 173 | target. 174 | """ 175 | return await build_raw_forge_url( 176 | "https://codeberg.org/{owner}/{repository}/raw//branch/{branch}/{file}", 177 | owner, 178 | repository, 179 | branch, 180 | desired_file, 181 | ) 182 | -------------------------------------------------------------------------------- /frogmouth/utility/type_tests.py: -------------------------------------------------------------------------------- 1 | """Support code for testing files for their potential type.""" 2 | 3 | from functools import singledispatch 4 | from pathlib import Path 5 | from typing import Any 6 | 7 | from httpx import URL 8 | 9 | from ..data.config import load_config 10 | 11 | 12 | @singledispatch 13 | def maybe_markdown(resource: Any) -> bool: 14 | """Does the given resource look like it's a Markdown file? 15 | 16 | Args: 17 | resource: The resource to test. 18 | 19 | Returns: 20 | `True` if the resources looks like a Markdown file, `False` if not. 21 | """ 22 | del resource 23 | return False 24 | 25 | 26 | @maybe_markdown.register 27 | def _(resource: Path) -> bool: 28 | return resource.suffix.lower() in load_config().markdown_extensions 29 | 30 | 31 | @maybe_markdown.register 32 | def _(resource: str) -> bool: 33 | return maybe_markdown(Path(resource)) 34 | 35 | 36 | @maybe_markdown.register 37 | def _(resource: URL) -> bool: 38 | return maybe_markdown(resource.path) 39 | 40 | 41 | def is_likely_url(candidate: str) -> bool: 42 | """Does the given value look something like a URL? 43 | 44 | Args: 45 | candidate: The candidate to check. 46 | 47 | Returns: 48 | `True` if the string is likely a URL, `False` if not. 49 | """ 50 | # Quick and dirty for now. 51 | url = URL(candidate) 52 | return url.is_absolute_url and url.scheme in ("http", "https") 53 | -------------------------------------------------------------------------------- /frogmouth/widgets/__init__.py: -------------------------------------------------------------------------------- 1 | """The major widgets for the application.""" 2 | 3 | from .navigation import Navigation 4 | from .omnibox import Omnibox 5 | from .viewer import Viewer 6 | 7 | __all__ = ["Navigation", "Omnibox", "Viewer"] 8 | -------------------------------------------------------------------------------- /frogmouth/widgets/navigation.py: -------------------------------------------------------------------------------- 1 | """Provides the navigation panel widget.""" 2 | 3 | from __future__ import annotations 4 | 5 | from pathlib import Path 6 | 7 | from textual.app import ComposeResult 8 | from textual.binding import Binding 9 | from textual.containers import Vertical 10 | from textual.message import Message 11 | from textual.reactive import var 12 | from textual.widgets import TabbedContent, Tabs 13 | from typing_extensions import Self 14 | 15 | from ..data import load_config, save_config 16 | from .navigation_panes.bookmarks import Bookmarks 17 | from .navigation_panes.history import History 18 | from .navigation_panes.local_files import LocalFiles 19 | from .navigation_panes.navigation_pane import NavigationPane 20 | from .navigation_panes.table_of_contents import TableOfContents 21 | 22 | 23 | class Navigation(Vertical, can_focus=False, can_focus_children=True): 24 | """A navigation panel widget.""" 25 | 26 | DEFAULT_CSS = """ 27 | Navigation { 28 | width: 44; 29 | background: $panel; 30 | display: block; 31 | dock: left; 32 | } 33 | 34 | Navigation.hidden { 35 | display: none; 36 | } 37 | 38 | TabbedContent { 39 | height: 100% !important; 40 | } 41 | 42 | ContentSwitcher { 43 | height: 1fr !important; 44 | } 45 | """ 46 | 47 | BINDINGS = [ 48 | Binding("comma,a,ctrl+left,shift+left,h", "previous_tab", "", show=False), 49 | Binding("full_stop,d,ctrl+right,shift+right,l", "next_tab", "", show=False), 50 | Binding("\\", "toggle_dock", "Dock left/right"), 51 | ] 52 | """Bindings local to the navigation pane.""" 53 | 54 | popped_out: var[bool] = var(False) 55 | """Is the navigation popped out?""" 56 | 57 | docked_left: var[bool] = var(True) 58 | """Should navigation be docked to the left side of the screen?""" 59 | 60 | def compose(self) -> ComposeResult: 61 | """Compose the content of the navigation pane.""" 62 | self.popped_out = False 63 | # pylint:disable=attribute-defined-outside-init 64 | self._contents = TableOfContents() 65 | self._local_files = LocalFiles() 66 | self._bookmarks = Bookmarks() 67 | self._history = History() 68 | with TabbedContent() as tabs: 69 | self._tabs = tabs 70 | yield self._contents 71 | yield self._local_files 72 | yield self._bookmarks 73 | yield self._history 74 | 75 | def on_mount(self) -> None: 76 | """Configure navigation once the DOM is set up.""" 77 | self.docked_left = load_config().navigation_left 78 | 79 | class Hidden(Message): 80 | """Message sent when the navigation is hidden.""" 81 | 82 | def watch_popped_out(self) -> None: 83 | """Watch for changes to the popped out state.""" 84 | self.set_class(not self.popped_out, "hidden") 85 | if not self.popped_out: 86 | self.post_message(self.Hidden()) 87 | 88 | def toggle(self) -> None: 89 | """Toggle the popped/unpopped state.""" 90 | self.popped_out = not self.popped_out 91 | 92 | def watch_docked_left(self) -> None: 93 | """Watch for changes to the left-docking status.""" 94 | self.styles.dock = "left" if self.docked_left else "right" 95 | 96 | @property 97 | def table_of_contents(self) -> TableOfContents: 98 | """The table of contents widget.""" 99 | return self._contents 100 | 101 | @property 102 | def local_files(self) -> LocalFiles: 103 | """The local files widget.""" 104 | return self._local_files 105 | 106 | @property 107 | def bookmarks(self) -> Bookmarks: 108 | """The bookmarks widget.""" 109 | return self._bookmarks 110 | 111 | @property 112 | def history(self) -> History: 113 | """The history widget.""" 114 | return self._history 115 | 116 | def jump_to_local_files(self, target: Path | None = None) -> Self: 117 | """Switch to and focus the local files pane. 118 | 119 | Returns: 120 | Self. 121 | """ 122 | if ( 123 | self.popped_out 124 | and target is None 125 | and self.query_one(Tabs).active == self._local_files.id 126 | ): 127 | self.popped_out = False 128 | else: 129 | self.popped_out = True 130 | if target is not None: 131 | self._local_files.chdir(target) 132 | self._local_files.activate().set_focus_within() 133 | return self 134 | 135 | def jump_to_bookmarks(self) -> Self: 136 | """Switch to and focus the bookmarks pane. 137 | 138 | Returns: 139 | Self. 140 | """ 141 | if self.popped_out and self.query_one(Tabs).active == self._bookmarks.id: 142 | self.popped_out = False 143 | else: 144 | self.popped_out = True 145 | self._bookmarks.activate().set_focus_within() 146 | return self 147 | 148 | def jump_to_history(self) -> Self: 149 | """Switch to and focus the history pane. 150 | 151 | Returns: 152 | Self. 153 | """ 154 | if self.popped_out and self.query_one(Tabs).active == self._history.id: 155 | self.popped_out = False 156 | else: 157 | self.popped_out = True 158 | self._history.activate().set_focus_within() 159 | return self 160 | 161 | def jump_to_contents(self) -> Self: 162 | """Switch to and focus the table of contents pane. 163 | 164 | Returns: 165 | Self. 166 | """ 167 | if self.popped_out and self.query_one(Tabs).active == self._contents.id: 168 | self.popped_out = False 169 | else: 170 | self.popped_out = True 171 | self._contents.activate().set_focus_within() 172 | return self 173 | 174 | def action_previous_tab(self) -> None: 175 | """Switch to the previous tab in the navigation pane.""" 176 | self.query_one(Tabs).action_previous_tab() 177 | self.focus_tab() 178 | 179 | def action_next_tab(self) -> None: 180 | """Switch to the next tab in the navigation pane.""" 181 | self.query_one(Tabs).action_next_tab() 182 | self.focus_tab() 183 | 184 | def action_toggle_dock(self) -> None: 185 | """Toggle the dock side for the navigation.""" 186 | config = load_config() 187 | config.navigation_left = not config.navigation_left 188 | save_config(config) 189 | self.docked_left = config.navigation_left 190 | 191 | def focus_tab(self) -> None: 192 | """Focus the currently active tab.""" 193 | if active := self.query_one(Tabs).active: 194 | self.query_one( 195 | f"NavigationPane#{active}", NavigationPane 196 | ).set_focus_within() 197 | -------------------------------------------------------------------------------- /frogmouth/widgets/navigation_panes/__init__.py: -------------------------------------------------------------------------------- 1 | """Provides the panes that go into the main navigation area.""" 2 | 3 | from .bookmarks import Bookmarks 4 | from .history import History 5 | from .local_files import LocalFiles 6 | from .table_of_contents import TableOfContents 7 | 8 | __all__ = [ 9 | "Bookmarks", 10 | "History", 11 | "LocalFiles", 12 | "TableOfContents", 13 | ] 14 | -------------------------------------------------------------------------------- /frogmouth/widgets/navigation_panes/bookmarks.py: -------------------------------------------------------------------------------- 1 | """Provides the bookmarks navigation pane.""" 2 | 3 | from __future__ import annotations 4 | 5 | from functools import partial 6 | from pathlib import Path 7 | 8 | from httpx import URL 9 | from rich.text import Text 10 | from textual.app import ComposeResult 11 | from textual.binding import Binding 12 | from textual.message import Message 13 | from textual.widgets import OptionList 14 | from textual.widgets.option_list import Option 15 | 16 | from ...data import Bookmark, load_bookmarks, save_bookmarks 17 | from ...dialogs import InputDialog, YesNoDialog 18 | from .navigation_pane import NavigationPane 19 | 20 | 21 | class Entry(Option): 22 | """An entry in the bookmark list.""" 23 | 24 | def __init__(self, bookmark: Bookmark) -> None: 25 | super().__init__(self._as_prompt(bookmark)) 26 | self.bookmark = bookmark 27 | """The bookmark that this entry relates to.""" 28 | 29 | @staticmethod 30 | def _as_prompt(bookmark: Bookmark) -> Text: 31 | """Depict the bookmark as a decorated prompt. 32 | 33 | Args: 34 | bookmark: The bookmark to depict. 35 | 36 | Returns: 37 | A prompt with icon, etc. 38 | """ 39 | return Text.from_markup( 40 | f":{'page_facing_up' if isinstance(bookmark.location, Path) else 'globe_with_meridians'}: " 41 | f"[bold]{bookmark.title}[/]\n[dim]{bookmark.location}[/]", 42 | overflow="ellipsis", 43 | ) 44 | 45 | 46 | class Bookmarks(NavigationPane): 47 | """Bookmarks navigation pane.""" 48 | 49 | DEFAULT_CSS = """ 50 | Bookmarks { 51 | height: 100%; 52 | } 53 | 54 | Bookmarks > OptionList { 55 | background: $panel; 56 | border: none; 57 | height: 1fr; 58 | } 59 | 60 | Bookmarks > OptionList:focus { 61 | border: none; 62 | } 63 | """ 64 | """The default CSS for the bookmarks navigation pane.""" 65 | 66 | BINDINGS = [ 67 | Binding("delete", "delete", "Delete the bookmark"), 68 | Binding("r", "rename", "Rename the bookmark"), 69 | ] 70 | """The bindings for the bookmarks navigation pane.""" 71 | 72 | def __init__(self) -> None: 73 | """Initialise the bookmarks navigation pane.""" 74 | super().__init__("Bookmarks") 75 | self._bookmarks: list[Bookmark] = load_bookmarks() 76 | """The internal list of bookmarks.""" 77 | 78 | def compose(self) -> ComposeResult: 79 | """Compose the child widgets.""" 80 | yield OptionList(*[Entry(bookmark) for bookmark in self._bookmarks]) 81 | 82 | def set_focus_within(self) -> None: 83 | """Focus the option list.""" 84 | self.query_one(OptionList).focus(scroll_visible=False) 85 | 86 | def _bookmarks_updated(self) -> None: 87 | """Handle the bookmarks being updated.""" 88 | # It's slightly costly, but currently there's no easier way to do 89 | # this; and really it's not going to be that frequent. Here we nuke 90 | # the content of the OptionList and rebuild it based on the actual 91 | # list of bookmarks. 92 | bookmarks = self.query_one(OptionList) 93 | old_position = bookmarks.highlighted 94 | bookmarks.clear_options() 95 | for bookmark in self._bookmarks: 96 | bookmarks.add_option(Entry(bookmark)) 97 | save_bookmarks(self._bookmarks) 98 | bookmarks.highlighted = old_position 99 | 100 | def add_bookmark(self, title: str, location: Path | URL) -> None: 101 | """Add a new bookmark. 102 | 103 | Args: 104 | title: The title of the bookmark. 105 | location: The location of the bookmark. 106 | """ 107 | self._bookmarks.append(Bookmark(title, location)) 108 | self._bookmarks = sorted(self._bookmarks, key=lambda bookmark: bookmark.title) 109 | self._bookmarks_updated() 110 | 111 | class Goto(Message): 112 | """Message that requests that the viewer goes to a given bookmark.""" 113 | 114 | def __init__(self, bookmark: Bookmark) -> None: 115 | """Initialise the bookmark goto message. 116 | 117 | Args: 118 | bookmark: The bookmark to go to. 119 | """ 120 | super().__init__() 121 | self.bookmark = bookmark 122 | 123 | def on_option_list_option_selected(self, event: OptionList.OptionSelected) -> None: 124 | """Handle an entry in the bookmarks being selected. 125 | 126 | Args: 127 | event: The event to handle. 128 | """ 129 | event.stop() 130 | assert isinstance(event.option, Entry) 131 | self.post_message(self.Goto(event.option.bookmark)) 132 | 133 | def delete_bookmark(self, bookmark: int, delete_it: bool) -> None: 134 | """Delete a given bookmark. 135 | 136 | Args: 137 | bookmark: The bookmark to delete. 138 | delete_it: Should it be deleted? 139 | """ 140 | if delete_it: 141 | del self._bookmarks[bookmark] 142 | self._bookmarks_updated() 143 | 144 | def action_delete(self) -> None: 145 | """Delete the highlighted bookmark.""" 146 | if (bookmark := self.query_one(OptionList).highlighted) is not None: 147 | self.app.push_screen( 148 | YesNoDialog( 149 | "Delete bookmark", 150 | "Are you sure you want to delete the bookmark?", 151 | ), 152 | partial(self.delete_bookmark, bookmark), 153 | ) 154 | 155 | def rename_bookmark(self, bookmark: int, new_name: str) -> None: 156 | """Rename the current bookmark. 157 | 158 | Args: 159 | bookmark: The location of the bookmark to rename. 160 | new_name: The input dialog result that is the new name. 161 | """ 162 | self._bookmarks[bookmark] = Bookmark( 163 | new_name, self._bookmarks[bookmark].location 164 | ) 165 | self._bookmarks_updated() 166 | 167 | def action_rename(self) -> None: 168 | """Rename the highlighted bookmark.""" 169 | if (bookmark := self.query_one(OptionList).highlighted) is not None: 170 | self.app.push_screen( 171 | InputDialog( 172 | "Bookmark title:", 173 | self._bookmarks[bookmark].title, 174 | ), 175 | partial(self.rename_bookmark, bookmark), 176 | ) 177 | -------------------------------------------------------------------------------- /frogmouth/widgets/navigation_panes/history.py: -------------------------------------------------------------------------------- 1 | """Provides the history navigation pane.""" 2 | 3 | from __future__ import annotations 4 | 5 | from functools import partial 6 | from pathlib import Path 7 | 8 | from httpx import URL 9 | from rich.text import Text 10 | from textual.app import ComposeResult 11 | from textual.binding import Binding 12 | from textual.message import Message 13 | from textual.widgets import OptionList 14 | from textual.widgets.option_list import Option 15 | 16 | from ...dialogs import YesNoDialog 17 | from .navigation_pane import NavigationPane 18 | 19 | 20 | class Entry(Option): 21 | """An entry in the history.""" 22 | 23 | def __init__(self, history_id: int, location: Path | URL) -> None: 24 | """Initialise the history entry item. 25 | 26 | Args: 27 | history_id: The ID of the item of history. 28 | location: The location being added to history. 29 | """ 30 | super().__init__(self._as_prompt(location)) 31 | self.history_id = history_id 32 | """The ID of the item of history.""" 33 | self.location = location 34 | """The location for his entry in the history.""" 35 | 36 | @staticmethod 37 | def _as_prompt(location: Path | URL) -> Text: 38 | """Depict the location as a decorated prompt. 39 | 40 | Args: 41 | location: The location to depict. 42 | 43 | Returns: 44 | A prompt with icon, etc. 45 | """ 46 | if isinstance(location, Path): 47 | return Text.from_markup( 48 | f":page_facing_up: [bold]{location.name}[/]\n[dim]{location.parent}[/]", 49 | overflow="ellipsis", 50 | ) 51 | return Text.from_markup( 52 | f":globe_with_meridians: [bold]{Path(location.path).name}[/]" 53 | f"\n[dim]{Path(location.path).parent}\n{location.host}[/]", 54 | overflow="ellipsis", 55 | ) 56 | 57 | 58 | class History(NavigationPane): 59 | """History navigation pane.""" 60 | 61 | DEFAULT_CSS = """ 62 | History { 63 | height: 100%; 64 | } 65 | 66 | History > OptionList { 67 | background: $panel; 68 | border: none; 69 | height: 1fr; 70 | } 71 | 72 | History > OptionList:focus { 73 | border: none; 74 | } 75 | """ 76 | 77 | BINDINGS = [ 78 | Binding("delete", "delete", "Delete the history item"), 79 | Binding("backspace", "clear", "Clean the history"), 80 | ] 81 | """The bindings for the history navigation pane.""" 82 | 83 | def __init__(self) -> None: 84 | """Initialise the history navigation pane.""" 85 | super().__init__("History") 86 | 87 | def compose(self) -> ComposeResult: 88 | """Compose the child widgets.""" 89 | yield OptionList() 90 | 91 | def set_focus_within(self) -> None: 92 | """Focus the option list.""" 93 | self.query_one(OptionList).focus(scroll_visible=False) 94 | 95 | def update_from(self, locations: list[Path | URL]) -> None: 96 | """Update the history from the given list of locations. 97 | 98 | Args: 99 | locations: A list of locations to update the history with. 100 | 101 | This call removes any existing history and sets it to the given 102 | value. 103 | """ 104 | option_list = self.query_one(OptionList).clear_options() 105 | for history_id, location in reversed(list(enumerate(locations))): 106 | option_list.add_option(Entry(history_id, location)) 107 | 108 | class Goto(Message): 109 | """Message that requests the viewer goes to a given location.""" 110 | 111 | def __init__(self, location: Path | URL) -> None: 112 | """Initialise the history goto message. 113 | 114 | Args: 115 | location: The location to go to. 116 | """ 117 | super().__init__() 118 | self.location = location 119 | """The location to go to.""" 120 | 121 | def on_option_list_option_selected(self, event: OptionList.OptionSelected) -> None: 122 | """Handle an entry in the history being selected. 123 | 124 | Args: 125 | event: The event to handle. 126 | """ 127 | event.stop() 128 | assert isinstance(event.option, Entry) 129 | self.post_message(self.Goto(event.option.location)) 130 | 131 | class Delete(Message): 132 | """Message that requests the viewer to delete an item of history.""" 133 | 134 | def __init__(self, history_id: int) -> None: 135 | """initialise the history delete message. 136 | 137 | args: 138 | history_id: The ID of the item of history to delete. 139 | """ 140 | super().__init__() 141 | self.history_id = history_id 142 | """The ID of the item of history to delete.""" 143 | 144 | def delete_history(self, history_id: int, delete_it: bool) -> None: 145 | """Delete a given history entry. 146 | 147 | Args: 148 | history_id: The ID of the item of history to delete. 149 | delete_it: Should it be deleted? 150 | """ 151 | if delete_it: 152 | self.post_message(self.Delete(history_id)) 153 | 154 | def action_delete(self) -> None: 155 | """Delete the highlighted item from history.""" 156 | history = self.query_one(OptionList) 157 | if (item := history.highlighted) is not None: 158 | assert isinstance(entry := history.get_option_at_index(item), Entry) 159 | self.app.push_screen( 160 | YesNoDialog( 161 | "Delete history entry?", 162 | "Are you sure you want to delete the history entry?", 163 | ), 164 | partial(self.delete_history, entry.history_id), 165 | ) 166 | 167 | class Clear(Message): 168 | """Message that requests that the history be cleared.""" 169 | 170 | def clear_history(self, clear_it: bool) -> None: 171 | """Perform a history clear. 172 | 173 | Args: 174 | clear_it: Should it be cleared? 175 | """ 176 | if clear_it: 177 | self.post_message(self.Clear()) 178 | 179 | def action_clear(self) -> None: 180 | """Clear out the whole history.""" 181 | self.app.push_screen( 182 | YesNoDialog( 183 | "Clear history?", 184 | "Are you sure you want to clear everything out of history?", 185 | ), 186 | self.clear_history, 187 | ) 188 | -------------------------------------------------------------------------------- /frogmouth/widgets/navigation_panes/local_files.py: -------------------------------------------------------------------------------- 1 | """Provides the local files navigation pane.""" 2 | 3 | from __future__ import annotations 4 | 5 | from pathlib import Path 6 | from typing import Iterable 7 | 8 | from httpx import URL 9 | from textual.app import ComposeResult 10 | from textual.message import Message 11 | from textual.widgets import DirectoryTree 12 | 13 | from ...utility import maybe_markdown 14 | from .navigation_pane import NavigationPane 15 | 16 | 17 | class FilteredDirectoryTree(DirectoryTree): # pylint:disable=too-many-ancestors 18 | """A `DirectoryTree` filtered for the markdown viewer.""" 19 | 20 | def filter_paths(self, paths: Iterable[Path]) -> Iterable[Path]: 21 | """Filter the directory tree for the Markdown viewer. 22 | 23 | Args: 24 | paths: The paths to be filtered. 25 | 26 | Returns: 27 | The parts filtered for the Markdown viewer. 28 | 29 | The filtered set will include all filesystem entries that aren't 30 | hidden (in a Unix sense of hidden) which are either a directory or a 31 | file that looks like it could be a Markdown document. 32 | """ 33 | try: 34 | return [ 35 | path 36 | for path in paths 37 | if not path.name.startswith(".") 38 | and path.is_dir() 39 | or (path.is_file() and maybe_markdown(path)) 40 | ] 41 | except PermissionError: 42 | return [] 43 | 44 | 45 | class LocalFiles(NavigationPane): 46 | """Local file picking navigation pane.""" 47 | 48 | DEFAULT_CSS = """ 49 | LocalFiles { 50 | height: 100%; 51 | } 52 | 53 | LocalFiles > DirectoryTree { 54 | background: $panel; 55 | width: 1fr; 56 | } 57 | 58 | LocalFiles > DirectoryTree:focus .tree--cursor, LocalFiles > DirectoryTree .tree--cursor { 59 | background: $accent 50%; 60 | color: $text; 61 | } 62 | """ 63 | 64 | def __init__(self) -> None: 65 | """Initialise the local files navigation pane.""" 66 | super().__init__("Local") 67 | 68 | def compose(self) -> ComposeResult: 69 | """Compose the child widgets.""" 70 | yield FilteredDirectoryTree(Path("~").expanduser()) 71 | 72 | def chdir(self, path: Path) -> None: 73 | """Change the filesystem view to the given directory. 74 | 75 | Args: 76 | path: The path to change to. 77 | """ 78 | self.query_one(FilteredDirectoryTree).path = path 79 | 80 | def set_focus_within(self) -> None: 81 | """Focus the directory tree..""" 82 | self.query_one(DirectoryTree).focus(scroll_visible=False) 83 | 84 | class Goto(Message): 85 | """Message that requests the viewer goes to a given location.""" 86 | 87 | def __init__(self, location: Path | URL) -> None: 88 | """Initialise the history goto message. 89 | 90 | Args: 91 | location: The location to go to. 92 | """ 93 | super().__init__() 94 | self.location = location 95 | """The location to go to.""" 96 | 97 | def on_directory_tree_file_selected( 98 | self, event: DirectoryTree.FileSelected 99 | ) -> None: 100 | """Handle a file being selected in the directory tree. 101 | 102 | Args: 103 | event: The direct tree selection event. 104 | """ 105 | event.stop() 106 | self.post_message(self.Goto(Path(event.path))) 107 | -------------------------------------------------------------------------------- /frogmouth/widgets/navigation_panes/navigation_pane.py: -------------------------------------------------------------------------------- 1 | """Provides a base class for all navigation panes.""" 2 | 3 | from textual.widgets import TabbedContent, TabPane 4 | from typing_extensions import Self 5 | 6 | 7 | class NavigationPane(TabPane): 8 | """Base class for panes within the navigation sidebar.""" 9 | 10 | def set_focus_within(self) -> None: 11 | """Set the focus on the correct child within the navigation pane.""" 12 | 13 | def activate(self) -> Self: 14 | """Activate the navigation pane. 15 | 16 | Returns: 17 | Self. 18 | """ 19 | assert self.parent is not None 20 | if self.id is not None and isinstance(self.parent.parent, TabbedContent): 21 | self.parent.parent.active = self.id 22 | return self 23 | -------------------------------------------------------------------------------- /frogmouth/widgets/navigation_panes/table_of_contents.py: -------------------------------------------------------------------------------- 1 | """Provides the table of contents navigation pane.""" 2 | 3 | from textual.app import ComposeResult 4 | from textual.widgets import Markdown, Tree 5 | from textual.widgets.markdown import MarkdownTableOfContents 6 | 7 | from .navigation_pane import NavigationPane 8 | 9 | 10 | class TableOfContents(NavigationPane): 11 | """Markdown document table of contents navigation pane.""" 12 | 13 | DEFAULT_CSS = """ 14 | TableOfContents { 15 | height: 100%; 16 | } 17 | 18 | TableOfContents > MarkdownTableOfContents { 19 | background: $panel; 20 | border: none; 21 | } 22 | 23 | TableOfContents > MarkdownTableOfContents > Tree { 24 | width: 1fr; 25 | background: $panel; 26 | padding: 0; 27 | } 28 | 29 | TableOfContents > MarkdownTableOfContents > Tree:focus .tree--cursor, TableOfContents > MarkdownTableOfContents > Tree .tree--cursor { 30 | background: $accent 50%; 31 | color: $text; 32 | } 33 | """ 34 | 35 | def __init__(self) -> None: 36 | """Initialise the table of contents navigation pane.""" 37 | super().__init__("Contents") 38 | 39 | def set_focus_within(self) -> None: 40 | """Ensure the tree in the table of contents is focused.""" 41 | self.query_one("MarkdownTableOfContents > Tree", Tree).focus( 42 | scroll_visible=False 43 | ) 44 | 45 | def compose(self) -> ComposeResult: 46 | """Compose the child widgets.""" 47 | # Note the use of a throwaway Markdown object. Textual 0.24 48 | # introduced a requirement for MarkdownTableOfContents to take a 49 | # reference to a Markdown document; this is a problem if you're 50 | # composing the ToC in a location somewhere unrelated to the 51 | # document itself, such that you can't guarantee the order in which 52 | # they're compose. I'm not using the ToC in a way that's 53 | # tightly-coupled to the document, neither am I using multiple ToCs 54 | # and documents. So... we make one and ignore it. 55 | # 56 | # https://github.com/Textualize/textual/issues/2516 57 | yield MarkdownTableOfContents(Markdown()) 58 | 59 | def on_table_of_contents_updated( 60 | self, event: Markdown.TableOfContentsUpdated 61 | ) -> None: 62 | """Handle a table of contents update event. 63 | 64 | Args: 65 | event: The table of content update event to handle. 66 | """ 67 | self.query_one( 68 | MarkdownTableOfContents 69 | ).table_of_contents = event.table_of_contents 70 | -------------------------------------------------------------------------------- /frogmouth/widgets/omnibox.py: -------------------------------------------------------------------------------- 1 | """Provides the viewer's omnibox widget.""" 2 | 3 | from __future__ import annotations 4 | 5 | from pathlib import Path 6 | from re import compile as compile_regexp 7 | from typing import Type 8 | from webbrowser import open as open_url 9 | 10 | from httpx import URL 11 | from textual.message import Message 12 | from textual.reactive import var 13 | from textual.widgets import Input 14 | 15 | from ..utility import is_likely_url 16 | from ..utility.advertising import DISCORD, ORGANISATION_NAME, PACKAGE_NAME 17 | 18 | 19 | class Omnibox(Input): 20 | """The command and location input widget for the viewer.""" 21 | 22 | DEFAULT_CSS = """ 23 | Omnibox { 24 | dock: top; 25 | padding: 0; 26 | height: 3; 27 | } 28 | 29 | Omnibox .input--placeholder { 30 | color: $text 50%; 31 | } 32 | """ 33 | """Default styling for the omnibox.""" 34 | 35 | visiting: var[str] = var("") 36 | """The location that is being visited.""" 37 | 38 | def watch_visiting(self) -> None: 39 | """Watch the visiting reactive variable.""" 40 | self.placeholder = self.visiting or "Enter a location or command" 41 | if self.visiting: 42 | self.value = self.visiting 43 | 44 | _ALIASES: dict[str, str] = { 45 | "a": "about", 46 | "b": "bookmarks", 47 | "bm": "bookmarks", 48 | "bb": "bitbucket", 49 | "c": "contents", 50 | "cb": "codeberg", 51 | "cd": "chdir", 52 | "cl": "changelog", 53 | "gh": "github", 54 | "gl": "gitlab", 55 | "h": "history", 56 | "l": "local", 57 | "obs": "obsidian", 58 | "toc": "contents", 59 | "q": "quit", 60 | "?": "help", 61 | } 62 | """Command aliases.""" 63 | 64 | @staticmethod 65 | def _split_command(value: str) -> list[str]: 66 | """Split a value into a command and argument tail. 67 | 68 | Args: 69 | value: The value to split. 70 | 71 | Returns: 72 | A list of the command and the argument(s). 73 | """ 74 | command = value.split(None, 1) 75 | return [*command, ""] if len(command) == 1 else command 76 | 77 | def _is_command(self, value: str) -> bool: 78 | """Is the given string a known command? 79 | 80 | Args: 81 | value: The value to check. 82 | 83 | Returns: 84 | `True` if the string is a known command, `False` if not. 85 | """ 86 | command, *_ = self._split_command(value) 87 | return ( 88 | getattr(self, f"command_{self._ALIASES.get(command, command)}", None) 89 | is not None 90 | ) 91 | 92 | def _execute_command(self, command: str) -> None: 93 | """Execute the given command. 94 | 95 | Args: 96 | command: The comment to execute. 97 | """ 98 | command, arguments = self._split_command(command) 99 | getattr(self, f"command_{self._ALIASES.get(command, command)}")( 100 | arguments.strip() 101 | ) 102 | 103 | class LocalViewCommand(Message): 104 | """The local file view command.""" 105 | 106 | def __init__(self, path: Path) -> None: 107 | """Initialise the local view command. 108 | 109 | Args: 110 | path: The path to view. 111 | """ 112 | super().__init__() 113 | self.path = path 114 | """The path of the file to view.""" 115 | 116 | class RemoteViewCommand(Message): 117 | """The remote file view command.""" 118 | 119 | def __init__(self, url: URL) -> None: 120 | """Initialise the remove view command. 121 | 122 | Args: 123 | url: The URL of the remote file to view. 124 | """ 125 | super().__init__() 126 | self.url = url 127 | """The URL of the file to view.""" 128 | 129 | class LocalChdirCommand(Message): 130 | """Command for changing the local files directory.""" 131 | 132 | def __init__(self, target: Path) -> None: 133 | """Initialise the local files chdir command.""" 134 | super().__init__() 135 | self.target = target 136 | """The target directory to change to.""" 137 | 138 | def on_input_submitted(self, event: Input.Submitted) -> None: 139 | """Handle the user submitting the input. 140 | 141 | Args: 142 | event: The submit event. 143 | """ 144 | 145 | # Clean up whatever the user input. 146 | submitted = self.value.strip() 147 | 148 | # Now that we've got it, empty the value. We'll put it back 149 | # depending on the outcome. 150 | self.value = "" 151 | 152 | # Work through the possible options for what the user entered. 153 | if is_likely_url(submitted): 154 | # It looks like it's an URL of some description so try and load 155 | # it as such. 156 | self.post_message(self.RemoteViewCommand(URL(submitted))) 157 | elif (path := Path(submitted).expanduser().resolve()).exists(): 158 | # It's a match for something in the local filesystem. Is it... 159 | if path.is_file(): 160 | # a file! Try and open it for viewing. 161 | self.post_message(self.LocalViewCommand(path)) 162 | self.value = str(path) 163 | elif path.is_dir(): 164 | # Nope, it's a directory. Take that to be a request to open 165 | # the local file selection navigation pane with the 166 | # directory as the root. 167 | self.post_message(self.LocalChdirCommand(path)) 168 | else: 169 | # It's something that exists in the filesystem, but it's not 170 | # a directory or a file. Let's nope on that for now. 171 | return 172 | elif self._is_command(command := submitted.lower()): 173 | # Having checked for URLs and existing filesystem things, it's 174 | # now safe to look for commands. Having got here, it is a match 175 | # for a command so we handle it as such. 176 | self._execute_command(command) 177 | else: 178 | # Having got this far, the best thing to do now is assume that 179 | # the user was attempting to enter a filename to view and got it 180 | # wrong. So that they get some sort of feedback, let's attempt 181 | # to view it anyway. 182 | self.post_message(self.LocalViewCommand(Path(submitted))) 183 | # Because it'll raise an error and the user may want to edit the 184 | # input to get it right, we put the original input back in 185 | # place. 186 | self.value = submitted 187 | 188 | # If we got a match above stop the event. 189 | event.stop() 190 | 191 | class ContentsCommand(Message): 192 | """The table of contents command.""" 193 | 194 | def command_contents(self, _: str) -> None: 195 | """Handle the table of contents command.""" 196 | self.post_message(self.ContentsCommand()) 197 | 198 | class LocalFilesCommand(Message): 199 | """The local files command.""" 200 | 201 | def command_local(self, _: str) -> None: 202 | """View the local files.""" 203 | self.post_message(self.LocalFilesCommand()) 204 | 205 | class BookmarksCommand(Message): 206 | """The bookmarks command.""" 207 | 208 | def command_bookmarks(self, _: str) -> None: 209 | """View the bookmarks.""" 210 | self.post_message(self.BookmarksCommand()) 211 | 212 | class QuitCommand(Message): 213 | """The quit command.""" 214 | 215 | def command_quit(self, _: str) -> None: 216 | """The quit command.""" 217 | self.post_message(self.QuitCommand()) 218 | 219 | class HistoryCommand(Message): 220 | """The history command.""" 221 | 222 | def command_history(self, _: str) -> None: 223 | """The history command.""" 224 | self.post_message(self.HistoryCommand()) 225 | 226 | class AboutCommand(Message): 227 | """The about command.""" 228 | 229 | def command_about(self, _: str) -> None: 230 | """The about command.""" 231 | self.post_message(self.AboutCommand()) 232 | 233 | class HelpCommand(Message): 234 | """The help command.""" 235 | 236 | def command_help(self, _: str) -> None: 237 | """The help command.""" 238 | self.post_message(self.HelpCommand()) 239 | 240 | def command_chdir(self, target: str) -> None: 241 | """The chdir command. 242 | 243 | Args: 244 | target: The target directory to change to. 245 | """ 246 | self.post_message( 247 | self.LocalChdirCommand(Path(target or "~").expanduser().resolve()) 248 | ) 249 | 250 | _GUESS_BRANCH = compile_regexp( 251 | r"^(?P[^/ ]+)[/ ](?P[^ :]+)(?: +(?P[^ ]+))?$" 252 | ) 253 | """Regular expression for matching a repo and file where we'll guess the branch.""" 254 | 255 | _SPECIFIC_BRANCH = compile_regexp( 256 | r"^(?P[^/ ]+)[/ ](?P[^ :]+):(?P[^ ]+)(?: +(?P[^ ]+))?$" 257 | ) 258 | """Regular expression for matching a repo and file where the branch is also given.""" 259 | 260 | class ForgeCommand(Message): 261 | """The base git forge quick load command.""" 262 | 263 | def __init__( 264 | self, 265 | owner: str, 266 | repository: str, 267 | branch: str | None = None, 268 | desired_file: str | None = None, 269 | ) -> None: 270 | """Initialise the git forge quick load command.""" 271 | super().__init__() 272 | self.owner = owner 273 | """The owner of the repository.""" 274 | self.repository = repository 275 | """The repository.""" 276 | self.branch: str | None = branch 277 | """The optional branch to attempt to pull the file from.""" 278 | self.desired_file: str | None = desired_file 279 | """The optional file the user wants from the repository.""" 280 | 281 | def _forge_quick_look(self, command: Type[ForgeCommand], tail: str) -> None: 282 | """Core forge quick look support method. 283 | 284 | Args: 285 | command: The command message to be posted. 286 | tail: The tail of the command to be parsed. 287 | """ 288 | tail = tail.strip() 289 | if hit := self._GUESS_BRANCH.match(tail): 290 | self.post_message( 291 | command(hit["owner"], hit["repo"], desired_file=hit["file"]) 292 | ) 293 | elif hit := self._SPECIFIC_BRANCH.match(tail): 294 | self.post_message( 295 | command( 296 | hit["owner"], 297 | hit["repo"], 298 | branch=hit["branch"], 299 | desired_file=hit["file"], 300 | ) 301 | ) 302 | 303 | class GitHubCommand(ForgeCommand): 304 | """The GitHub quick load command.""" 305 | 306 | def command_github(self, tail: str) -> None: 307 | """The github command. 308 | 309 | Args: 310 | tail: The tail of the command. 311 | """ 312 | self._forge_quick_look(self.GitHubCommand, tail) 313 | 314 | class GitLabCommand(ForgeCommand): 315 | """The GitLab quick load command.""" 316 | 317 | def command_gitlab(self, tail: str) -> None: 318 | """The Gitlab command. 319 | 320 | Args: 321 | tail: The tail of the command. 322 | """ 323 | self._forge_quick_look(self.GitLabCommand, tail) 324 | 325 | class BitBucketCommand(ForgeCommand): 326 | """The BitBucket quick load command.""" 327 | 328 | def command_bitbucket(self, tail: str) -> None: 329 | """The BitBucket command. 330 | 331 | Args: 332 | tail: The tail of the command. 333 | """ 334 | self._forge_quick_look(self.BitBucketCommand, tail) 335 | 336 | class CodebergCommand(ForgeCommand): 337 | """The Codeberg quick load command.""" 338 | 339 | def command_codeberg(self, tail: str) -> None: 340 | """The Codeberg command. 341 | 342 | Args: 343 | tail: The tail of the command. 344 | """ 345 | self._forge_quick_look(self.CodebergCommand, tail) 346 | 347 | def command_discord(self, _: str) -> None: 348 | """The command to visit the Textualize discord server.""" 349 | open_url(DISCORD) 350 | 351 | def command_changelog(self, _: str) -> None: 352 | """The command to show the application's own ChangeLog""" 353 | self.command_github(f"{ORGANISATION_NAME}/{PACKAGE_NAME} ChangeLog.md") 354 | 355 | def command_obsidian(self, vault: str) -> None: 356 | """The command to visit an obsidian vault, if one can be seen. 357 | 358 | Args: 359 | vault: The vault to visit. 360 | 361 | If the vault name is empty, an attempt will be made to visit the 362 | root level of all Obsidian vaults. 363 | 364 | Note: 365 | At the moment this will only work with Obsidian on macOS where 366 | the vaults are being held in iCloud. 367 | """ 368 | # Right now this will only work on macOS. I've not used Obsidian on 369 | # any other OS so I'm unsure where the vault will be stored. I'll 370 | # add to this once I've found out. 371 | if ( 372 | target := ( 373 | Path( 374 | "~/Library/Mobile Documents/iCloud~md~obsidian/Documents" 375 | ).expanduser() 376 | / vault 377 | ) 378 | ).exists(): 379 | self.command_chdir(str(target)) 380 | -------------------------------------------------------------------------------- /frogmouth/widgets/viewer.py: -------------------------------------------------------------------------------- 1 | """The markdown viewer itself.""" 2 | 3 | from __future__ import annotations 4 | 5 | from collections import deque 6 | from pathlib import Path 7 | from typing import Callable 8 | from webbrowser import open as open_url 9 | 10 | from httpx import URL, AsyncClient, HTTPStatusError, RequestError 11 | from markdown_it import MarkdownIt 12 | from mdit_py_plugins import front_matter 13 | from textual import work 14 | from textual.app import ComposeResult 15 | from textual.binding import Binding 16 | from textual.containers import VerticalScroll 17 | from textual.message import Message 18 | from textual.reactive import var 19 | from textual.widgets import Markdown 20 | from typing_extensions import Final 21 | 22 | from .. import __version__ 23 | from ..dialogs import ErrorDialog 24 | from ..utility.advertising import APPLICATION_TITLE, USER_AGENT 25 | 26 | PLACEHOLDER = f"""\ 27 | # {APPLICATION_TITLE} {__version__} 28 | 29 | Welcome to {APPLICATION_TITLE}! 30 | """ 31 | 32 | 33 | class History: 34 | """Holds the browsing history for the viewer.""" 35 | 36 | MAXIMUM_HISTORY_LENGTH: Final[int] = 256 37 | """The maximum number of items we'll keep in history.""" 38 | 39 | def __init__(self, history: list[Path | URL] | None = None) -> None: 40 | """Initialise the history object.""" 41 | self._history: deque[Path | URL] = deque( 42 | history or [], maxlen=self.MAXIMUM_HISTORY_LENGTH 43 | ) 44 | """The list that holds the history of locations visited.""" 45 | self._current: int = max(len(self._history) - 1, 0) 46 | """The current location.""" 47 | 48 | @property 49 | def location(self) -> Path | URL | None: 50 | """The current location in the history.""" 51 | try: 52 | return self._history[self._current] 53 | except IndexError: 54 | return None 55 | 56 | @property 57 | def current(self) -> int | None: 58 | """The current location in history, or None if there is no current location.""" 59 | return None if self.location is None else self._current 60 | 61 | @property 62 | def locations(self) -> list[Path | URL]: 63 | """The locations in the history.""" 64 | return list(self._history) 65 | 66 | def remember(self, location: Path | URL) -> None: 67 | """Remember a new location in the history. 68 | 69 | Args: 70 | location: The location to remember. 71 | """ 72 | self._history.append(location) 73 | self._current = len(self._history) - 1 74 | 75 | def back(self) -> bool: 76 | """Go back in the history. 77 | 78 | Returns: 79 | `True` if the location changed, `False` if not. 80 | """ 81 | if self._current: 82 | self._current -= 1 83 | return True 84 | return False 85 | 86 | def forward(self) -> bool: 87 | """Go forward in the history. 88 | 89 | Returns: 90 | `True` if the location changed, `False` if not. 91 | """ 92 | if self._current < len(self._history) - 1: 93 | self._current += 1 94 | return True 95 | return False 96 | 97 | def __delitem__(self, index: int) -> None: 98 | del self._history[index] 99 | self._current = max(len(self._history) - 1, self._current) 100 | 101 | 102 | class Viewer(VerticalScroll, can_focus=True, can_focus_children=True): 103 | """The markdown viewer class.""" 104 | 105 | DEFAULT_CSS = """ 106 | Viewer { 107 | width: 1fr; 108 | scrollbar-gutter: stable; 109 | } 110 | """ 111 | 112 | BINDINGS = [ 113 | Binding("w,k", "scroll_up", "", show=False), 114 | Binding("s,j", "scroll_down", "", show=False), 115 | Binding("space", "page_down", "", show=False), 116 | Binding("b", "page_up", "", show=False), 117 | ] 118 | """Bindings for the Markdown viewer widget.""" 119 | 120 | history: var[History] = var(History) 121 | """The browsing history.""" 122 | 123 | viewing_location: var[bool] = var(False) 124 | """Is an actual location being viewed?""" 125 | 126 | class ViewerMessage(Message): 127 | """Base class for viewer messages.""" 128 | 129 | def __init__(self, viewer: Viewer) -> None: 130 | """Initialise the message. 131 | 132 | Args: 133 | viewer: The viewer sending the message. 134 | """ 135 | super().__init__() 136 | self.viewer: Viewer = viewer 137 | """The viewer that sent the message.""" 138 | 139 | class LocationChanged(ViewerMessage): 140 | """Message sent when the viewer location changes.""" 141 | 142 | class HistoryUpdated(ViewerMessage): 143 | """Message sent when the history is updated.""" 144 | 145 | def compose(self) -> ComposeResult: 146 | """Compose the markdown viewer.""" 147 | yield Markdown( 148 | PLACEHOLDER, 149 | parser_factory=lambda: MarkdownIt("gfm-like").use( 150 | front_matter.front_matter_plugin 151 | ), 152 | ) 153 | 154 | @property 155 | def document(self) -> Markdown: 156 | """The markdown document.""" 157 | return self.query_one(Markdown) 158 | 159 | @property 160 | def location(self) -> Path | URL | None: 161 | """The location that is currently being visited.""" 162 | return self.history.location if self.viewing_location else None 163 | 164 | def scroll_to_block(self, block_id: str) -> None: 165 | """Scroll the document to the given block ID. 166 | 167 | Args: 168 | block_id: The ID of the block to scroll to. 169 | """ 170 | self.scroll_to_widget(self.document.query_one(f"#{block_id}"), top=True) 171 | 172 | def _post_load(self, location: Path | URL, remember: bool = True) -> None: 173 | """Perform some post-load tasks. 174 | 175 | Args: 176 | location: The location that has been loaded. 177 | remember: Should we remember the location in the history? 178 | """ 179 | # We've loaded something fresh, ensure we're at the top. 180 | self.scroll_home(animate=False) 181 | # If we've made it in here we are viewing an actual location. 182 | self.viewing_location = True 183 | # Remember the location in the history if we're supposed to. 184 | if remember: 185 | self.history.remember(location) 186 | self.post_message(self.HistoryUpdated(self)) 187 | # Let anyone else know we've changed location. 188 | self.post_message(self.LocationChanged(self)) 189 | 190 | @work(exclusive=True) 191 | async def _local_load(self, location: Path, remember: bool = True) -> None: 192 | """Load a Markdown document from a local file. 193 | 194 | Args: 195 | location: The location to load from. 196 | remember: Should we remember the location in th ehistory? 197 | """ 198 | try: 199 | await self.document.load(location) 200 | except OSError as error: 201 | self.app.push_screen( 202 | ErrorDialog( 203 | "Error loading local document", 204 | f"{location}\n\n{error}.", 205 | ) 206 | ) 207 | else: 208 | self._post_load(location, remember) 209 | 210 | @work(exclusive=True) 211 | async def _remote_load(self, location: URL, remember: bool = True) -> None: 212 | """Load a Markdown document from a URL. 213 | 214 | Args: 215 | location: The location to load from. 216 | remember: Should we remember the location in the history? 217 | """ 218 | 219 | try: 220 | async with AsyncClient() as client: 221 | response = await client.get( 222 | location, 223 | follow_redirects=True, 224 | headers={"user-agent": USER_AGENT}, 225 | ) 226 | except RequestError as error: 227 | self.app.push_screen(ErrorDialog("Error getting document", str(error))) 228 | return 229 | 230 | try: 231 | response.raise_for_status() 232 | except HTTPStatusError as error: 233 | self.app.push_screen(ErrorDialog("Error getting document", str(error))) 234 | return 235 | 236 | # There didn't seem to be an error transporting the data, and 237 | # neither did there seem to be an error with the resource itself. So 238 | # at this point we should hopefully have the document's content. 239 | # However... it's possible we've been fooled into loading up 240 | # something that looked like it was a markdown file, but really it's 241 | # a web-rendering of such a file; so as a final check we make sure 242 | # we're looking at something that's plain text, or actually 243 | # Markdown. 244 | content_type = response.headers.get("content-type", "") 245 | if any( 246 | content_type.startswith(f"text/{sub_type}") 247 | for sub_type in ("plain", "markdown", "x-markdown") 248 | ): 249 | self.document.update(response.text) 250 | self._post_load(location, remember) 251 | else: 252 | # Didn't look like something we could handle with the Markdown 253 | # viewer. We could throw up an error, or we could just be nice 254 | # to the user. Let's be nice... 255 | open_url(str(location)) 256 | 257 | def visit(self, location: Path | URL, remember: bool = True) -> None: 258 | """Visit a location. 259 | 260 | Args: 261 | location: The location to visit. 262 | remember: Should this visit be added to the history? 263 | """ 264 | # Based on the type of the location, load up the content. 265 | if isinstance(location, Path): 266 | self._local_load(location.expanduser().resolve(), remember) 267 | elif isinstance(location, URL): 268 | self._remote_load(location, remember) 269 | else: 270 | raise ValueError("Unknown location type passed to the Markdown viewer") 271 | 272 | def reload(self) -> None: 273 | """Reload the current location.""" 274 | if self.location is not None: 275 | self.visit(self.location, False) 276 | 277 | def show(self, content: str) -> None: 278 | """Show some direct text in the viewer. 279 | 280 | Args: 281 | content: The text to show. 282 | """ 283 | self.viewing_location = False 284 | self.document.update(content) 285 | self.scroll_home(animate=False) 286 | 287 | def _jump(self, direction: Callable[[], bool]) -> None: 288 | """Jump in a particular direction within the history. 289 | 290 | Args: 291 | direction: A function that jumps in the desired direction. 292 | """ 293 | if direction(): 294 | if self.history.location is not None: 295 | self.visit(self.history.location, remember=False) 296 | 297 | def back(self) -> None: 298 | """Go back in the viewer history.""" 299 | self._jump(self.history.back) 300 | 301 | def forward(self) -> None: 302 | """Go forward in the viewer history.""" 303 | self._jump(self.history.forward) 304 | 305 | def load_history(self, history: list[Path | URL]) -> None: 306 | """Load up a history list from the given history. 307 | 308 | Args: 309 | history: The history load up from. 310 | """ 311 | self.history = History(history) 312 | self.post_message(self.HistoryUpdated(self)) 313 | 314 | def delete_history(self, history_id: int) -> None: 315 | """Delete an item from the history. 316 | 317 | Args: 318 | history_id: The ID of the history item to delete. 319 | """ 320 | try: 321 | del self.history[history_id] 322 | except IndexError: 323 | pass 324 | else: 325 | self.post_message(self.HistoryUpdated(self)) 326 | 327 | def clear_history(self) -> None: 328 | """Clear down the whole of history.""" 329 | self.load_history([]) 330 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.poetry] 2 | name = "frogmouth" 3 | homepage = "https://github.com/Textualize/frogmouth" 4 | version = "0.9.2" 5 | description = "A Markdown document viewer for the terminal" 6 | authors = ["Dave Pearson "] 7 | license = "MIT" 8 | readme = "README.md" 9 | packages = [{include = "frogmouth"}] 10 | classifiers = [ 11 | "Development Status :: 4 - Beta", 12 | "Environment :: Console", 13 | "Intended Audience :: Developers", 14 | "Intended Audience :: End Users/Desktop", 15 | "Intended Audience :: Information Technology", 16 | "Intended Audience :: Other Audience", 17 | "Operating System :: MacOS", 18 | "Operating System :: Microsoft :: Windows :: Windows 10", 19 | "Operating System :: Microsoft :: Windows :: Windows 11", 20 | "Operating System :: POSIX :: Linux", 21 | "Programming Language :: Python :: 3.10", 22 | "Programming Language :: Python :: 3.11", 23 | "Programming Language :: Python :: 3.8", 24 | "Programming Language :: Python :: 3.9", 25 | "Topic :: Software Development :: Documentation", 26 | "Topic :: Text Processing :: Markup :: Markdown", 27 | ] 28 | 29 | [tool.poetry.dependencies] 30 | python = "^3.8" 31 | textual = "==0.53.1" 32 | typing-extensions = "^4.5.0" 33 | httpx = "^0.24.1" 34 | xdg = "^6.0.0" 35 | 36 | 37 | [tool.poetry.group.dev.dependencies] 38 | textual-dev = "^1.1" 39 | mypy = "^1.1.1" 40 | pylint = "^2.17.1" 41 | pre-commit = "^3.2.1" 42 | 43 | [build-system] 44 | requires = ["poetry-core"] 45 | build-backend = "poetry.core.masonry.api" 46 | 47 | [tool.poetry.scripts] 48 | frogmouth = "frogmouth.app.app:run" 49 | --------------------------------------------------------------------------------