From dc2a19051ff9274dd998730efbea6f8584f488e0 Mon Sep 17 00:00:00 2001 From: gwen Date: Mon, 29 Jun 2026 22:14:45 +0200 Subject: [PATCH] feat(ui-terminal): textual examples --- src/code_browser.py | 88 +++++++++++++++++++++++++++++++++++++++++++ src/code_browser.tcss | 31 +++++++++++++++ src/dictionary.py | 75 ++++++++++++++++++++++++++++++++++++ src/dictionary.tcss | 25 ++++++++++++ src/markdown.py | 78 ++++++++++++++++++++++++++++++++++++++ 5 files changed, 297 insertions(+) create mode 100644 src/code_browser.py create mode 100644 src/code_browser.tcss create mode 100644 src/dictionary.py create mode 100644 src/dictionary.tcss create mode 100644 src/markdown.py diff --git a/src/code_browser.py b/src/code_browser.py new file mode 100644 index 0000000..56382f1 --- /dev/null +++ b/src/code_browser.py @@ -0,0 +1,88 @@ +""" +Code browser example. + +Run with: + + python code_browser.py PATH +""" + +from __future__ import annotations + +import sys +from pathlib import Path + +from rich.traceback import Traceback + +from textual.app import App, ComposeResult +from textual.containers import Container, VerticalScroll +from textual.highlight import highlight +from textual.reactive import reactive, var +from textual.widgets import DirectoryTree, Footer, Header, Static + + +class CodeBrowser(App): + """Textual code browser app.""" + + CSS_PATH = "code_browser.tcss" + BINDINGS = [ + ("f", "toggle_files", "Toggle Files"), + ("q", "quit", "Quit"), + ] + + show_tree = var(True) + path: reactive[str | None] = reactive(None) + + def watch_show_tree(self, show_tree: bool) -> None: + """Called when show_tree is modified.""" + self.set_class(show_tree, "-show-tree") + + def compose(self) -> ComposeResult: + """Compose our UI.""" + path = "./" if len(sys.argv) < 2 else sys.argv[1] + yield Header() + with Container(): + yield DirectoryTree(path, id="tree-view") + with VerticalScroll(id="code-view"): + yield Static(id="code", expand=True) + yield Footer() + + def on_mount(self) -> None: + self.query_one(DirectoryTree).focus() + + def theme_change(_signal) -> None: + """Force the syntax to use a different theme.""" + self.watch_path(self.path) + + self.theme_changed_signal.subscribe(self, theme_change) + + def on_directory_tree_file_selected( + self, event: DirectoryTree.FileSelected + ) -> None: + """Called when the user click a file in the directory tree.""" + event.stop() + self.path = str(event.path) + + def watch_path(self, path: str | None) -> None: + """Called when path changes.""" + code_view = self.query_one("#code", Static) + if path is None: + code_view.update("") + return + try: + code = Path(path).read_text(encoding="utf-8") + syntax = highlight(code, path=path) + except Exception: + code_view.update(Traceback(theme="github-dark", width=None)) + self.sub_title = "ERROR" + else: + code_view.update(syntax) + self.query_one("#code-view").scroll_home(animate=False) + self.sub_title = path + + def action_toggle_files(self) -> None: + """Called in response to key binding.""" + self.show_tree = not self.show_tree + + +if __name__ == "__main__": + CodeBrowser().run() diff --git a/src/code_browser.tcss b/src/code_browser.tcss new file mode 100644 index 0000000..7d2e5f6 --- /dev/null +++ b/src/code_browser.tcss @@ -0,0 +1,31 @@ +Screen { + &:inline { + height: 50vh; + } +} + +#tree-view { + display: none; + scrollbar-gutter: stable; + overflow: auto; + width: auto; + height: 100%; + dock: left; +} + +CodeBrowser.-show-tree #tree-view { + display: block; + max-width: 50%; +} + + +#code-view { + overflow: auto scroll; + min-width: 100%; + hatch: right $panel; +} +#code { + width: auto; + padding: 0 1; + background: $surface; +} diff --git a/src/dictionary.py b/src/dictionary.py new file mode 100644 index 0000000..f68df02 --- /dev/null +++ b/src/dictionary.py @@ -0,0 +1,75 @@ +from __future__ import annotations + +try: + import httpx +except ImportError: + raise ImportError("Please install httpx with 'pip install httpx' ") + + +from textual import getters, work +from textual.app import App, ComposeResult +from textual.containers import VerticalScroll +from textual.widgets import Input, Markdown + + +class DictionaryApp(App): + """Searches a dictionary API as-you-type.""" + + CSS_PATH = "dictionary.tcss" + + results = getters.query_one("#results", Markdown) + input = getters.query_one(Input) + + def compose(self) -> ComposeResult: + yield Input(placeholder="Search for a word", id="dictionary-search") + with VerticalScroll(id="results-container"): + yield Markdown(id="results") + + async def on_input_changed(self, message: Input.Changed) -> None: + """A coroutine to handle a text changed message.""" + if message.value: + self.lookup_word(message.value) + else: + # Clear the results + await self.results.update("") + + @work(exclusive=True) + async def lookup_word(self, word: str) -> None: + """Looks up a word.""" + url = f"https://api.dictionaryapi.dev/api/v2/entries/en/{word}" + + async with httpx.AsyncClient() as client: + response = await client.get(url) + try: + results = response.json() + except Exception: + self.results.update(response.text) + return + + if word == self.input.value: + markdown = self.make_word_markdown(results) + self.results.update(markdown) + + def make_word_markdown(self, results: object) -> str: + """Convert the results into markdown.""" + lines = [] + if isinstance(results, dict): + lines.append(f"# {results['title']}") + lines.append(results["message"]) + elif isinstance(results, list): + for result in results: + lines.append(f"# {result['word']}") + lines.append("") + for meaning in result.get("meanings", []): + lines.append(f"_{meaning['partOfSpeech']}_") + lines.append("") + for definition in meaning.get("definitions", []): + lines.append(f" - {definition['definition']}") + lines.append("---") + + return "\n".join(lines) + + +if __name__ == "__main__": + app = DictionaryApp() + app.run() diff --git a/src/dictionary.tcss b/src/dictionary.tcss new file mode 100644 index 0000000..7ca8eb6 --- /dev/null +++ b/src/dictionary.tcss @@ -0,0 +1,25 @@ +Screen { + background: $panel; +} + +Input#dictionary-search { + dock: top; + margin: 1 0; +} + +#results { + width: 100%; + height: auto; +} + +#results-container { + background: $surface; + margin: 0 0 1 0; + height: 100%; + overflow: hidden auto; + border: tall transparent; +} + +#results-container:focus { + border: tall $border; +} diff --git a/src/markdown.py b/src/markdown.py new file mode 100644 index 0000000..eb8bd16 --- /dev/null +++ b/src/markdown.py @@ -0,0 +1,78 @@ +from __future__ import annotations + +from pathlib import Path +from sys import argv + +from textual.app import App, ComposeResult +from textual.binding import Binding +from textual.reactive import var +from textual.widgets import Footer, MarkdownViewer + + +class MarkdownApp(App): + """A simple Markdown viewer application.""" + + BINDINGS = [ + Binding( + "t", + "toggle_table_of_contents", + "TOC", + tooltip="Toggle the Table of Contents Panel", + ), + Binding("b", "back", "Back", tooltip="Navigate back"), + Binding("f", "forward", "Forward", tooltip="Navigate forward"), + ] + + path = var(Path(__file__).parent / "demo.md") + + @property + def markdown_viewer(self) -> MarkdownViewer: + """Get the Markdown widget.""" + return self.query_one(MarkdownViewer) + + def compose(self) -> ComposeResult: + yield Footer() + yield MarkdownViewer() + + async def on_mount(self) -> None: + """Go to the first path when the app starts.""" + try: + await self.markdown_viewer.go(self.path) + except FileNotFoundError: + self.exit(message=f"Unable to load {self.path!r}") + + def on_markdown_viewer_navigator_updated(self) -> None: + """Refresh bindings for forward / back when the document changes.""" + self.refresh_bindings() + + def action_toggle_table_of_contents(self) -> None: + """Toggles display of the table of contents.""" + self.markdown_viewer.show_table_of_contents = ( + not self.markdown_viewer.show_table_of_contents + ) + + async def action_back(self) -> None: + """Navigate backwards.""" + await self.markdown_viewer.back() + + async def action_forward(self) -> None: + """Navigate forwards.""" + await self.markdown_viewer.forward() + + def check_action(self, action: str, _) -> bool | None: + """Check if certain actions can be performed.""" + if action == "forward" and self.markdown_viewer.navigator.end: + # Disable footer link if we can't go forward + return None + if action == "back" and self.markdown_viewer.navigator.start: + # Disable footer link if we can't go backward + return None + # All other keys display as normal + return True + + +if __name__ == "__main__": + app = MarkdownApp() + if len(argv) > 1 and Path(argv[1]).exists(): + app.path = Path(argv[1]) + app.run()