Textual Example

python
tui
textual
Author

@hrbrmstr

Published

September 29, 2023

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

TAGS_URL = "https://viz.greynoise.io/gn-api/greynoise/v2/meta/metadata"

CACHE_EXPIRATION = 24 * 60 * 60  # 24 hours in seconds

def fetch_and_cache_data(url: str, app_name: str) -> str:
  # Get the cache directory path based on the XDG Base Directory Specification
  cache_dir = os.path.join(xdg_cache_home(), app_name)

  # 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
  cache_file_path = os.path.join(cache_dir, url.replace("/", "_"))

  # 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
    response = requests.get(url)
    data = response.text

    # 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."""

  CSS_PATH = "tags.tcss"

  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__":
  app = TagsApp()
  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."""
  
  regex = re.compile(tag, re.IGNORECASE)
  results = [ element for element in self.tag_names if regex.search(element) ]

  if tag == self.query_one(Input).value:
    markdown = self.make_tag_markdown(results)
    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 = [ tag for tag in self.metadata if tag["name"] == result ]
      lines.append(f"## {result}")
      lines.append(f"{tag[0]['description']}  \n  ")
      lines.append(f"{intent_dots[tag[0]['intention']]}")
  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.