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