feat(ui-terminal): textual examples
This commit is contained in:
parent
9623e2090c
commit
dc2a19051f
5 changed files with 297 additions and 0 deletions
88
src/code_browser.py
Normal file
88
src/code_browser.py
Normal 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
31
src/code_browser.tcss
Normal 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
75
src/dictionary.py
Normal 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
25
src/dictionary.tcss
Normal 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
78
src/markdown.py
Normal 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()
|
||||||
Loading…
Reference in a new issue