feat(ui-terminal): textual examples

This commit is contained in:
gwen 2026-06-29 22:14:45 +02:00
parent 9623e2090c
commit dc2a19051f
5 changed files with 297 additions and 0 deletions

88
src/code_browser.py Normal file
View file

@ -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()

31
src/code_browser.tcss Normal file
View file

@ -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;
}

75
src/dictionary.py Normal file
View file

@ -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()

25
src/dictionary.tcss Normal file
View file

@ -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;
}

78
src/markdown.py Normal file
View file

@ -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()