├── .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 | [](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 |
40 |
41 | |
42 |
43 |
44 |
45 | |
46 |
47 |
48 |
49 |
50 |
51 |
52 | |
53 |
54 |
55 |
56 | |
57 |
58 |
59 |
60 |
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 |
--------------------------------------------------------------------------------