Merge branch 'main' of https://github.com/Textualize/Textual into placeholder

This commit is contained in:
Rodrigo Girão Serrão
2022-12-05 15:08:01 +00:00
75 changed files with 5947 additions and 732 deletions

View File

@@ -0,0 +1 @@
::: textual.widgets.DirectoryTree

1
docs/api/tree.md Normal file
View File

@@ -0,0 +1 @@
::: textual.widgets.Tree

1
docs/api/tree_node.md Normal file
View File

@@ -0,0 +1 @@
::: textual.widgets.TreeNode

View File

@@ -2,3 +2,15 @@ willmcgugan:
name: Will McGugan
description: CEO / code-monkey
avatar: https://github.com/willmcgugan.png
darrenburns:
name: Darren Burns
description: Code-monkey
avatar: https://github.com/darrenburns.png
davep:
name: Dave Pearson
description: Code-monkey
avatar: https://github.com/davep.png
rodrigo:
name: Rodrigo Girão Serrão
description: Code-monkey
avatar: https://github.com/rodrigogiraoserrao.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 526 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 572 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 590 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 169 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 179 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 165 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 110 KiB

View File

@@ -0,0 +1,339 @@
---
draft: false
date: 2022-11-26
categories:
- DevLog
authors:
- davep
---
# On dog food, the (original) Metaverse, and (not) being bored
## Introduction
!!! quote
Cutler, armed with a schedule, was urging the team to "eat its own dog
food". Part macho stunt and part common sense, the "dog food diet" was the
cornerstone of Cutlers philosophy.
<cite>G. Pascal Zachary &mdash; Show-Stopper!</cite>
I can't remember exactly when it was -- it was likely late in 1994 or some
time in 1995 -- when I first came across the concept of, or rather the name
for the concept of, *"eating your own dog food"*. The idea and the name
played a huge part in the book [*Show-Stopper!* by G. Pascal
Zachary](https://www.gpascalzachary.com/showstopper__the_breakneck_race_to_create_windows_nt_and_the_next_generation_at_m_50101.htm).
The idea wasn't new to me of course; I'd been writing code for over a decade
by then and plenty of times I'd built things and then used those things to
do things, but it was fascinating to a mostly-self-taught 20-something me to
be reading this (excellent -- go read it if you care about the history of
your craft) book and to see the idea written down and named.
<!-- more -->
While [Textualize](https://www.textualize.io/) isn't (thankfully -- really,
I do recommend reading the book) anything like working on the team building
Windows NT, the idea of taking a little time out from working *on* Textual,
and instead work *with* Textual, makes a lot of sense. It's far too easy to
get focused on adding things and improving things and tweaking things while
losing sight of the fact that people will want to build **with** your
product.
So you can imagine how pleased I was when
[Will](https://mastodon.social/@willmcgugan) announced that he wanted [all
of us](https://www.textualize.io/about-us) to spend a couple or so weeks
building something with Textual. I had, of course, already written [one
small application with the
library](https://github.com/Textualize/textual/blob/main/examples/five_by_five.py),
and had plans for another (in part [it's how I ended up working
here](https://blog.davep.org/2022/10/05/on-to-something-new-redux.html)),
but I'd yet to really dive in and try and build something more involved.
Giving it some thought: I wasn't entirely sure what I wanted to build
though. I do want to use Textual to build a brand new terminal-based Norton
Guide reader ([not my first](https://github.com/davep/eg), not by [a long
way](https://github.com/davep/eg-OS2)) but I felt that was possibly a bit
too niche, and actually could take a bit too long anyway. Maybe not, it
remains to be seen.
Eventually I decided on this approach: try and do a quick prototype of some
daft idea each day or each couple of days, do that for a week or so, and
then finally try and settle down on something less trivial. This approach
should work well in that it'll help introduce me to more of Textual, help
try out a few different parts of the library, and also hopefully discover
some real pain-points with working with it and highlight a list of issues we
should address -- as seen from the perspective of a developer working with
the library.
So, here I am, at the end of week one. What I want to try and do is briefly
(yes yes, I know, this introduction is the antithesis of brief) talk about
what I built and perhaps try and highlight some lessons learnt, highlight
some patterns I think are useful, and generally do an end-of-week version of
a [TIL](https://simonwillison.net/2022/Nov/6/what-to-blog-about/). TWIL?
Yeah. I guess this is a TWIL.
## gridinfo
I started the week by digging out a quick hack I'd done a couple of weeks
earlier, with a view to cleaning it up. It started out as a fun attempt to
do something with [Rich Pixels](https://github.com/darrenburns/rich-pixels)
while also making a terminal-based take on
[`slstats.el`](https://github.com/davep/slstats.el). I'm actually pleased
with the result and how quickly it came together.
The point of the application itself is to show some general information
about the current state of the Second Life grid (hello to any fellow
residents of [the original
Metaverse](https://wiki.secondlife.com/wiki/History_of_Second_Life)!), and
to also provide a simple region lookup screen that, using Rich Pixels, will
display the object map (albeit in pretty low resolution -- but that's the
fun of this!).
So the opening screen looks like this:
![The initial screen of gridinfo, showing the main SL stats](../images/2022-11-26-davep-devlog/gridinfo-1.png)
and a lookup of a region looks like this:
![Looking up the details of the first even region](../images/2022-11-26-davep-devlog/gridinfo-2.png)
Here's a wee video of the whole thing in action:
<div class="video-wrapper">
<iframe
width="560" height="315"
src="https://www.youtube.com/embed/dzpGgVPD2aM"
title="YouTube video player"
frameborder="0"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
allowfullscreen>
</iframe>
</div>
### Worth a highlight
Here's a couple of things from the code that I think are worth a highlight,
as things to consider when building Textual apps:
#### Don't use the default screen
Use of the default `Screen` that's provided by the `App` is handy enough,
but I feel any non-trivial application should really put as much code as
possible in screens that relate to key "work". Here's the entirety of my
application code:
```python
class GridInfo( App[ None ] ):
"""TUI app for showing information about the Second Life grid."""
CSS_PATH = "gridinfo.css"
"""The name of the CSS file for the app."""
TITLE = "Grid Information"
"""str: The title of the application."""
SCREENS = {
"main": Main,
"region": RegionInfo
}
"""The collection of application screens."""
def on_mount( self ) -> None:
"""Set up the application on startup."""
self.push_screen( "main" )
```
You'll notice there's no work done in the app, other than to declare the
screens, and to set the `main` screen running when the app is mounted.
#### Don't work hard `on_mount`
My initial version of the application had it loading up the data from the
Second Life and GridSurvey APIs in `Main.on_mount`. This obviously wasn't a
great idea as it made the startup appear slow. That's when I realised just
how handy
[`call_after_refresh`](https://textual.textualize.io/api/message_pump/#textual.message_pump.MessagePump.call_after_refresh)
is. This meant I could show some placeholder information and then fire off
the requests (3 of them: one to get the main grid information, one to get
the grid concurrency data, and one to get the grid size data), keeping the
application looking active and updating the display when the replies came
in.
### Pain points
While building this app I think there was only really the one pain-point,
and I suspect it's mostly more on me than on Textual itself: getting a good
layout and playing whack-a-mole with CSS. I suspect this is going to be down
to getting more and more familiar with CSS and the terminal (which is
different from laying things out for the web), while also practising with
various layout schemes -- which is where the [revamped `Placeholder`
class](https://textual.textualize.io/blog/2022/11/22/what-i-learned-from-my-first-non-trivial-pr/#what-i-learned-from-my-first-non-trivial-pr)
is going to be really useful.
## unbored
The next application was initially going to be a very quick hack, but
actually turned into a less-trivial build than I'd initially envisaged; not
in a negative way though. The more I played with it the more I explored and
I feel that this ended up being my first really good exploration of some
useful (personal -- your kilometerage may vary) patterns and approaches when
working with Textual.
The application itself is a terminal client for [the
Bored-API](https://www.boredapi.com/). I had initially intended to roll my
own code for working with the API, but I noticed that [someone had done a
nice library for it](https://pypi.org/project/bored-api/) and it seemed
silly to not build on that. Not needing to faff with that, I could
concentrate on the application itself.
At first I was just going to let the user click away at a button that showed
a random activity, but this quickly morphed into a *"why don't I make this
into a sort of TODO list builder app, where you can add things to do when
you are bored, and delete things you don't care for or have done"* approach.
Here's a view of the main screen:
![The main Unbored screen](../images/2022-11-26-davep-devlog/unbored-1.png)
and here's a view of the filter pop-over:
![Setting filters for activities](../images/2022-11-26-davep-devlog/unbored-2.png)
### Worth a highlight
#### Don't put all your `BINDINGS` in one place
This came about from me overloading the use of the `escape` key. I wanted it
to work more or less like this:
- If you're inside an activity, move focus up to the activity type selection
buttons.
- If the filter pop-over is visible, close that.
- Otherwise exit the application.
It was easy enough to do, and I had an action in the `Main` screen that
`escape` was bound to (again, in the `Main` screen) that did all this logic
with some `if`/`elif` work but it didn't feel elegant. Moreover, it meant
that the `Footer` always displayed the same description for the key.
That's when I realised that it made way more sense to have a `Binding` for
`escape` in every widget that was the actual context for escape's use. So I
went from one top-level binding to...
```python
...
class Activity( Widget ):
"""A widget that holds and displays a suggested activity."""
BINDINGS = [
...
Binding( "escape", "deselect", "Switch to Types" )
]
...
class Filters( Vertical ):
"""Filtering sidebar."""
BINDINGS = [
Binding( "escape", "close", "Close Filters" )
]
...
class Main( Screen ):
"""The main application screen."""
BINDINGS = [
Binding( "escape", "quit", "Close" )
]
"""The bindings for the main screen."""
```
This was so much cleaner **and** I got better `Footer` descriptions too. I'm
going to be leaning hard on this approach from now on.
#### Messages are awesome
Until I wrote this application I hadn't really had a need to define or use
my own `Message`s. During work on this I realised how handy they really are.
In the code I have an `Activity` widget which takes care of the job of
moving itself amongst its siblings if the user asks to move an activity up
or down. When this happens I also want the `Main` screen to save the
activities to the filesystem as things have changed.
Thing is: I don't want the screen to know what an `Activity` is capable of
and I don't want an `Activity` to know what the screen is capable of;
especially the latter as I really don't want a child of a screen to know
what the screen can do (in this case *"save stuff"*).
This is where messages come in. Using a message I could just set things up
so that the `Activity` could shout out **"HEY I JUST DID A THING THAT CHANGES
ME"** and not care who is listening and not care what they do with that
information.
So, thanks to this bit of code in my `Activity` widget...
```python
class Moved( Message ):
"""A message to indicate that an activity has moved."""
def action_move_up( self ) -> None:
"""Move this activity up one place in the list."""
if self.parent is not None and not self.is_first:
parent = cast( Widget, self.parent )
parent.move_child(
self, before=parent.children.index( self ) - 1
)
self.emit_no_wait( self.Moved( self ) )
self.scroll_visible( top=True )
```
...the `Main` screen can do this:
```python
def on_activity_moved( self, _: Activity.Moved ) -> None:
"""React to an activity being moved."""
self.save_activity_list()
```
### Pain points
On top of the issues of getting to know terminal-based-CSS that I mentioned
earlier:
- Textual currently lacks any sort of selection list or radio-set widget.
This meant that I couldn't quite do the activity type picking how I would
have wanted. Of course I could have rolled my own widgets for this, but I
think I'd sooner wait until such things [are in Textual
itself](https://textual.textualize.io/roadmap/#widgets).
- Similar to that, I could have used some validating `Input` widgets. They
too are on the roadmap but I managed to cobble together fairly good
working versions for my purposes. In doing so though I did further
highlight that the [reactive attribute
facility](https://textual.textualize.io/tutorial/#reactive-attributes)
needs a wee bit more attention as I ran into some
([already-known](https://github.com/Textualize/textual/issues/1216)) bugs.
Thankfully in my case [it was a very easy
workaround](https://github.com/davep/unbored/blob/d46f7959aeda0996f39d287388c6edd2077be935/unbored#L251-L255).
- Scrolling in general seems a wee bit off when it comes to widgets that are
more than one line tall. While there's nothing really obvious I can point
my finger at, I'm finding that scrolling containers sometimes get confused
about what should be in view. This becomes very obvious when forcing
things to scroll from code. I feel this deserves a dedicated test
application to explore this more.
## Conclusion
The first week of *"dogfooding"* has been fun and I'm more convinced than
ever that it's an excellent exercise for Textualize to engage in. I didn't
quite manage my plan of *"one silly trivial prototype per day"*, which means
I've ended up with two (well technically one and a half I guess given that
`gridinfo` already existed as a prototype) applications rather than four.
I'm okay with that. I got a **lot** of utility out of this.
Now to look at the list of ideas I have going and think about what I'll kick
next week off with...

View File

@@ -0,0 +1,233 @@
---
draft: false
date: 2022-11-22
categories:
- DevLog
authors:
- rodrigo
---
# What I learned from my first non-trivial PR
<div>
--8<-- "docs/blog/images/placeholder-example.svg"
</div>
It's 8:59 am and, by my Portuguese standards, it is freezing cold outside: 5 or 6 degrees Celsius.
It is my second day at Textualize and I just got into the office.
I undress my many layers of clothing to protect me from the Scottish cold and I sit down in my improvised corner of the Textualize office.
As I sit down, I turn myself in my chair to face my boss and colleagues to ask “So, what should I do today?”.
I was not expecting Will's answer, but the challenge excited me:
<!-- more -->
> “I thought I'll just throw you in the deep end and have you write some code.”
What happened next was that I spent two days [working on PR #1229](https://github.com/Textualize/textual/pull/1229) to add a new widget to the [Textual](https://github.com/Textualize/textual) code base.
At the time of writing, the pull request has not been merged yet.
Well, to be honest with you, it hasn't even been reviewed by anyone...
But that won't stop me from blogging about some of the things I learned while creating this PR.
## The placeholder widget
This PR adds a widget called `Placeholder` to Textual.
As per the documentation, this widget “is meant to have no complex functionality.
Use the placeholder widget when studying the layout of your app before having to develop your custom widgets.”
The point of the placeholder widget is that you can focus on building the layout of your app without having to have all of your (custom) widgets ready.
The placeholder widget also displays a couple of useful pieces of information to help you work out the layout of your app, namely the ID of the widget itself (or a custom label, if you provide one) and the width and height of the widget.
As an example of usage of the placeholder widget, you can refer to the screenshot at the top of this blog post, which I included below so you don't have to scroll up:
<div>
--8<-- "docs/blog/images/placeholder-example.svg"
</div>
The top left and top right widgets have custom labels.
Immediately under the top right placeholder, you can see some placeholders identified as `#p3`, `#p4`, and `#p5`.
Those are the IDs of the respective placeholders.
Then, rows 2 and 3 contain some placeholders that show their respective size and some placeholders that just contain some text.
## Bootstrapping the code for the widget
So, how does a code monkey start working on a non-trivial PR within 24 hours of joining a company?
The answer is simple: just copy and paste code!
But instead of copying and pasting from Stack Overflow, I decided to copy and paste from the internal code base.
My task was to create a new widget, so I thought it would be a good idea to take a look at the implementation of other Textual widgets.
For some reason I cannot seem to recall, I decided to take a look at the implementation of the button widget that you can find in [_button.py](https://github.com/Textualize/textual/blob/main/src/textual/widgets/_button.py).
By looking at how the button widget is implemented, I could immediately learn a few useful things about what I needed to do and some other things about how Textual works.
For example, a widget can have a class attribute called `DEFAULT_CSS` that specifies the default CSS for that widget.
I learned this just from staring at the code for the button widget.
Studying the code base will also reveal the standards that are in place.
For example, I learned that for a widget with variants (like the button with its “success” and “error” variants), the widget gets a CSS class with the name of the variant prefixed by a dash.
You can learn this by looking at the method `Button.watch_variant`:
```py
class Button(Static, can_focus=True):
# ...
def watch_variant(self, old_variant: str, variant: str):
self.remove_class(f"-{old_variant}")
self.add_class(f"-{variant}")
```
In short, looking at code and files that are related to the things you need to do is a great way to get information about things you didn't even know you needed.
## Handling the placeholder variant
A button widget can have a different variant, which is mostly used by Textual to determine the CSS that should apply to the given button.
For the placeholder widget, we want the variant to determine what information the placeholder shows.
The [original GitHub issue](https://github.com/Textualize/textual/issues/1200) mentions 5 variants for the placeholder:
- a variant that just shows a label or the placeholder ID;
- a variant that shows the size and location of the placeholder;
- a variant that shows the state of the placeholder (does it have focus? is the mouse over it?);
- a variant that shows the CSS that is applied to the placeholder itself; and
- a variant that shows some text inside the placeholder.
The variant can be assigned when the placeholder is first instantiated, for example, `Placeholder("css")` would create a placeholder that shows its own CSS.
However, we also want to have an `on_click` handler that cycles through all the possible variants.
I was getting ready to reinvent the wheel when I remembered that the standard module [`itertools`](https://docs.python.org/3/library/itertools) has a lovely tool that does exactly what I needed!
Thus, all I needed to do was create a new `cycle` through the variants each time a placeholder is created and then grab the next variant whenever the placeholder is clicked:
```py
class Placeholder(Static):
def __init__(
self,
variant: PlaceholderVariant = "default",
*,
label: str | None = None,
name: str | None = None,
id: str | None = None,
classes: str | None = None,
) -> None:
# ...
self.variant = self.validate_variant(variant)
# Set a cycle through the variants with the correct starting point.
self._variants_cycle = cycle(_VALID_PLACEHOLDER_VARIANTS_ORDERED)
while next(self._variants_cycle) != self.variant:
pass
def on_click(self) -> None:
"""Click handler to cycle through the placeholder variants."""
self.cycle_variant()
def cycle_variant(self) -> None:
"""Get the next variant in the cycle."""
self.variant = next(self._variants_cycle)
```
I am just happy that I had the insight to add this little `while` loop when a placeholder is instantiated:
```py
from itertools import cycle
# ...
class Placeholder(Static):
# ...
def __init__(...):
# ...
self._variants_cycle = cycle(_VALID_PLACEHOLDER_VARIANTS_ORDERED)
while next(self._variants_cycle) != self.variant:
pass
```
Can you see what would be wrong if this loop wasn't there?
## Updating the render of the placeholder on variant change
If the variant of the placeholder is supposed to determine what information the placeholder shows, then that information must be updated every time the variant of the placeholder changes.
Thankfully, Textual has reactive attributes and watcher methods, so all I needed to do was...
Defer the problem to another method:
```py
class Placeholder(Static):
# ...
variant = reactive("default")
# ...
def watch_variant(
self, old_variant: PlaceholderVariant, variant: PlaceholderVariant
) -> None:
self.validate_variant(variant)
self.remove_class(f"-{old_variant}")
self.add_class(f"-{variant}")
self.call_variant_update() # <-- let this method do the heavy lifting!
```
Doing this properly required some thinking.
Not that the current proposed solution is the best possible, but I did think of worse alternatives while I was thinking how to tackle this.
I wasn't entirely sure how I would manage the variant-dependant rendering because I am not a fan of huge conditional statements that look like switch statements:
```py
if variant == "default":
# render the default placeholder
elif variant == "size":
# render the placeholder with its size
elif variant == "state":
# render the state of the placeholder
elif variant == "css":
# render the placeholder with its CSS rules
elif variant == "text":
# render the placeholder with some text inside
```
However, I am a fan of using the built-in `getattr` and I thought of creating a rendering method for each different variant.
Then, all I needed to do was make sure the variant is part of the name of the method so that I can programmatically determine the name of the method that I need to call.
This means that the method `Placeholder.call_variant_update` is just this:
```py
class Placeholder(Static):
# ...
def call_variant_update(self) -> None:
"""Calls the appropriate method to update the render of the placeholder."""
update_variant_method = getattr(self, f"_update_{self.variant}_variant")
update_variant_method()
```
If `self.variant` is, say, `"size"`, then `update_variant_method` refers to `_update_size_variant`:
```py
class Placeholder(Static):
# ...
def _update_size_variant(self) -> None:
"""Update the placeholder with the size of the placeholder."""
width, height = self.size
self._placeholder_label.update(f"[b]{width} x {height}[/b]")
```
This variant `"size"` also interacts with resizing events, so we have to watch out for those:
```py
class Placeholder(Static):
# ...
def on_resize(self, event: events.Resize) -> None:
"""Update the placeholder "size" variant with the new placeholder size."""
if self.variant == "size":
self._update_size_variant()
```
## Deleting code is a (hurtful) blessing
To conclude this blog post, let me muse about the fact that the original issue mentioned five placeholder variants and that my PR only includes two and a half.
After careful consideration and after coming up with the `getattr` mechanism to update the display of the placeholder according to the active variant, I started showing the “final” product to Will and my other colleagues.
Eventually, we ended up getting rid of the variant for CSS and the variant that shows the placeholder state.
This means that I had to **delete part of my code** even before it saw the light of day.
On the one hand, deleting those chunks of code made me a bit sad.
After all, I had spent quite some time thinking about how to best implement that functionality!
But then, it was time to write documentation and tests, and I verified that the **best code** is the code that you don't even write!
The code you don't write is guaranteed to have zero bugs and it also does not need any documentation whatsoever!
So, it was a shame that some lines of code I poured my heart and keyboard into did not get merged into the Textual code base.
On the other hand, I am quite grateful that I won't have to fix the bugs that will certainly reveal themselves in a couple of weeks or months from now.
Heck, the code hasn't been merged yet and just by writing this blog post I noticed a couple of tweaks that were missing!

View File

@@ -0,0 +1,551 @@
---
draft: false
date: 2022-11-24
categories:
- DevLog
authors:
- rodrigo
---
# Spinners and progress bars in Textual
![](../images/spinners-and-pbs-in-textual/live-display.gif)
One of the things I love about mathematics is that you can solve a problem just by **guessing** the correct answer.
That is a perfectly valid strategy for solving a problem.
The only thing you need to do after guessing the answer is to prove that your guess is correct.
I used this strategy, to some success, to display spinners and indeterminate progress bars from [Rich](github.com/textualize/rich) in [Textual](https://github.com/textualize/textual).
<!-- more -->
## Display an indeterminate progress bar in Textual
I have been playing around with Textual and recently I decided I needed an indeterminate progress bar to show that some data was loading.
Textual is likely to [get progress bars in the future](https://github.com/Textualize/rich/issues/2665#issuecomment-1326229220), but I don't want to wait for the future!
I want my progress bars now!
Textual builds on top of Rich, so if [Rich has progress bars](https://rich.readthedocs.io/en/stable/progress.html), I reckoned I could use them in my Textual apps.
### Progress bars in Rich
Creating a progress bar in Rich is as easy as opening up the documentation for `Progress` and copying & pasting the code.
=== "Code"
```py
import time
from rich.progress import track
for _ in track(range(20), description="Processing..."):
time.sleep(0.5) # Simulate work being done
```
=== "Output"
![](../images/spinners-and-pbs-in-textual/rich-progress-bar.gif)
The function `track` provides a very convenient interface for creating progress bars that keep track of a well-specified number of steps.
In the example above, we were keeping track of some task that was going to take 20 steps to complete.
(For example, if we had to process a list with 20 elements.)
However, I am looking for indeterminate progress bars.
Scrolling further down the documentation for `rich.progress` I found what I was looking for:
=== "Code"
```py hl_lines="5"
import time
from rich.progress import Progress
with Progress() as progress:
_ = progress.add_task("Loading...", total=None) # (1)!
while True:
time.sleep(0.01)
```
1. Setting `total=None` is what makes it an indeterminate progress bar.
=== "Output"
![](../images/spinners-and-pbs-in-textual/indeterminate-rich-progress-bar.gif)
So, putting an indeterminate progress bar on the screen is _easy_.
Now, I only needed to glue that together with the little I know about Textual to put an indeterminate progress bar in a Textual app.
### Guessing what is what and what goes where
What I want is to have an indeterminate progress bar inside my Textual app.
Something that looks like this:
![](../images/spinners-and-pbs-in-textual/bar-in-textual.gif)
The GIF above shows just the progress bar.
Obviously, the end goal is to have the progress bar be part of a Textual app that does something.
So, when I set out to do this, my first thought went to the stopwatch app in the [Textual tutorial](https://textual.textualize.io/tutorial) because it has a widget that updates automatically, the `TimeDisplay`.
Below you can find the essential part of the code for the `TimeDisplay` widget and a small animation of it updating when the stopwatch is started.
=== "`TimeDisplay` widget"
```py hl_lines="14 18 22"
from time import monotonic
from textual.reactive import reactive
from textual.widgets import Static
class TimeDisplay(Static):
"""A widget to display elapsed time."""
start_time = reactive(monotonic)
time = reactive(0.0)
total = reactive(0.0)
def on_mount(self) -> None:
"""Event handler called when widget is added to the app."""
self.update_timer = self.set_interval(1 / 60, self.update_time, pause=True)
def update_time(self) -> None:
"""Method to update time to current."""
self.time = self.total + (monotonic() - self.start_time)
def watch_time(self, time: float) -> None:
"""Called when the time attribute changes."""
minutes, seconds = divmod(time, 60)
hours, minutes = divmod(minutes, 60)
self.update(f"{hours:02,.0f}:{minutes:02.0f}:{seconds:05.2f}")
```
=== "Output"
![](../images/spinners-and-pbs-in-textual/stopwatch-timedisplay.gif)
The reason the time display updates magically is due to the three methods that I highlighted in the code above:
1. The method `on_mount` is called when the `TimeDisplay` widget is mounted on the app and, in it, we use the method `set_interval` to let Textual know that every `1 / 60` seconds we would like to call the method `update_time`. (In other words, we would like `update_time` to be called 60 times per second.)
2. In turn, the method `update_time` (which is called _automatically_ a bunch of times per second) will update the reactive attribute `time`. _When_ this attribute update happens, the method `watch_time` kicks in.
3. The method `watch_time` is a watcher method and gets called whenever the attribute `self.time` is assigned to.
So, if the method `update_time` is called a bunch of times per second, the watcher method `watch_time` is also called a bunch of times per second. In it, we create a nice representation of the time that has elapsed and we use the method `update` to update the time that is being displayed.
I thought it would be reasonable if a similar mechanism needed to be in place for my progress bar, but then I realised that the progress bar seems to update itself...
Looking at the indeterminate progress bar example from before, the only thing going on was that we used `time.sleep` to stop our program for a bit.
We didn't do _anything_ to update the progress bar...
Look:
```py
with Progress() as progress:
_ = progress.add_task("Loading...", total=None) # (1)!
while True:
time.sleep(0.01)
```
After pondering about this for a bit, I realised I would not need a watcher method for anything.
The watcher method would only make sense if I needed to update an attribute related to some sort of artificial progress, but that clearly isn't needed to get the bar going...
At some point, I realised that the object `progress` is the object of interest.
At first, I thought `progress.add_task` would return the progress bar, but it actually returns the integer ID of the task added, so the object of interest is `progress`.
Because I am doing nothing to update the bar explicitly, the object `progress` must be updating itself.
The Textual documentation also says that we can [build widgets from Rich renderables](https://textual.textualize.io/guide/widgets/#rich-renderables), so I concluded that if `Progress` were a renderable, then I could inherit from `Static` and use the method `update` to update the widget with my instance of `Progress` directly.
I gave it a try and I put together this code:
```py hl_lines="10 11 15-17 20"
from rich.progress import Progress, BarColumn
from textual.app import App, ComposeResult
from textual.widgets import Static
class IndeterminateProgress(Static):
def __init__(self):
super().__init__("")
self._bar = Progress(BarColumn()) # (1)!
self._bar.add_task("", total=None) # (2)!
def on_mount(self) -> None:
# When the widget is mounted start updating the display regularly.
self.update_render = self.set_interval(
1 / 60, self.update_progress_bar
) # (3)!
def update_progress_bar(self) -> None:
self.update(self._bar) # (4)!
class MyApp(App):
def compose(self) -> ComposeResult:
yield IndeterminateProgress()
if __name__ == "__main__":
app = MyApp()
app.run()
```
1. Create an instance of `Progress` that just cares about the bar itself (Rich progress bars can have a label, an indicator for the time left, etc).
2. We add the indeterminate task with `total=None` for the indeterminate progress bar.
3. When the widget is mounted on the app, we want to start calling `update_progress_bar` 60 times per second.
4. To update the widget of the progress bar we just call the method `Static.update` with the `Progress` object because `self._bar` is a Rich renderable.
And lo and behold, it worked:
![](../images/spinners-and-pbs-in-textual/bar-in-textual.gif)
### Proving it works
I finished writing this piece of code and I was ecstatic because it was working!
After all, my Textual app starts and renders the progress bar.
And so, I shared this simple app with someone who wanted to do a similar thing, but I was left with a bad taste in my mouth because I couldn't really connect all the dots and explain exactly why it worked.
!!! warning "Plot twist"
By the end of the blog post, I will be much closer to a full explanation!
## Display a Rich spinner in a Textual app
A day after creating my basic `IndeterminateProgress` widget, I found someone that was trying to display a Rich spinner in a Textual app.
Actually, it was someone that had [filed an issue against Rich](https://github.com/Textualize/rich/issues/2665).
They didn't ask “how can I display a Rich spinner in a Textual app?”, but they filed an alleged bug that crept up on them _when_ they tried displaying a spinner in a Textual app.
When reading the issue I realised that displaying a Rich spinner looked very similar to displaying a Rich progress bar, so I made a tiny change to my code and tried to run it:
=== "Code"
```py hl_lines="10"
from rich.spinner import Spinner
from textual.app import App, ComposeResult
from textual.widgets import Static
class SpinnerWidget(Static):
def __init__(self):
super().__init__("")
self._spinner = Spinner("moon") # (1)!
def on_mount(self) -> None:
self.update_render = self.set_interval(1 / 60, self.update_spinner)
def update_spinner(self) -> None:
self.update(self._spinner)
class MyApp(App[None]):
def compose(self) -> ComposeResult:
yield SpinnerWidget()
MyApp().run()
```
1. Instead of creating an instance of `Progress`, we create an instance of `Spinner` and save it so we can call `self.update(self._spinner)` later on.
=== "Spinner running"
![](../images/spinners-and-pbs-in-textual/spinner.gif)
## Losing the battle against pausing the animations
After creating the progress bar and spinner widgets I thought of creating the little display that was shown at the beginning of the blog post:
![](../images/spinners-and-pbs-in-textual/live-display.gif)
When writing the code for this app, I realised both widgets had a lot of shared code and logic and I tried abstracting away their common functionality.
That led to the code shown below (more or less) where I implemented the updating functionality in `IntervalUpdater` and then let the `IndeterminateProgressBar` and `SpinnerWidget` instantiate the correct Rich renderable.
```py hl_lines="8-15 22 30"
from rich.progress import Progress, BarColumn
from rich.spinner import Spinner
from textual.app import RenderableType
from textual.widgets import Button, Static
class IntervalUpdater(Static):
_renderable_object: RenderableType # (1)!
def update_rendering(self) -> None: # (2)!
self.update(self._renderable_object)
def on_mount(self) -> None: # (3)!
self.interval_update = self.set_interval(1 / 60, self.update_rendering)
class IndeterminateProgressBar(IntervalUpdater):
"""Basic indeterminate progress bar widget based on rich.progress.Progress."""
def __init__(self) -> None:
super().__init__("")
self._renderable_object = Progress(BarColumn()) # (4)!
self._renderable_object.add_task("", total=None)
class SpinnerWidget(IntervalUpdater):
"""Basic spinner widget based on rich.spinner.Spinner."""
def __init__(self, style: str) -> None:
super().__init__("")
self._renderable_object = Spinner(style) # (5)!
```
1. Instances of `IntervalUpdate` should set the attribute `_renderable_object` to the instance of the Rich renderable that we want to animate.
2. The methods `update_rendering` and `on_mount` are exactly the same as what we had before, both in the progress bar widget and in the spinner widget.
3. The methods `update_rendering` and `on_mount` are exactly the same as what we had before, both in the progress bar widget and in the spinner widget.
4. For an indeterminate progress bar we set the attribute `_renderable_object` to an instance of `Progress`.
5. For a spinner we set the attribute `_renderable_object` to an instance of `Spinner`.
But I wanted something more!
I wanted to make my app similar to the stopwatch app from the terminal and thus wanted to add a “Pause” and a “Resume” button.
These buttons should, respectively, stop the progress bar and the spinner animations and resume them.
Below you can see the code I wrote and a short animation of the app working.
=== "App code"
```py hl_lines="18-19 21-22 60-70 55-56"
from rich.progress import Progress, BarColumn
from rich.spinner import Spinner
from textual.app import App, ComposeResult, RenderableType
from textual.containers import Grid, Horizontal, Vertical
from textual.widgets import Button, Static
class IntervalUpdater(Static):
_renderable_object: RenderableType
def update_rendering(self) -> None:
self.update(self._renderable_object)
def on_mount(self) -> None:
self.interval_update = self.set_interval(1 / 60, self.update_rendering)
def pause(self) -> None: # (1)!
self.interval_update.pause()
def resume(self) -> None: # (2)!
self.interval_update.resume()
class IndeterminateProgressBar(IntervalUpdater):
"""Basic indeterminate progress bar widget based on rich.progress.Progress."""
def __init__(self) -> None:
super().__init__("")
self._renderable_object = Progress(BarColumn())
self._renderable_object.add_task("", total=None)
class SpinnerWidget(IntervalUpdater):
"""Basic spinner widget based on rich.spinner.Spinner."""
def __init__(self, style: str) -> None:
super().__init__("")
self._renderable_object = Spinner(style)
class LiveDisplayApp(App[None]):
"""App showcasing some widgets that update regularly."""
CSS_PATH = "myapp.css"
def compose(self) -> ComposeResult:
yield Vertical(
Grid(
SpinnerWidget("moon"),
IndeterminateProgressBar(),
SpinnerWidget("aesthetic"),
SpinnerWidget("bouncingBar"),
SpinnerWidget("earth"),
SpinnerWidget("dots8Bit"),
),
Horizontal(
Button("Pause", id="pause"), # (3)!
Button("Resume", id="resume", disabled=True),
),
)
def on_button_pressed(self, event: Button.Pressed) -> None: # (4)!
pressed_id = event.button.id
assert pressed_id is not None
for widget in self.query(IntervalUpdater):
getattr(widget, pressed_id)() # (5)!
for button in self.query(Button): # (6)!
if button.id == pressed_id:
button.disabled = True
else:
button.disabled = False
LiveDisplayApp().run()
```
1. The method `pause` looks at the attribute `interval_update` (returned by the method `set_interval`) and tells it to stop calling the method `update_rendering` 60 times per second.
2. The method `resume` looks at the attribute `interval_update` (returned by the method `set_interval`) and tells it to resume calling the method `update_rendering` 60 times per second.
3. We set two distinct IDs for the two buttons so we can easily tell which button was pressed and _what_ the press of that button means.
4. The event handler `on_button_pressed` will wait for button presses and will take care of pausing or resuming the animations.
5. We look for all of the instances of `IntervalUpdater` in our app and use a little bit of introspection to call the correct method (`pause` or `resume`) in our widgets. Notice this was only possible because the buttons were assigned IDs that matched the names of the methods. (I love Python :snake:!)
6. We go through our two buttons to disable the one that was just pressed and to enable the other one.
=== "CSS"
```css
Screen {
align: center middle;
}
Horizontal {
height: 1fr;
align-horizontal: center;
}
Button {
margin: 0 3 0 3;
}
Grid {
height: 4fr;
align: center middle;
grid-size: 3 2;
grid-columns: 8;
grid-rows: 1;
grid-gutter: 1;
border: gray double;
}
IntervalUpdater {
content-align: center middle;
}
```
=== "Output"
![](../images/spinners-and-pbs-in-textual/pause-resume-appears-to-work.gif)
If you think this was a lot, take a couple of deep breaths before moving on.
The only issue with my app is that... it does not work!
If you press the button to pause the animations, it looks like the widgets are paused.
However, you can see that if I move my mouse over the paused widgets, they update:
![](../images/spinners-and-pbs-in-textual/fake-pause.gif)
Obviously, that caught me by surprise, in the sense that I expected it work.
On the other hand, this isn't surprising.
After all, I thought I had guessed how I could solve the problem of displaying these Rich renderables that update live and I thought I knew how to pause and resume their animations, but I hadn't convinced myself I knew exactly why it worked.
!!! warning
This goes to show that sometimes it is not the best idea to commit code that you wrote and that works if you don't know _why_ it works.
The code might _seem_ to work and yet have deficiencies that will hurt you further down the road.
As it turns out, the reason why pausing is not working is that I did not grok why the rendering worked in the first place...
So I had to go down that rabbit hole first.
## Understanding the Rich rendering magic
### How `Static.update` works
The most basic way of creating a Textual widget is to inherit from `Widget` and implement the method `render` that just returns the _thing_ that must be printed on the screen.
Then, the widget `Static` provides some functionality on top of that: the method `update`.
The method `Static.update(renderable)` is used to tell the widget in question that its method `render` (called when the widget needs to be drawn) should just return `renderable`.
So, if the implementation of the method `IntervalUpdater.update_rendering` (the method that gets called 60 times per second) is this:
```py
class IntervalUpdater(Static):
# ...
def update_rendering(self) -> None:
self.update(self._renderable_object)
```
Then, we are essentially saying “hey, the thing in `self._renderable_object` is what must be returned whenever Textual asks you to render yourself.
So, this really proves that both `Progress` and `Spinner` from Rich are renderables.
But what is more, this shows that my implementation of `IntervalUpdater` can be simplified greatly!
In fact, we can boil it down to just this:
```py hl_lines="4-6 9"
class IntervalUpdater(Static):
_renderable_object: RenderableType
def __init__(self, renderable_object: RenderableType) -> None: # (1)!
super().__init__(renderable_object) # (2)!
def on_mount(self) -> None:
self.interval_update = self.set_interval(1 / 60, self.refresh) # (3)!
```
1. To create an instance of `IntervalUpdater`, now we give it the Rich renderable that we want displayed.
If this Rich renderable is something that updates over time, then those changes will be reflected in the rendering.
2. We initialise `Static` with the renderable object itself, instead of initialising with the empty string `""` and then updating repeatedly.
3. We call `self.refresh` 60 times per second.
We don't need the auxiliary method `update_rendering` because this widget (an instance of `Static`) already knows what its renderable is.
Once you understand the code above you will realise that the previous implementation of `update_rendering` was actually doing superfluous work because the repeated calls to `self.update` always had the exact same object.
Again, we see strong evidence that the Rich progress bars and the spinners have the inherent ability to display a different representation of themselves as time goes by.
### How Rich spinners get updated
I kept seeing strong evidence that Rich spinners and Rich progress bars updated their own rendering but I still did not have actual proof.
So, I went digging around to see how `Spinner` was implemented and I found this code ([from the file `spinner.py`](https://github.com/Textualize/rich/blob/5f4e93efb159af99ed51f1fbfd8b793bb36448d9/rich/spinner.py) at the time of writing):
```py hl_lines="7 10 13-15"
class Spinner:
# ...
def __rich_console__(
self, console: "Console", options: "ConsoleOptions"
) -> "RenderResult":
yield self.render(console.get_time()) # (1)!
# ...
def render(self, time: float) -> "RenderableType": # (2)!
# ...
frame_no = ((time - self.start_time) * self.speed) / ( # (3)!
self.interval / 1000.0
) + self.frame_no_offset
# ...
# ...
```
1. The Rich spinner implements the function `__rich_console__` that is supposed to return the result of rendering the spinner.
Instead, it defers its work to the method `render`...
However, to call the method `render`, we need to pass the argument `console.get_time()`, which the spinner uses to know in which state it is!
2. The method `render` takes a `time` and returns a renderable!
3. To determine the frame number (the current look of the spinner) we do some calculations with the “current time”, given by the parameter `time`, and the time when the spinner started!
The snippet of code shown above, from the implementation of `Spinner`, explains why moving the mouse over a spinner (or a progress bar) that supposedly was paused makes it move.
We no longer get repeated updates (60 times per second) because we told our app that we wanted to pause the result of `set_interval`, so we no longer get automatic updates.
However, moving the mouse over the spinners and the progress bar makes Textual want to re-render them and, when it does, it figures out that time was not frozen (obviously!) and so the spinners and the progress bar have a different frame to show.
To get a better feeling for this, do the following experiment:
1. Run the command `textual console` in a terminal to open the Textual devtools console.
2. Add a print statement like `print("Rendering from within spinner")` to the beginning of the method `Spinner.render` (from Rich).
3. Add a print statement like `print("Rendering static")` to the beginning of the method `Static.render` (from Textual).
4. Put a blank terminal and the devtools console side by side.
5. Run the app: notice that you get a lot of both print statements.
6. Hit the Pause button: the print statements stop.
7. Move your mouse over a widget or two: you get a couple of print statements, one from the `Static.render` and another from the `Spinner.render`.
The result of steps 6 and 7 are shown below.
Notice that, in the beginning of the animation, the screen on the right shows some prints but is quiet because no more prints are coming in.
When the mouse enters the screen and starts going over widgets, the screen on the right gets new prints in pairs, first from `Static.render` (which Textual calls to render the widget) and then from `Spinner.render` because ultimately we need to know how the Spinner looks.
![](../images/spinners-and-pbs-in-textual/final-experiment.gif)
Now, at this point, I made another educated guess and deduced that progress bars work in the same way!
I still have to prove it, and I guess I will do so in another blog post, coming soon, where our spinner and progress bar widgets can be properly paused!
I will see you soon :wave:

View File

@@ -0,0 +1,133 @@
---
draft: false
date: 2022-11-20
categories:
- DevLog
authors:
- willmcgugan
---
# Stealing Open Source code from Textual
I would like to talk about a serious issue in the Free and Open Source software world. Stealing code. You wouldn't steal a car would you?
<div class="video-wrapper">
<iframe width="auto" src="https://www.youtube.com/embed/HmZm8vNHBSU" title="YouTube video player" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture" allowfullscreen></iframe>
</div>
But you *should* steal code from Open Source projects. Respect the license (you may need to give attribution) but stealing code is not like stealing a car. If I steal your car, I have deprived you of a car. If you steal my open source code, I haven't lost anything.
!!! warning
I'm not advocating for *piracy*. Open source code gives you explicit permission to use it.
From my point of view, I feel like code has greater value when it has been copied / modified in another project.
There are a number of files and modules in [Textual](https://github.com/Textualize/textual) that could either be lifted as is, or wouldn't require much work to extract. I'd like to cover a few here. You might find them useful in your next project.
<!-- more -->
## Loop first / last
How often do you find yourself looping over an iterable and needing to know if an element is the first and/or last in the sequence? It's a simple thing, but I find myself nedding this a *lot*, so I wrote some helpers in [_loop.py](https://github.com/Textualize/textual/blob/main/src/textual/_loop.py).
I'm sure there is an equivalent implementation on PyPI, but steal this if you need it.
Here's an example of use:
```python
for last, (y, line) in loop_last(enumerate(self.lines, self.region.y)):
yield move_to(x, y)
yield from line
if not last:
yield new_line
```
## LRU Cache
Python's [lru_cache](https://docs.python.org/3/library/functools.html#functools.lru_cache) can be the one-liner that makes your code orders of magnitude faster. But it has a few gotchas.
The main issue is managing the lifetime of these caches. The decorator keeps a single global cache, which will keep a reference to every object in the function call. On an instance method that means you keep references to `self` for the lifetime of your app.
For a more flexibility you can use the [LRUCache](https://github.com/Textualize/textual/blob/main/src/textual/_cache.py) implementation from Textual. This uses essentially the same algorithm as the stdlib decorator, but it is implemented as a container.
Here's a quick example of its use. It works like a dictionary until you reach a maximum size. After that, new elements will kick out the element that was used least recently.
```python
>>> from textual._cache import LRUCache
>>> cache = LRUCache(maxsize=3)
>>> cache["foo"] = 1
>>> cache["bar"] = 2
>>> cache["baz"] = 3
>>> dict(cache)
{'foo': 1, 'bar': 2, 'baz': 3}
>>> cache["egg"] = 4
>>> dict(cache)
{'bar': 2, 'baz': 3, 'egg': 4}
```
In Textual, we use a [LRUCache](https://github.com/Textualize/textual/search?q=LRUCache) to store the results of rendering content to the terminal. For example, in a [datatable](https://twitter.com/search?q=%23textualdatatable&src=typed_query&f=live) it is too costly to render everything up front. So Textual renders only the lines that are currently visible on the "screen". The cache ensures that scrolling only needs to render the newly exposed lines, and lines that haven't been displayed in a while are discarded to save memory.
## Color
Textual has a [Color](https://github.com/Textualize/textual/blob/main/src/textual/color.py) class which could be extracted in to a module of its own.
The Color class can parse colors encoded in a variety of HTML and CSS formats. Color object support a variety of methods and operators you can use to manipulate colors, in a fairly natural way.
Here's some examples in the REPL.
```python
>>> from textual.color import Color
>>> color = Color.parse("lime")
>>> color
Color(0, 255, 0, a=1.0)
>>> color.darken(0.8)
Color(0, 45, 0, a=1.0)
>>> color + Color.parse("red").with_alpha(0.1)
Color(25, 229, 0, a=1.0)
>>> color = Color.parse("#12a30a")
>>> color
Color(18, 163, 10, a=1.0)
>>> color.css
'rgb(18,163,10)'
>>> color.hex
'#12A30A'
>>> color.monochrome
Color(121, 121, 121, a=1.0)
>>> color.monochrome.hex
'#797979'
>>> color.hsl
HSL(h=0.3246187363834423, s=0.8843930635838151, l=0.33921568627450976)
>>>
```
There are some very good color libraries in PyPI, which you should also consider using. But Textual's Color class is lean and performant, with no C dependencies.
## Geometry
This may be my favorite module in Textual: [geometry.py](https://github.com/Textualize/textual/blob/main/src/textual/geometry.py).
The geometry module contains a number of classes responsible for storing and manipulating 2D geometry. There is an `Offset` class which is a two dimensional point. A `Region` class which is a rectangular region defined by a coordinate and dimensions. There is a `Spacing` class which defines additional space around a region. And there is a `Size` class which defines the dimensions of an area by its width and height.
These objects are used by Textual's layout engine and compositor, which makes them the oldest and most thoroughly tested part of the project.
There's a lot going on in this module, but the docstrings are quite detailed and have unicode art like this to help explain things.
```
cut_x ↓
┌────────┐ ┌───┐
│ │ │ │
│ 0 │ │ 1 │
│ │ │ │
cut_y → └────────┘ └───┘
┌────────┐ ┌───┐
│ 2 │ │ 3 │
└────────┘ └───┘
```
## You should steal our code
There is a lot going on in the [Textual Repository](https://github.com/Textualize/textual). Including a CSS parser, renderer, layout and compositing engine. All written in pure Python. Steal it with my blessing.

View File

@@ -48,7 +48,7 @@ class Hello(Static):
class CustomApp(App):
CSS_PATH = "hello03.css"
CSS_PATH = "hello04.css"
def compose(self) -> ComposeResult:
yield Hello()

View File

@@ -32,13 +32,13 @@ class TimeDisplay(Static):
self.start_time = monotonic()
self.update_timer.resume()
def stop(self):
def stop(self) -> None:
"""Method to stop the time display updating."""
self.update_timer.pause()
self.total += monotonic() - self.start_time
self.time = self.total
def reset(self):
def reset(self) -> None:
"""Method to reset the time display to zero."""
self.total = 0
self.time = 0

View File

@@ -0,0 +1,12 @@
from textual.app import App, ComposeResult
from textual.widgets import DirectoryTree
class DirectoryTreeApp(App):
def compose(self) -> ComposeResult:
yield DirectoryTree("./")
if __name__ == "__main__":
app = DirectoryTreeApp()
app.run()

View File

@@ -0,0 +1,18 @@
from textual.app import App, ComposeResult
from textual.widgets import Tree
class TreeApp(App):
def compose(self) -> ComposeResult:
tree: Tree = Tree("Dune")
tree.root.expand()
characters = tree.root.add("Characters", expand=True)
characters.add_leaf("Paul")
characters.add_leaf("Jessica")
characters.add_leaf("Channi")
yield tree
if __name__ == "__main__":
app = TreeApp()
app.run()

View File

@@ -68,7 +68,7 @@ Let's look at a trivial Textual app.
=== "Output"
```{.textual path="docs/examples/guide/dom1.py"}
```{.textual path="docs/examples/guide/dom1.py" press="_"}
```
This example creates an instance of `ExampleApp`, which will implicitly create a `Screen` object. In DOM terms, the `Screen` is a _child_ of `ExampleApp`.

View File

@@ -27,7 +27,7 @@ Apps don't get much simpler than this&mdash;don't expect it to do much.
If we run this app with `python simple02.py` you will see a blank terminal, something like the following:
```{.textual path="docs/examples/app/simple02.py"}
```{.textual path="docs/examples/app/simple02.py" press="_"}
```
When you call [App.run()][textual.app.App.run] Textual puts the terminal in to a special state called *application mode*. When in application mode the terminal will no longer echo what you type. Textual will take over responding to user input (keyboard and mouse) and will update the visible portion of the terminal (i.e. the *screen*).
@@ -56,7 +56,7 @@ Another such event is the *key* event which is sent when the user presses a key.
The `on_mount` handler sets the `self.screen.styles.background` attribute to `"darkblue"` which (as you can probably guess) turns the background blue. Since the mount event is sent immediately after entering application mode, you will see a blue screen when you run this code.
```{.textual path="docs/examples/app/event01.py" hl_lines="23-25"}
```{.textual path="docs/examples/app/event01.py" hl_lines="23-25" press="_"}
```
The key event handler (`on_key`) has an `event` parameter which will receive a [Key][textual.events.Key] instance. Every event has an associated event object which will be passed to the handler method if it is present in the method's parameter list.

View File

@@ -217,7 +217,7 @@ The following example uses a computed attribute. It displays three inputs for ea
```
1. Combines color components in to a Color object.
2. The compute method is called when the _result_ of `compute_color` changes.
2. The watch method is called when the _result_ of `compute_color` changes.
=== "computed01.css"

View File

@@ -20,7 +20,7 @@ The first line sets the [background](../styles/background.md) style to `"darkblu
The second line sets [border](../styles/border.md) to a tuple of `("heavy", "white")` which tells Textual to draw a white border with a style of `"heavy"`. Running this code will show the following:
```{.textual path="docs/examples/guide/styles/screen.py"}
```{.textual path="docs/examples/guide/styles/screen.py" press="_"}
```
## Styling widgets
@@ -48,9 +48,9 @@ Note how the combined height of the widget is three rows in the terminal. This i
## Colors
There are a number of style attributes which accept colors. The most commonly used are [color](../styles/color.md) which sets the default color of text on a widget, and [background](..styles/background/md) which sets the background color (beneath the text).
There are a number of style attributes which accept colors. The most commonly used are [color](../styles/color.md) which sets the default color of text on a widget, and [background](../styles/background.md) which sets the background color (beneath the text).
You can set a color value to one of a number of pre-defined color constants, such as `"crimson"`, `"lime"`, and `"palegreen"`. You can find a full list in the [Color reference](../reference/color.md#textual.color--named-colors).
You can set a color value to one of a number of pre-defined color constants, such as `"crimson"`, `"lime"`, and `"palegreen"`. You can find a full list in the [Color API](../api/color.md#textual.color--named-colors).
Here's how you would set the screen background to lime:

View File

@@ -39,7 +39,7 @@ height: 10;
height: 50%;
/* Automatic height */
width: auto
height: auto
```
## Python

View File

@@ -24,3 +24,21 @@ body[data-md-color-primary="black"] .excalidraw svg rect {
.excalidraw {
text-align: center;
}
.video-wrapper {
position: relative;
display: block;
height: 0;
padding: 0;
overflow: hidden;
padding-bottom: 56.25%;
}
.video-wrapper > iframe {
position: absolute;
top: 0;
bottom: 0;
left: 0;
width: 100%;
height: 100%;
border: 0;
}

View File

@@ -0,0 +1,43 @@
# DirectoryTree
A tree control to navigate the contents of your filesystem.
- [x] Focusable
- [ ] Container
## Example
The example below creates a simple tree to navigate the current working directory.
```python
--8<-- "docs/examples/widgets/directory_tree.py"
```
## Messages
### FileSelected
The `DirectoryTree.FileSelected` message is sent when the user selects a file in the tree
- [x] Bubbles
#### Attributes
| attribute | type | purpose |
| --------- | ----- | ----------------- |
| `path` | `str` | Path of the file. |
## Reactive Attributes
| Name | Type | Default | Description |
| ------------- | ------ | ------- | ----------------------------------------------- |
| `show_root` | `bool` | `True` | Show the root node. |
| `show_guides` | `bool` | `True` | Show guide lines between levels. |
| `guide_depth` | `int` | `4` | Amount of indentation between parent and child. |
## See Also
* [Tree][textual.widgets.DirectoryTree] code reference
* [Tree][textual.widgets.Tree] code reference

80
docs/widgets/tree.md Normal file
View File

@@ -0,0 +1,80 @@
# Tree
A tree control widget.
- [x] Focusable
- [ ] Container
## Example
The example below creates a simple tree.
=== "Output"
```{.textual path="docs/examples/widgets/tree.py"}
```
=== "tree.py"
```python
--8<-- "docs/examples/widgets/tree.py"
```
A each tree widget has a "root" attribute which is an instance of a [TreeNode][textual.widgets.TreeNode]. Call [add()][textual.widgets.TreeNode.add] or [add_leaf()][textual.widgets.TreeNode.add_leaf] to add new nodes underneath the root. Both these methods return a TreeNode for the child, so you can add more levels.
## Reactive Attributes
| Name | Type | Default | Description |
| ------------- | ------ | ------- | ----------------------------------------------- |
| `show_root` | `bool` | `True` | Show the root node. |
| `show_guides` | `bool` | `True` | Show guide lines between levels. |
| `guide_depth` | `int` | `4` | Amount of indentation between parent and child. |
## Messages
### NodeSelected
The `Tree.NodeSelected` message is sent when the user selects a tree node.
#### Attributes
| attribute | type | purpose |
| --------- | ------------------------------------ | -------------- |
| `node` | [TreeNode][textual.widgets.TreeNode] | Selected node. |
### NodeExpanded
The `Tree.NodeExpanded` message is sent when the user expands a node in the tree.
#### Attributes
| attribute | type | purpose |
| --------- | ------------------------------------ | -------------- |
| `node` | [TreeNode][textual.widgets.TreeNode] | Expanded node. |
### NodeCollapsed
The `Tree.NodeCollapsed` message is sent when the user expands a node in the tree.
#### Attributes
| attribute | type | purpose |
| --------- | ------------------------------------ | --------------- |
| `node` | [TreeNode][textual.widgets.TreeNode] | Collapsed node. |
## See Also
* [Tree][textual.widgets.Tree] code reference
* [TreeNode][textual.widgets.TreeNode] code reference

View File

@@ -1 +0,0 @@
# TreeControl