Skip to content

Commit

Permalink
Merge branch 'main' of github.com:Textualize/textual into log-slow-me…
Browse files Browse the repository at this point in the history
…ssage
  • Loading branch information
darrenburns committed Sep 11, 2024
2 parents 6902a61 + 2429c30 commit 0946657
Show file tree
Hide file tree
Showing 151 changed files with 8,253 additions and 2,507 deletions.
66 changes: 65 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,23 +5,83 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](http://keepachangelog.com/)
and this project adheres to [Semantic Versioning](http://semver.org/).

## Unreleased
## [Unreleased]

### Added

- Added `MaskedInput` widget https://github.com/Textualize/textual/pull/4783
- Input validation for floats and integers accept embedded underscores, e.g., "1_234_567" is valid. https://github.com/Textualize/textual/pull/4784

### Changed

- Input validation for integers no longer accepts scientific notation like '1.5e2'; must be castable to int. https://github.com/Textualize/textual/pull/4784

### Fixed

- Input validation of floats no longer accepts NaN (not a number). https://github.com/Textualize/textual/pull/4784
- Fixed issues with screenshots by simplifying segments only for snapshot tests https://github.com/Textualize/textual/issues/4929

## [0.79.1] - 2024-08-31

### Fixed

- Fixed broken updates when non active screen changes https://github.com/Textualize/textual/pull/4957

## [0.79.0] - 2024-08-30

### Added

- Added `DOMNode.check_consume_key` https://github.com/Textualize/textual/pull/4940
- Added `App.ESCAPE_TO_MINIMIZE`, `App.screen_to_minimize`, and `Screen.ESCAPE_TO_MINIMIZE` https://github.com/Textualize/textual/pull/4951
- Added `DOMNode.query_exactly_one` https://github.com/Textualize/textual/pull/4950
- Added `SelectorSet.is_simple` https://github.com/Textualize/textual/pull/4950

### Changed

- KeyPanel will show multiple keys if bound to the same action https://github.com/Textualize/textual/pull/4940
- Breaking change: `DOMNode.query_one` will not `raise TooManyMatches` https://github.com/Textualize/textual/pull/4950

## [0.78.0] - 2024-08-27

### Added

- Added Maximize and Minimize system commands. https://github.com/Textualize/textual/pull/4931
- Added `Screen.maximize`, `Screen.minimize`, `Screen.action_maximize`, `Screen.action_minimize`, `Widget.is_maximized`, `Widget.allow_maximize`. https://github.com/Textualize/textual/pull/4931
- Added `Widget.ALLOW_MAXIMIZE`, `Screen.ALLOW_IN_MAXIMIZED_VIEW` classvars https://github.com/Textualize/textual/pull/4931

## [0.77.0] - 2024-08-22

### Added

- Added `tooltip` to Binding https://github.com/Textualize/textual/pull/4859
- Added a link to the command palette to the Footer (set `show_command_palette=False` to disable) https://github.com/Textualize/textual/pull/4867
- Added `TOOLTIP_DELAY` to App to customize time until a tooltip is displayed
- Added "Show keys" option to system commands to show a summary of key bindings. https://github.com/Textualize/textual/pull/4876
- Added "split" CSS style, currently undocumented, and may change. https://github.com/Textualize/textual/pull/4876
- Added `Region.get_spacing_between` https://github.com/Textualize/textual/pull/4876
- Added `App.COMMAND_PALETTE_KEY` to change default command palette key binding https://github.com/Textualize/textual/pull/4867
- Added `App.get_key_display` https://github.com/Textualize/textual/pull/4890
- Added `DOMNode.BINDING_GROUP` https://github.com/Textualize/textual/pull/4906
- Added `DOMNode.HELP` classvar which contains Markdown help to be shown in the help panel https://github.com/Textualize/textual/pull/4915
- Added `App.get_system_commands` https://github.com/Textualize/textual/pull/4920
- Added "Save Screenshot" system command https://github.com/Textualize/textual/pull/4922

### Changed

- Removed caps_lock and num_lock modifiers https://github.com/Textualize/textual/pull/4861
- Keys such as escape and space are now displayed in lower case in footer https://github.com/Textualize/textual/pull/4876
- Changed default command palette binding to `ctrl+p` https://github.com/Textualize/textual/pull/4867
- Removed `ctrl_to_caret` and `upper_case_keys` from Footer. These can be implemented in `App.get_key_display`.
- Renamed `SystemCommands` to `SystemCommandsProvider` https://github.com/Textualize/textual/pull/4920
- Breaking change: Removed `ClassicFooter` widget (please use new `Footer` widget) https://github.com/Textualize/textual/pull/4921
- Disallowed `Screen` instances in `App.SCREENS` and `App.MODES`

### Fixed

- Fix crash when `validate_on` value isn't a set https://github.com/Textualize/textual/pull/4868
- Fix `Input.cursor_blink` having no effect on the blink cycle after mounting https://github.com/Textualize/textual/pull/4869
- Fixed scrolling by page not taking scrollbar in to account https://github.com/Textualize/textual/pull/4916
- Fixed `App.MODES` being the same for all instances -- per-instance modes now exist internally

## [0.76.0]

Expand All @@ -48,6 +108,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/).

- Fixed issue with Enter events causing unresponsive UI https://github.com/Textualize/textual/pull/4833


## [0.75.0] - 2024-08-01

### Added
Expand Down Expand Up @@ -2293,6 +2354,9 @@ https://textual.textualize.io/blog/2022/11/08/version-040/#version-040
- New handler system for messages that doesn't require inheritance
- Improved traceback handling

[0.79.0]: https://github.com/Textualize/textual/compare/v0.78.0...v0.79.0
[0.78.0]: https://github.com/Textualize/textual/compare/v0.77.0...v0.78.0
[0.77.0]: https://github.com/Textualize/textual/compare/v0.76.0...v0.77.0
[0.76.0]: https://github.com/Textualize/textual/compare/v0.75.1...v0.76.0
[0.75.1]: https://github.com/Textualize/textual/compare/v0.75.0...v0.75.1
[0.75.0]: https://github.com/Textualize/textual/compare/v0.74.0...v0.75.0
Expand Down
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
253 changes: 253 additions & 0 deletions docs/blog/posts/anatomy-of-a-textual-user-interface.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,253 @@
---
draft: false
date: 2024-09-15
categories:
- DevLog
authors:
- willmcgugan
---

# Anatomy of a Textual User Interface

!!! note "My bad 🤦"

The date is wrong on this post—it was actually published on the 2nd of September 2024.
I don't want to fix it, as that would break the URL.

I recently wrote a [TUI](https://en.wikipedia.org/wiki/Text-based_user_interface) to chat to an AI agent in the terminal.
I'm not the first to do this (shout out to [Elia](https://github.com/darrenburns/elia) and [Paita](https://github.com/villekr/paita)), but I *may* be the first to have it reply as if it were the AI from the Aliens movies?

Here's a video of it in action:



<iframe width="100%" style="aspect-ratio:1512 / 982" src="https://www.youtube.com/embed/hr5JvQS4d_w" title="Mother AI" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" referrerpolicy="strict-origin-when-cross-origin" allowfullscreen></iframe>

Now let's dissect the code like Bishop dissects a facehugger.

<!-- more -->

## All right, sweethearts, what are you waiting for? Breakfast in bed?

At the top of the file we have some boilerplate:

```python
# /// script
# requires-python = ">=3.12"
# dependencies = [
# "llm",
# "textual",
# ]
# ///
from textual import on, work
from textual.app import App, ComposeResult
from textual.widgets import Header, Input, Footer, Markdown
from textual.containers import VerticalScroll
import llm

SYSTEM = """Formulate all responses as if you where the sentient AI named Mother from the Aliens movies."""
```

The text in the comment is a relatively new addition to the Python ecosystem.
It allows you to specify dependencies inline so that tools can setup an environment automatically.
The format of the comment was developed by [Ofek Lev](https://github.com/ofek) and first implemented in [Hatch](https://hatch.pypa.io/latest/blog/2024/05/02/hatch-v1100/#python-script-runner), and has since become a Python standard via [PEP 0723](https://peps.python.org/pep-0723/) (also authored by Ofek).

!!! note

PEP 0723 is also implemented in [uv](https://docs.astral.sh/uv/guides/scripts/#running-scripts).

I really like this addition to Python because it means I can now share a Python script without the recipient needing to manually setup a fresh environment and install dependencies.

After this comment we have a bunch of imports: [textual](https://github.com/textualize/textual) for the UI, and [llm](https://llm.datasette.io/en/stable/) to talk to ChatGPT (also supports other LLMs).

Finally, we define `SYSTEM`, which is the *system prompt* for the LLM.

## Look, those two specimens are worth millions to the bio-weapons division.

Next up we have the following:

```python

class Prompt(Markdown):
pass


class Response(Markdown):
BORDER_TITLE = "Mother"
```

These two classes define the widgets which will display text the user enters and the response from the LLM.
They both extend the builtin [Markdown](https://textual.textualize.io/widgets/markdown/) widget, since LLMs like to talk in that format.

## Well, somebody's gonna have to go out there. Take a portable terminal, go out there and patch in manually.

Following on from the widgets we have the following:

```python
class MotherApp(App):
AUTO_FOCUS = "Input"

CSS = """
Prompt {
background: $primary 10%;
color: $text;
margin: 1;
margin-right: 8;
padding: 1 2 0 2;
}
Response {
border: wide $success;
background: $success 10%;
color: $text;
margin: 1;
margin-left: 8;
padding: 1 2 0 2;
}
"""
```

This defines an app, which is the top-level object for any Textual app.

The `AUTO_FOCUS` string is a classvar which causes a particular widget to receive input focus when the app starts. In this case it is the `Input` widget, which we will define later.

The classvar is followed by a string containing CSS.
Technically, TCSS or *Textual Cascading Style Sheets*, a variant of CSS for terminal interfaces.

This isn't a tutorial, so I'm not going to go in to a details, but we're essentially setting properties on widgets which define how they look.
Here I styled the prompt and response widgets to have a different color, and tried to give the response a retro tech look with a green background and border.

We could express these styles in code.
Something like this:

```python
self.styles.color = "red"
self.styles.margin = 8
```

Which is fine, but CSS shines when the UI get's more complex.

## Look, man. I only need to know one thing: where they are.

After the app constants, we have a method called `compose`:

```python
def compose(self) -> ComposeResult:
yield Header()
with VerticalScroll(id="chat-view"):
yield Response("INTERFACE 2037 READY FOR INQUIRY")
yield Input(placeholder="How can I help you?")
yield Footer()
```

This method adds the initial widgets to the UI.

`Header` and `Footer` are builtin widgets.

Sandwiched between them is a `VerticalScroll` *container* widget, which automatically adds a scrollbar (if required). It is pre-populated with a single `Response` widget to show a welcome message (the `with` syntax places a widget within a parent widget). Below that is an `Input` widget where we can enter text for the LLM.

This is all we need to define the *layout* of the TUI.
In Textual the layout is defined with styles (in the same was as color and margin).
Virtually any layout is possible, and you never have to do any math to calculate sizes of widgets&mdash;it is all done declaratively.

We could add a little CSS to tweak the layout, but the defaults work well here.
The header and footer are *docked* to an appropriate edge.
The `VerticalScroll` widget is styled to consume any available space, leaving room for widgets with a defined height (like our `Input`).

If you resize the terminal it will keep those relative proportions.

## Look into my eye.

The next method is an *event handler*.


```python
def on_mount(self) -> None:
self.model = llm.get_model("gpt-4o")
```

This method is called when the app receives a Mount event, which is one of the first events sent and is typically used for any setup operations.

It gets a `Model` object got our LLM of choice, which we will use later.

Note that the [llm](https://llm.datasette.io/en/stable/) library supports a [large number of models](https://llm.datasette.io/en/stable/openai-models.html), so feel free to replace the string with the model of your choice.

## We're in the pipe, five by five.

The next method is also a message handler:

```python
@on(Input.Submitted)
async def on_input(self, event: Input.Submitted) -> None:
chat_view = self.query_one("#chat-view")
event.input.clear()
await chat_view.mount(Prompt(event.value))
await chat_view.mount(response := Response())
response.anchor()
self.send_prompt(event.value, response)
```

The decorator tells Textual to handle the `Input.Submitted` event, which is sent when the user hits return in the Input.

!!! info "More on event handlers"

There are two ways to receive events in Textual: a naming convention or the decorator.
They aren't on the base class because the app and widgets can receive arbitrary events.

When that happens, this method clears the input and adds the prompt text to the `VerticalScroll`.
It also adds a `Response` widget to contain the LLM's response, and *anchors* it.
Anchoring a widget will keep it at the bottom of a scrollable view, which is just what we need for a chat interface.

Finally in that method we call `send_prompt`.

## We're on an express elevator to hell, going down!

Here is `send_prompt`:

```python
@work(thread=True)
def send_prompt(self, prompt: str, response: Response) -> None:
response_content = ""
llm_response = self.model.prompt(prompt, system=SYSTEM)
for chunk in llm_response:
response_content += chunk
self.call_from_thread(response.update, response_content)
```

You'll notice that it is decorated with `@work`, which turns this method in to a *worker*.
In this case, a *threaded* worker. Workers are a layer over async and threads, which takes some of the pain out of concurrency.

This worker is responsible for sending the prompt, and then reading the response piece-by-piece.
It calls the Markdown widget's `update` method which replaces its content with new Markdown code, to give that funky streaming text effect.


## Game over man, game over!

The last few lines creates an app instance and runs it:

```python
if __name__ == "__main__":
app = MotherApp()
app.run()
```

You may need to have your [API key](https://help.openai.com/en/articles/4936850-where-do-i-find-my-openai-api-key) set in an environment variable.
Or if you prefer, you could set in the `on_mount` function with the following:

```python
self.model.key = "... key here ..."
```

## Not bad, for a human.

Here's the [code for the Mother AI](https://gist.github.com/willmcgugan/648a537c9d47dafa59cb8ece281d8c2c).

Run the following in your shell of choice to launch mother.py (assumes you have [uv](https://docs.astral.sh/uv/) installed):

```base
uv run mother.py
```

## You know, we manufacture those, by the way.

Join our [Discord server](https://discord.gg/Enf6Z3qhVr) to discuss more 80s movies (or possibly TUIs).
Loading

0 comments on commit 0946657

Please sign in to comment.