This is a companion piece to Drop #343 to show how to use Textual.
You will need to do one more “pip install
” dance to follow along here:
$ python3 -m pip install xdg-base-dirs
So, this app we are building will let you type into a textbox and it will lookup Tag names that match what you have typed, displaying the details in a scrollable area below the input box.
We’re pulling this data from an API call, but the data doesn’t change that quickly, so let’s get the boring bit of code that handles fetching and caching this API data out of the way now:
from xdg_base_dirs import xdg_cache_home
= "https://viz.greynoise.io/gn-api/greynoise/v2/meta/metadata"
TAGS_URL
= 24 * 60 * 60 # 24 hours in seconds
CACHE_EXPIRATION
def fetch_and_cache_data(url: str, app_name: str) -> str:
# Get the cache directory path based on the XDG Base Directory Specification
= os.path.join(xdg_cache_home(), app_name)
cache_dir
# Create cache directory if it doesn't exist
if not os.path.exists(cache_dir):
os.makedirs(cache_dir)
# Generate a cache file path based on the URL
= os.path.join(cache_dir, url.replace("/", "_"))
cache_file_path
# Check if the cache file exists and is not expired
if os.path.exists(cache_file_path) and time.time() - os.path.getmtime(cache_file_path) < CACHE_EXPIRATION:
# Read and return the cached content
with open(cache_file_path, "r") as cache_file:
return cache_file.read()
else:
# Fetch the data from the URL
= requests.get(url)
response = response.text
data
# Cache the data by writing it to a file
with open(cache_file_path, "w") as cache_file:
cache_file.write(data)
return data
Please see the inline comments for a description of what it does.
The core of the Textual part is very similar to the minimal shell we looked at eariler:
from textual.app import App, ComposeResult
from textual.containers import VerticalScroll
from textual.widgets import Header, Footer, Input, Markdown
class TagsApp(App):
"""A Textual app to Browse GreyNoise Tags."""
= "tags.tcss"
CSS_PATH
= [
BINDINGS "d", "toggle_dark", "Toggle dark mode"),
("ctrl+q", "quit", "Quit")
(
]
def compose(self) -> ComposeResult:
yield Header()
yield Input(placeholder="Search for a tag")
with VerticalScroll(id="results-container"):
yield Markdown(id="results")
yield Footer()
def action_toggle_dark(self) -> None:
"""An action to toggle dark mode."""
self.dark = not self.dark
if __name__ == "__main__":
= TagsApp()
app app.run()
The compose()
bit is where we define the layout of the app. We have a header, an input box, a scrollable area to display the results, and a footer. The BINDINGS
are there to tell the app what to do based on what keys are hit. And, the CSS_PATH
is where we define the styling for our app. There isn’t a ton in that CSS file:
Header {background: #AAAAAA;
}
Footer {background: #AAAAAA;
}
Input {border: #AAAAAA;
}
We now need some remaining code to round out the corners and actually have an app.
First, let’s tell the app what to do after Textual gets the TUI parts ready:
async def on_mount(self) -> None:
self.tags = json.loads(fetch_and_cache_data(TAGS_URL, APP_NAME))
self.metadata = self.tags["metadata"]
self.tag_names = [ tag["name"] for tag in self.metadata ]
self.query_one(Input).focus()
That code block loads up the tag data and makes it avaialble to the app. We then focus input on the search/input box. on_mount
is a special class method that Textual knows how to call when it’s done with that setup bit.
We also need to know when someone types something into that box:
async def on_input_changed(self, message: Input.Changed) -> None:
"""A coroutine to handle a text changed message."""
if message.value:
self.lookup_tag(message.value)
else:
# Clear the results
self.query_one("#results", Markdown).update("")
And, we now need the bits that actually perform the lookup and display the results:
@work(exclusive=True)
async def lookup_tag(self, tag: str) -> None:
"""Looks up a tag."""
= re.compile(tag, re.IGNORECASE)
regex = [ element for element in self.tag_names if regex.search(element) ]
results
if tag == self.query_one(Input).value:
= self.make_tag_markdown(results)
markdown self.query_one("#results", Markdown).update(markdown)
def make_tag_markdown(self, results: object) -> str:
"""Convert the results in to markdown."""
= []
lines for result in results:
= [ tag for tag in self.metadata if tag["name"] == result ]
tag f"## {result}")
lines.append(f"{tag[0]['description']} \n ")
lines.append(f"{intent_dots[tag[0]['intention']]}")
lines.append(return "\n".join(lines)
It sure feels alot like writing event handlers in JavaScript.
The complete code is over at GitLab (sub out the “la” for “hu” if you like giving Microsoft telemetry). It contains one extra file: tags.toml
. This is a configuration file for textual-web
. To make this TUI a web app, all you need to do is run:
$ textual-web --config tags.toml
You can try that out right now.
Your Turn
Here are some ideas to help experiment more with Textual:
- Go look at the Tags on the site they come from and gradually add the filter controls and more elements to the results display.
- Take the weather app we built in Golang and turn it into a Textual app.
- Refine one or more of the example apps that come with the Textual repo.
- See what it took to make Harlequin.