Merge branch 'main' into fix-1258
@@ -12,11 +12,18 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
|
|||||||
|
|
||||||
- Added "inherited bindings" -- BINDINGS classvar will be merged with base classes, unless inherit_bindings is set to False
|
- Added "inherited bindings" -- BINDINGS classvar will be merged with base classes, unless inherit_bindings is set to False
|
||||||
- Added `Tree` widget which replaces `TreeControl`.
|
- Added `Tree` widget which replaces `TreeControl`.
|
||||||
|
- Added widget `Placeholder` https://github.com/Textualize/textual/issues/1200.
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|
||||||
- Rebuilt `DirectoryTree` with new `Tree` control.
|
- Rebuilt `DirectoryTree` with new `Tree` control.
|
||||||
- Container widgets now have default height of `1fr`.
|
- Container widgets now have default height of `1fr`.
|
||||||
|
- The default `width` of a `Label` is now `auto`.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Type selectors can now contain numbers https://github.com/Textualize/textual/issues/1253
|
||||||
|
- Fixed visibility not affecting children https://github.com/Textualize/textual/issues/1313
|
||||||
|
|
||||||
## [0.5.0] - 2022-11-20
|
## [0.5.0] - 2022-11-20
|
||||||
|
|
||||||
|
|||||||
1
docs/api/placeholder.md
Normal file
@@ -0,0 +1 @@
|
|||||||
|
::: textual.widgets.Placeholder
|
||||||
BIN
docs/blog/images/2022-11-26-davep-devlog/gridinfo-1.png
Normal file
|
After Width: | Height: | Size: 526 KiB |
BIN
docs/blog/images/2022-11-26-davep-devlog/gridinfo-2.png
Normal file
|
After Width: | Height: | Size: 1.1 MiB |
BIN
docs/blog/images/2022-11-26-davep-devlog/unbored-1.png
Normal file
|
After Width: | Height: | Size: 572 KiB |
BIN
docs/blog/images/2022-11-26-davep-devlog/unbored-2.png
Normal file
|
After Width: | Height: | Size: 590 KiB |
BIN
docs/blog/images/spinners-and-pbs-in-textual/bar-in-textual.gif
Normal file
|
After Width: | Height: | Size: 68 KiB |
BIN
docs/blog/images/spinners-and-pbs-in-textual/fake-pause.gif
Normal file
|
After Width: | Height: | Size: 41 KiB |
|
After Width: | Height: | Size: 169 KiB |
|
After Width: | Height: | Size: 58 KiB |
BIN
docs/blog/images/spinners-and-pbs-in-textual/live-display.gif
Normal file
|
After Width: | Height: | Size: 179 KiB |
|
After Width: | Height: | Size: 165 KiB |
|
After Width: | Height: | Size: 29 KiB |
BIN
docs/blog/images/spinners-and-pbs-in-textual/spinner.gif
Normal file
|
After Width: | Height: | Size: 38 KiB |
|
After Width: | Height: | Size: 110 KiB |
@@ -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 Cutler’s philosophy.
|
||||||
|
|
||||||
|
<cite>G. Pascal Zachary — 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:
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
and a lookup of a region looks like this:
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
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:
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
and here's a view of the filter pop-over:
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
### 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...
|
||||||
551
docs/blog/posts/spinners-and-pbs-in-textual.md
Normal file
@@ -0,0 +1,551 @@
|
|||||||
|
---
|
||||||
|
draft: false
|
||||||
|
date: 2022-11-24
|
||||||
|
categories:
|
||||||
|
- DevLog
|
||||||
|
authors:
|
||||||
|
- rodrigo
|
||||||
|
---
|
||||||
|
|
||||||
|
# Spinners and progress bars in Textual
|
||||||
|
|
||||||
|

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

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

|
||||||
|
|
||||||
|
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:
|
||||||
|
|
||||||
|

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

|
||||||
|
|
||||||
|
|
||||||
|
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:
|
||||||
|
|
||||||
|

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

|
||||||
|
|
||||||
|
|
||||||
|
## 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:
|
||||||
|
|
||||||
|

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

|
||||||
|
|
||||||
|
|
||||||
|
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:
|
||||||
|
|
||||||
|

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

|
||||||
|
|
||||||
|
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:
|
||||||
@@ -48,7 +48,7 @@ class Hello(Static):
|
|||||||
|
|
||||||
|
|
||||||
class CustomApp(App):
|
class CustomApp(App):
|
||||||
CSS_PATH = "hello03.css"
|
CSS_PATH = "hello04.css"
|
||||||
|
|
||||||
def compose(self) -> ComposeResult:
|
def compose(self) -> ComposeResult:
|
||||||
yield Hello()
|
yield Hello()
|
||||||
|
|||||||
@@ -32,13 +32,13 @@ class TimeDisplay(Static):
|
|||||||
self.start_time = monotonic()
|
self.start_time = monotonic()
|
||||||
self.update_timer.resume()
|
self.update_timer.resume()
|
||||||
|
|
||||||
def stop(self):
|
def stop(self) -> None:
|
||||||
"""Method to stop the time display updating."""
|
"""Method to stop the time display updating."""
|
||||||
self.update_timer.pause()
|
self.update_timer.pause()
|
||||||
self.total += monotonic() - self.start_time
|
self.total += monotonic() - self.start_time
|
||||||
self.time = self.total
|
self.time = self.total
|
||||||
|
|
||||||
def reset(self):
|
def reset(self) -> None:
|
||||||
"""Method to reset the time display to zero."""
|
"""Method to reset the time display to zero."""
|
||||||
self.total = 0
|
self.total = 0
|
||||||
self.time = 0
|
self.time = 0
|
||||||
|
|||||||
50
docs/examples/widgets/placeholder.css
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
Placeholder {
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
#top {
|
||||||
|
height: 50%;
|
||||||
|
width: 100%;
|
||||||
|
layout: grid;
|
||||||
|
grid-size: 2 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
#left {
|
||||||
|
row-span: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
#bot {
|
||||||
|
height: 50%;
|
||||||
|
width: 100%;
|
||||||
|
layout: grid;
|
||||||
|
grid-size: 8 8;
|
||||||
|
}
|
||||||
|
|
||||||
|
#c1 {
|
||||||
|
row-span: 4;
|
||||||
|
column-span: 8;
|
||||||
|
}
|
||||||
|
|
||||||
|
#col1, #col2, #col3 {
|
||||||
|
width: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
#p1 {
|
||||||
|
row-span: 4;
|
||||||
|
column-span: 4;
|
||||||
|
}
|
||||||
|
|
||||||
|
#p2 {
|
||||||
|
row-span: 2;
|
||||||
|
column-span: 4;
|
||||||
|
}
|
||||||
|
|
||||||
|
#p3 {
|
||||||
|
row-span: 2;
|
||||||
|
column-span: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
#p4 {
|
||||||
|
row-span: 1;
|
||||||
|
column-span: 2;
|
||||||
|
}
|
||||||
39
docs/examples/widgets/placeholder.py
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
from textual.app import App, ComposeResult
|
||||||
|
from textual.containers import Container, Horizontal, Vertical
|
||||||
|
from textual.widgets import Placeholder
|
||||||
|
|
||||||
|
|
||||||
|
class PlaceholderApp(App):
|
||||||
|
|
||||||
|
CSS_PATH = "placeholder.css"
|
||||||
|
|
||||||
|
def compose(self) -> ComposeResult:
|
||||||
|
yield Vertical(
|
||||||
|
Container(
|
||||||
|
Placeholder("This is a custom label for p1.", id="p1"),
|
||||||
|
Placeholder("Placeholder p2 here!", id="p2"),
|
||||||
|
Placeholder(id="p3"),
|
||||||
|
Placeholder(id="p4"),
|
||||||
|
Placeholder(id="p5"),
|
||||||
|
Placeholder(),
|
||||||
|
Horizontal(
|
||||||
|
Placeholder(variant="size", id="col1"),
|
||||||
|
Placeholder(variant="text", id="col2"),
|
||||||
|
Placeholder(variant="size", id="col3"),
|
||||||
|
id="c1",
|
||||||
|
),
|
||||||
|
id="bot"
|
||||||
|
),
|
||||||
|
Container(
|
||||||
|
Placeholder(variant="text", id="left"),
|
||||||
|
Placeholder(variant="size", id="topright"),
|
||||||
|
Placeholder(variant="text", id="botright"),
|
||||||
|
id="top",
|
||||||
|
),
|
||||||
|
id="content",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
app = PlaceholderApp()
|
||||||
|
app.run()
|
||||||
@@ -68,7 +68,7 @@ Let's look at a trivial Textual app.
|
|||||||
|
|
||||||
=== "Output"
|
=== "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`.
|
This example creates an instance of `ExampleApp`, which will implicitly create a `Screen` object. In DOM terms, the `Screen` is a _child_ of `ExampleApp`.
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ Apps don't get much simpler than this—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:
|
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*).
|
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.
|
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.
|
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.
|
||||||
|
|||||||
@@ -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.
|
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"
|
=== "computed01.css"
|
||||||
|
|
||||||
|
|||||||
@@ -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:
|
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
|
## Styling widgets
|
||||||
@@ -48,9 +48,9 @@ Note how the combined height of the widget is three rows in the terminal. This i
|
|||||||
|
|
||||||
## Colors
|
## 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:
|
Here's how you would set the screen background to lime:
|
||||||
|
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ height: 10;
|
|||||||
height: 50%;
|
height: 50%;
|
||||||
|
|
||||||
/* Automatic height */
|
/* Automatic height */
|
||||||
width: auto
|
height: auto
|
||||||
```
|
```
|
||||||
|
|
||||||
## Python
|
## Python
|
||||||
|
|||||||
47
docs/widgets/placeholder.md
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
# Placeholder
|
||||||
|
|
||||||
|
|
||||||
|
A widget that 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 placeholder widget has variants that display different bits of useful information.
|
||||||
|
Clicking a placeholder will cycle through its variants.
|
||||||
|
|
||||||
|
- [ ] Focusable
|
||||||
|
- [ ] Container
|
||||||
|
|
||||||
|
## Example
|
||||||
|
|
||||||
|
The example below shows each placeholder variant.
|
||||||
|
|
||||||
|
=== "Output"
|
||||||
|
|
||||||
|
```{.textual path="docs/examples/widgets/placeholder.py"}
|
||||||
|
```
|
||||||
|
|
||||||
|
=== "placeholder.py"
|
||||||
|
|
||||||
|
```python
|
||||||
|
--8<-- "docs/examples/widgets/placeholder.py"
|
||||||
|
```
|
||||||
|
|
||||||
|
=== "placeholder.css"
|
||||||
|
|
||||||
|
```css
|
||||||
|
--8<-- "docs/examples/widgets/placeholder.css"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Reactive Attributes
|
||||||
|
|
||||||
|
| Name | Type | Default | Description |
|
||||||
|
| ---------- | ------ | ----------- | -------------------------------------------------- |
|
||||||
|
| `variant` | `str` | `"default"` | Styling variant. One of `default`, `size`, `text`. |
|
||||||
|
|
||||||
|
|
||||||
|
## Messages
|
||||||
|
|
||||||
|
This widget sends no messages.
|
||||||
|
|
||||||
|
## See Also
|
||||||
|
|
||||||
|
* [Placeholder](../api/placeholder.md) code reference
|
||||||
@@ -98,19 +98,24 @@ nav:
|
|||||||
- "widgets/index.md"
|
- "widgets/index.md"
|
||||||
- "widgets/input.md"
|
- "widgets/input.md"
|
||||||
- "widgets/label.md"
|
- "widgets/label.md"
|
||||||
|
- "widgets/placeholder.md"
|
||||||
- "widgets/static.md"
|
- "widgets/static.md"
|
||||||
- "widgets/tree.md"
|
- "widgets/tree.md"
|
||||||
- API:
|
- API:
|
||||||
- "api/index.md"
|
- "api/index.md"
|
||||||
- "api/app.md"
|
- "api/app.md"
|
||||||
- "api/button.md"
|
- "api/button.md"
|
||||||
|
- "api/checkbox.md"
|
||||||
- "api/color.md"
|
- "api/color.md"
|
||||||
- "api/containers.md"
|
- "api/containers.md"
|
||||||
|
- "api/data_table.md"
|
||||||
|
- "api/directory_tree.md"
|
||||||
- "api/dom_node.md"
|
- "api/dom_node.md"
|
||||||
- "api/events.md"
|
- "api/events.md"
|
||||||
- "api/footer.md"
|
- "api/footer.md"
|
||||||
- "api/geometry.md"
|
- "api/geometry.md"
|
||||||
- "api/header.md"
|
- "api/header.md"
|
||||||
|
- "api/input.md"
|
||||||
- "api/label.md"
|
- "api/label.md"
|
||||||
- "api/message_pump.md"
|
- "api/message_pump.md"
|
||||||
- "api/message.md"
|
- "api/message.md"
|
||||||
|
|||||||
@@ -41,7 +41,6 @@ def arrange(
|
|||||||
|
|
||||||
placements: list[WidgetPlacement] = []
|
placements: list[WidgetPlacement] = []
|
||||||
add_placement = placements.append
|
add_placement = placements.append
|
||||||
region = size.region
|
|
||||||
|
|
||||||
_WidgetPlacement = WidgetPlacement
|
_WidgetPlacement = WidgetPlacement
|
||||||
top_z = TOP_Z
|
top_z = TOP_Z
|
||||||
@@ -50,7 +49,9 @@ def arrange(
|
|||||||
get_dock = attrgetter("styles.dock")
|
get_dock = attrgetter("styles.dock")
|
||||||
styles = widget.styles
|
styles = widget.styles
|
||||||
|
|
||||||
|
layer_region = size.region
|
||||||
for widgets in dock_layers.values():
|
for widgets in dock_layers.values():
|
||||||
|
region = layer_region
|
||||||
|
|
||||||
layout_widgets, dock_widgets = partition(get_dock, widgets)
|
layout_widgets, dock_widgets = partition(get_dock, widgets)
|
||||||
|
|
||||||
|
|||||||
@@ -347,6 +347,7 @@ class Compositor:
|
|||||||
order: tuple[tuple[int, ...], ...],
|
order: tuple[tuple[int, ...], ...],
|
||||||
layer_order: int,
|
layer_order: int,
|
||||||
clip: Region,
|
clip: Region,
|
||||||
|
visible: bool,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Called recursively to place a widget and its children in the map.
|
"""Called recursively to place a widget and its children in the map.
|
||||||
|
|
||||||
@@ -356,7 +357,12 @@ class Compositor:
|
|||||||
order (tuple[int, ...]): A tuple of ints to define the order.
|
order (tuple[int, ...]): A tuple of ints to define the order.
|
||||||
clip (Region): The clipping region (i.e. the viewport which contains it).
|
clip (Region): The clipping region (i.e. the viewport which contains it).
|
||||||
"""
|
"""
|
||||||
widgets.add(widget)
|
visibility = widget.styles.get_rule("visibility")
|
||||||
|
if visibility is not None:
|
||||||
|
visible = visibility == "visible"
|
||||||
|
|
||||||
|
if visible:
|
||||||
|
widgets.add(widget)
|
||||||
styles_offset = widget.styles.offset
|
styles_offset = widget.styles.offset
|
||||||
layout_offset = (
|
layout_offset = (
|
||||||
styles_offset.resolve(region.size, clip.size)
|
styles_offset.resolve(region.size, clip.size)
|
||||||
@@ -420,32 +426,34 @@ class Compositor:
|
|||||||
widget_order,
|
widget_order,
|
||||||
layer_order,
|
layer_order,
|
||||||
sub_clip,
|
sub_clip,
|
||||||
|
visible,
|
||||||
)
|
)
|
||||||
layer_order -= 1
|
layer_order -= 1
|
||||||
|
|
||||||
# Add any scrollbars
|
if visible:
|
||||||
for chrome_widget, chrome_region in widget._arrange_scrollbars(
|
# Add any scrollbars
|
||||||
container_region
|
for chrome_widget, chrome_region in widget._arrange_scrollbars(
|
||||||
):
|
container_region
|
||||||
map[chrome_widget] = MapGeometry(
|
):
|
||||||
chrome_region + layout_offset,
|
map[chrome_widget] = MapGeometry(
|
||||||
|
chrome_region + layout_offset,
|
||||||
|
order,
|
||||||
|
clip,
|
||||||
|
container_size,
|
||||||
|
container_size,
|
||||||
|
chrome_region,
|
||||||
|
)
|
||||||
|
|
||||||
|
map[widget] = MapGeometry(
|
||||||
|
region + layout_offset,
|
||||||
order,
|
order,
|
||||||
clip,
|
clip,
|
||||||
|
total_region.size,
|
||||||
container_size,
|
container_size,
|
||||||
container_size,
|
virtual_region,
|
||||||
chrome_region,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
map[widget] = MapGeometry(
|
elif visible:
|
||||||
region + layout_offset,
|
|
||||||
order,
|
|
||||||
clip,
|
|
||||||
total_region.size,
|
|
||||||
container_size,
|
|
||||||
virtual_region,
|
|
||||||
)
|
|
||||||
|
|
||||||
else:
|
|
||||||
# Add the widget to the map
|
# Add the widget to the map
|
||||||
map[widget] = MapGeometry(
|
map[widget] = MapGeometry(
|
||||||
region + layout_offset,
|
region + layout_offset,
|
||||||
@@ -457,7 +465,15 @@ class Compositor:
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Add top level (root) widget
|
# Add top level (root) widget
|
||||||
add_widget(root, size.region, size.region, ((0,),), layer_order, size.region)
|
add_widget(
|
||||||
|
root,
|
||||||
|
size.region,
|
||||||
|
size.region,
|
||||||
|
((0,),),
|
||||||
|
layer_order,
|
||||||
|
size.region,
|
||||||
|
True,
|
||||||
|
)
|
||||||
return map, widgets
|
return map, widgets
|
||||||
|
|
||||||
@property
|
@property
|
||||||
@@ -630,11 +646,6 @@ class Compositor:
|
|||||||
if not self.map:
|
if not self.map:
|
||||||
return
|
return
|
||||||
|
|
||||||
def is_visible(widget: Widget) -> bool:
|
|
||||||
"""Return True if the widget is (literally) visible by examining various
|
|
||||||
properties which affect whether it can be seen or not."""
|
|
||||||
return widget.visible and widget.styles.opacity > 0
|
|
||||||
|
|
||||||
_Region = Region
|
_Region = Region
|
||||||
|
|
||||||
visible_widgets = self.visible_widgets
|
visible_widgets = self.visible_widgets
|
||||||
@@ -644,13 +655,13 @@ class Compositor:
|
|||||||
widget_regions = [
|
widget_regions = [
|
||||||
(widget, region, clip)
|
(widget, region, clip)
|
||||||
for widget, (region, clip) in visible_widgets.items()
|
for widget, (region, clip) in visible_widgets.items()
|
||||||
if crop_overlaps(clip) and is_visible(widget)
|
if crop_overlaps(clip) and widget.styles.opacity > 0
|
||||||
]
|
]
|
||||||
else:
|
else:
|
||||||
widget_regions = [
|
widget_regions = [
|
||||||
(widget, region, clip)
|
(widget, region, clip)
|
||||||
for widget, (region, clip) in visible_widgets.items()
|
for widget, (region, clip) in visible_widgets.items()
|
||||||
if is_visible(widget)
|
if widget.styles.opacity > 0
|
||||||
]
|
]
|
||||||
|
|
||||||
intersection = _Region.intersection
|
intersection = _Region.intersection
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ if TYPE_CHECKING:
|
|||||||
from .widget import Widget
|
from .widget import Widget
|
||||||
|
|
||||||
ArrangeResult: TypeAlias = "tuple[list[WidgetPlacement], set[Widget]]"
|
ArrangeResult: TypeAlias = "tuple[list[WidgetPlacement], set[Widget]]"
|
||||||
DockArrangeResult: TypeAlias = "tuple[list[WidgetPlacement], set[Widget], Spacing]"
|
DockArrangeResult: TypeAlias = "tuple[list[WidgetPlacement], set[Widget], Spacing]]"
|
||||||
|
|
||||||
|
|
||||||
class WidgetPlacement(NamedTuple):
|
class WidgetPlacement(NamedTuple):
|
||||||
|
|||||||
@@ -101,8 +101,8 @@ class NodeList(Sequence):
|
|||||||
if widget_id in self._nodes_by_id:
|
if widget_id in self._nodes_by_id:
|
||||||
raise DuplicateIds(
|
raise DuplicateIds(
|
||||||
f"Tried to insert a widget with ID {widget_id!r}, but a widget {self._nodes_by_id[widget_id]!r} "
|
f"Tried to insert a widget with ID {widget_id!r}, but a widget {self._nodes_by_id[widget_id]!r} "
|
||||||
f"already exists with that ID in this list of children. "
|
"already exists with that ID in this list of children. "
|
||||||
f"The children of a widget must have unique IDs."
|
"The children of a widget must have unique IDs."
|
||||||
)
|
)
|
||||||
|
|
||||||
def _remove(self, widget: Widget) -> None:
|
def _remove(self, widget: Widget) -> None:
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from functools import lru_cache
|
||||||
|
from sys import intern
|
||||||
from typing import TYPE_CHECKING, Callable, Iterable, List
|
from typing import TYPE_CHECKING, Callable, Iterable, List
|
||||||
|
|
||||||
from rich.segment import Segment
|
from rich.segment import Segment
|
||||||
@@ -52,6 +54,20 @@ def style_links(
|
|||||||
return segments
|
return segments
|
||||||
|
|
||||||
|
|
||||||
|
@lru_cache(1024 * 8)
|
||||||
|
def make_blank(width, style: Style) -> Segment:
|
||||||
|
"""Make a blank segment.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
width (_type_): Width of blank.
|
||||||
|
style (Style): Style of blank.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Segment: A single segment
|
||||||
|
"""
|
||||||
|
return Segment(intern(" " * width), style)
|
||||||
|
|
||||||
|
|
||||||
class StylesCache:
|
class StylesCache:
|
||||||
"""Responsible for rendering CSS Styles and keeping a cache of rendered lines.
|
"""Responsible for rendering CSS Styles and keeping a cache of rendered lines.
|
||||||
|
|
||||||
@@ -319,20 +335,20 @@ class StylesCache:
|
|||||||
right_style = from_color(color=(background + border_right_color).rich_color)
|
right_style = from_color(color=(background + border_right_color).rich_color)
|
||||||
right = get_box(border_right, inner, outer, right_style)[1][2]
|
right = get_box(border_right, inner, outer, right_style)[1][2]
|
||||||
if border_left and border_right:
|
if border_left and border_right:
|
||||||
line = [left, Segment(" " * (width - 2), background_style), right]
|
line = [left, make_blank(width - 2, background_style), right]
|
||||||
elif border_left:
|
elif border_left:
|
||||||
line = [left, Segment(" " * (width - 1), background_style)]
|
line = [left, make_blank(width - 1, background_style)]
|
||||||
elif border_right:
|
elif border_right:
|
||||||
line = [Segment(" " * (width - 1), background_style), right]
|
line = [make_blank(width - 1, background_style), right]
|
||||||
else:
|
else:
|
||||||
line = [Segment(" " * width, background_style)]
|
line = [make_blank(width, background_style)]
|
||||||
else:
|
else:
|
||||||
# Content with border and padding (C)
|
# Content with border and padding (C)
|
||||||
content_y = y - gutter.top
|
content_y = y - gutter.top
|
||||||
if content_y < content_height:
|
if content_y < content_height:
|
||||||
line = render_content_line(y - gutter.top)
|
line = render_content_line(y - gutter.top)
|
||||||
else:
|
else:
|
||||||
line = [Segment(" " * content_width, inner)]
|
line = [make_blank(content_width, inner)]
|
||||||
if inner:
|
if inner:
|
||||||
line = Segment.apply_style(line, inner)
|
line = Segment.apply_style(line, inner)
|
||||||
line = line_pad(line, pad_left, pad_right, inner)
|
line = line_pad(line, pad_left, pad_right, inner)
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ from typing import (
|
|||||||
TypeVar,
|
TypeVar,
|
||||||
Union,
|
Union,
|
||||||
cast,
|
cast,
|
||||||
|
overload,
|
||||||
)
|
)
|
||||||
from weakref import WeakSet, WeakValueDictionary
|
from weakref import WeakSet, WeakValueDictionary
|
||||||
|
|
||||||
@@ -892,23 +893,52 @@ class App(Generic[ReturnType], DOMNode):
|
|||||||
def render(self) -> RenderableType:
|
def render(self) -> RenderableType:
|
||||||
return Blank(self.styles.background)
|
return Blank(self.styles.background)
|
||||||
|
|
||||||
|
ExpectType = TypeVar("ExpectType", bound=Widget)
|
||||||
|
|
||||||
|
@overload
|
||||||
def get_child_by_id(self, id: str) -> Widget:
|
def get_child_by_id(self, id: str) -> Widget:
|
||||||
|
...
|
||||||
|
|
||||||
|
@overload
|
||||||
|
def get_child_by_id(self, id: str, expect_type: type[ExpectType]) -> ExpectType:
|
||||||
|
...
|
||||||
|
|
||||||
|
def get_child_by_id(
|
||||||
|
self, id: str, expect_type: type[ExpectType] | None = None
|
||||||
|
) -> ExpectType | Widget:
|
||||||
"""Shorthand for self.screen.get_child(id: str)
|
"""Shorthand for self.screen.get_child(id: str)
|
||||||
Returns the first child (immediate descendent) of this DOMNode
|
Returns the first child (immediate descendent) of this DOMNode
|
||||||
with the given ID.
|
with the given ID.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
id (str): The ID of the node to search for.
|
id (str): The ID of the node to search for.
|
||||||
|
expect_type (type | None, optional): Require the object be of the supplied type, or None for any type.
|
||||||
|
Defaults to None.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
DOMNode: The first child of this node with the specified ID.
|
ExpectType | Widget: The first child of this node with the specified ID.
|
||||||
|
|
||||||
Raises:
|
Raises:
|
||||||
NoMatches: if no children could be found for this ID
|
NoMatches: if no children could be found for this ID
|
||||||
|
WrongType: if the wrong type was found.
|
||||||
"""
|
"""
|
||||||
return self.screen.get_child_by_id(id)
|
return (
|
||||||
|
self.screen.get_child_by_id(id)
|
||||||
|
if expect_type is None
|
||||||
|
else self.screen.get_child_by_id(id, expect_type)
|
||||||
|
)
|
||||||
|
|
||||||
|
@overload
|
||||||
def get_widget_by_id(self, id: str) -> Widget:
|
def get_widget_by_id(self, id: str) -> Widget:
|
||||||
|
...
|
||||||
|
|
||||||
|
@overload
|
||||||
|
def get_widget_by_id(self, id: str, expect_type: type[ExpectType]) -> ExpectType:
|
||||||
|
...
|
||||||
|
|
||||||
|
def get_widget_by_id(
|
||||||
|
self, id: str, expect_type: type[ExpectType] | None = None
|
||||||
|
) -> ExpectType | Widget:
|
||||||
"""Shorthand for self.screen.get_widget_by_id(id)
|
"""Shorthand for self.screen.get_widget_by_id(id)
|
||||||
Return the first descendant widget with the given ID.
|
Return the first descendant widget with the given ID.
|
||||||
|
|
||||||
@@ -918,14 +948,21 @@ class App(Generic[ReturnType], DOMNode):
|
|||||||
|
|
||||||
Args:
|
Args:
|
||||||
id (str): The ID to search for in the subtree
|
id (str): The ID to search for in the subtree
|
||||||
|
expect_type (type | None, optional): Require the object be of the supplied type, or None for any type.
|
||||||
|
Defaults to None.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
DOMNode: The first descendant encountered with this ID.
|
ExpectType | Widget: The first descendant encountered with this ID.
|
||||||
|
|
||||||
Raises:
|
Raises:
|
||||||
NoMatches: if no children could be found for this ID
|
NoMatches: if no children could be found for this ID
|
||||||
|
WrongType: if the wrong type was found.
|
||||||
"""
|
"""
|
||||||
return self.screen.get_widget_by_id(id)
|
return (
|
||||||
|
self.screen.get_widget_by_id(id)
|
||||||
|
if expect_type is None
|
||||||
|
else self.screen.get_widget_by_id(id, expect_type)
|
||||||
|
)
|
||||||
|
|
||||||
def update_styles(self, node: DOMNode | None = None) -> None:
|
def update_styles(self, node: DOMNode | None = None) -> None:
|
||||||
"""Request update of styles.
|
"""Request update of styles.
|
||||||
@@ -1463,7 +1500,6 @@ class App(Generic[ReturnType], DOMNode):
|
|||||||
|
|
||||||
# If we don't already know about this widget...
|
# If we don't already know about this widget...
|
||||||
if child not in self._registry:
|
if child not in self._registry:
|
||||||
|
|
||||||
# Now to figure out where to place it. If we've got a `before`...
|
# Now to figure out where to place it. If we've got a `before`...
|
||||||
if before is not None:
|
if before is not None:
|
||||||
# ...it's safe to NodeList._insert before that location.
|
# ...it's safe to NodeList._insert before that location.
|
||||||
|
|||||||
@@ -305,7 +305,7 @@ class BoxProperty:
|
|||||||
current_value: tuple[str, Color] = cast(
|
current_value: tuple[str, Color] = cast(
|
||||||
"tuple[str, Color]", obj.get_rule(self.name)
|
"tuple[str, Color]", obj.get_rule(self.name)
|
||||||
)
|
)
|
||||||
has_edge = current_value and current_value[0]
|
has_edge = bool(current_value and current_value[0])
|
||||||
new_edge = bool(_type)
|
new_edge = bool(_type)
|
||||||
if obj.set_rule(self.name, new_value):
|
if obj.set_rule(self.name, new_value):
|
||||||
obj.refresh(layout=has_edge != new_edge)
|
obj.refresh(layout=has_edge != new_edge)
|
||||||
|
|||||||
@@ -594,8 +594,10 @@ class Styles(StylesBase):
|
|||||||
Returns:
|
Returns:
|
||||||
bool: ``True`` if a rule was cleared, or ``False`` if it was already not set.
|
bool: ``True`` if a rule was cleared, or ``False`` if it was already not set.
|
||||||
"""
|
"""
|
||||||
self._updates += 1
|
changed = self._rules.pop(rule, None) is not None
|
||||||
return self._rules.pop(rule, None) is not None
|
if changed:
|
||||||
|
self._updates += 1
|
||||||
|
return changed
|
||||||
|
|
||||||
def get_rules(self) -> RulesMap:
|
def get_rules(self) -> RulesMap:
|
||||||
return self._rules.copy()
|
return self._rules.copy()
|
||||||
@@ -610,12 +612,17 @@ class Styles(StylesBase):
|
|||||||
Returns:
|
Returns:
|
||||||
bool: ``True`` if the rule changed, otherwise ``False``.
|
bool: ``True`` if the rule changed, otherwise ``False``.
|
||||||
"""
|
"""
|
||||||
self._updates += 1
|
|
||||||
if value is None:
|
if value is None:
|
||||||
return self._rules.pop(rule, None) is not None
|
changed = self._rules.pop(rule, None) is not None
|
||||||
|
if changed:
|
||||||
|
self._updates += 1
|
||||||
|
return changed
|
||||||
current = self._rules.get(rule)
|
current = self._rules.get(rule)
|
||||||
self._rules[rule] = value
|
self._rules[rule] = value
|
||||||
return current != value
|
changed = current != value
|
||||||
|
if changed:
|
||||||
|
self._updates += 1
|
||||||
|
return changed
|
||||||
|
|
||||||
def get_rule(self, rule: str, default: object = None) -> object:
|
def get_rule(self, rule: str, default: object = None) -> object:
|
||||||
return self._rules.get(rule, default)
|
return self._rules.get(rule, default)
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ expect_root_scope = Expect(
|
|||||||
selector_start_id=r"\#" + IDENTIFIER,
|
selector_start_id=r"\#" + IDENTIFIER,
|
||||||
selector_start_class=r"\." + IDENTIFIER,
|
selector_start_class=r"\." + IDENTIFIER,
|
||||||
selector_start_universal=r"\*",
|
selector_start_universal=r"\*",
|
||||||
selector_start=r"[a-zA-Z_\-]+",
|
selector_start=IDENTIFIER,
|
||||||
variable_name=rf"{VARIABLE_REF}:",
|
variable_name=rf"{VARIABLE_REF}:",
|
||||||
).expect_eof(True)
|
).expect_eof(True)
|
||||||
|
|
||||||
|
|||||||
@@ -176,6 +176,7 @@ class LinuxDriver(Driver):
|
|||||||
self.exit_event.set()
|
self.exit_event.set()
|
||||||
if self._key_thread is not None:
|
if self._key_thread is not None:
|
||||||
self._key_thread.join()
|
self._key_thread.join()
|
||||||
|
self.exit_event.clear()
|
||||||
termios.tcflush(self.fileno, termios.TCIFLUSH)
|
termios.tcflush(self.fileno, termios.TCIFLUSH)
|
||||||
except Exception as error:
|
except Exception as error:
|
||||||
# TODO: log this
|
# TODO: log this
|
||||||
|
|||||||
@@ -84,6 +84,7 @@ class WindowsDriver(Driver):
|
|||||||
if self._event_thread is not None:
|
if self._event_thread is not None:
|
||||||
self._event_thread.join()
|
self._event_thread.join()
|
||||||
self._event_thread = None
|
self._event_thread = None
|
||||||
|
self.exit_event.clear()
|
||||||
except Exception as error:
|
except Exception as error:
|
||||||
# TODO: log this
|
# TODO: log this
|
||||||
pass
|
pass
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from collections import Counter
|
|
||||||
from asyncio import Event as AsyncEvent
|
from asyncio import Event as AsyncEvent
|
||||||
from asyncio import Lock, create_task, wait
|
from asyncio import Lock, create_task, wait
|
||||||
|
from collections import Counter
|
||||||
from fractions import Fraction
|
from fractions import Fraction
|
||||||
from itertools import islice
|
from itertools import islice
|
||||||
from operator import attrgetter
|
from operator import attrgetter
|
||||||
@@ -14,7 +14,9 @@ from typing import (
|
|||||||
Iterable,
|
Iterable,
|
||||||
NamedTuple,
|
NamedTuple,
|
||||||
Sequence,
|
Sequence,
|
||||||
|
TypeVar,
|
||||||
cast,
|
cast,
|
||||||
|
overload,
|
||||||
)
|
)
|
||||||
|
|
||||||
import rich.repr
|
import rich.repr
|
||||||
@@ -44,7 +46,7 @@ from ._types import Lines
|
|||||||
from .await_remove import AwaitRemove
|
from .await_remove import AwaitRemove
|
||||||
from .binding import Binding
|
from .binding import Binding
|
||||||
from .box_model import BoxModel, get_box_model
|
from .box_model import BoxModel, get_box_model
|
||||||
from .css.query import NoMatches
|
from .css.query import NoMatches, WrongType
|
||||||
from .css.scalar import ScalarOffset
|
from .css.scalar import ScalarOffset
|
||||||
from .dom import DOMNode, NoScreen
|
from .dom import DOMNode, NoScreen
|
||||||
from .geometry import Offset, Region, Size, Spacing, clamp
|
from .geometry import Offset, Region, Size, Spacing, clamp
|
||||||
@@ -53,7 +55,6 @@ from .message import Message
|
|||||||
from .messages import CallbackType
|
from .messages import CallbackType
|
||||||
from .reactive import Reactive
|
from .reactive import Reactive
|
||||||
from .render import measure
|
from .render import measure
|
||||||
from .await_remove import AwaitRemove
|
|
||||||
from .walk import walk_depth_first
|
from .walk import walk_depth_first
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
@@ -222,7 +223,6 @@ class Widget(DOMNode):
|
|||||||
id: str | None = None,
|
id: str | None = None,
|
||||||
classes: str | None = None,
|
classes: str | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
|
|
||||||
self._size = Size(0, 0)
|
self._size = Size(0, 0)
|
||||||
self._container_size = Size(0, 0)
|
self._container_size = Size(0, 0)
|
||||||
self._layout_required = False
|
self._layout_required = False
|
||||||
@@ -350,41 +350,81 @@ class Widget(DOMNode):
|
|||||||
def offset(self, offset: Offset) -> None:
|
def offset(self, offset: Offset) -> None:
|
||||||
self.styles.offset = ScalarOffset.from_offset(offset)
|
self.styles.offset = ScalarOffset.from_offset(offset)
|
||||||
|
|
||||||
|
ExpectType = TypeVar("ExpectType", bound="Widget")
|
||||||
|
|
||||||
|
@overload
|
||||||
def get_child_by_id(self, id: str) -> Widget:
|
def get_child_by_id(self, id: str) -> Widget:
|
||||||
|
...
|
||||||
|
|
||||||
|
@overload
|
||||||
|
def get_child_by_id(self, id: str, expect_type: type[ExpectType]) -> ExpectType:
|
||||||
|
...
|
||||||
|
|
||||||
|
def get_child_by_id(
|
||||||
|
self, id: str, expect_type: type[ExpectType] | None = None
|
||||||
|
) -> ExpectType | Widget:
|
||||||
"""Return the first child (immediate descendent) of this node with the given ID.
|
"""Return the first child (immediate descendent) of this node with the given ID.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
id (str): The ID of the child.
|
id (str): The ID of the child.
|
||||||
|
expect_type (type | None, optional): Require the object be of the supplied type, or None for any type.
|
||||||
|
Defaults to None.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
DOMNode: The first child of this node with the ID.
|
ExpectType | Widget: The first child of this node with the ID.
|
||||||
|
|
||||||
Raises:
|
Raises:
|
||||||
NoMatches: if no children could be found for this ID
|
NoMatches: if no children could be found for this ID
|
||||||
|
WrongType: if the wrong type was found.
|
||||||
"""
|
"""
|
||||||
child = self.children._get_by_id(id)
|
child = self.children._get_by_id(id)
|
||||||
if child is not None:
|
if child is None:
|
||||||
|
raise NoMatches(f"No child found with id={id!r}")
|
||||||
|
if expect_type is None:
|
||||||
return child
|
return child
|
||||||
raise NoMatches(f"No child found with id={id!r}")
|
if not isinstance(child, expect_type):
|
||||||
|
raise WrongType(
|
||||||
|
f"Child with id={id!r} is wrong type; expected {expect_type}, got"
|
||||||
|
f" {type(child)}"
|
||||||
|
)
|
||||||
|
return child
|
||||||
|
|
||||||
|
@overload
|
||||||
def get_widget_by_id(self, id: str) -> Widget:
|
def get_widget_by_id(self, id: str) -> Widget:
|
||||||
|
...
|
||||||
|
|
||||||
|
@overload
|
||||||
|
def get_widget_by_id(self, id: str, expect_type: type[ExpectType]) -> ExpectType:
|
||||||
|
...
|
||||||
|
|
||||||
|
def get_widget_by_id(
|
||||||
|
self, id: str, expect_type: type[ExpectType] | None = None
|
||||||
|
) -> ExpectType | Widget:
|
||||||
"""Return the first descendant widget with the given ID.
|
"""Return the first descendant widget with the given ID.
|
||||||
Performs a depth-first search rooted at this widget.
|
Performs a depth-first search rooted at this widget.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
id (str): The ID to search for in the subtree
|
id (str): The ID to search for in the subtree
|
||||||
|
expect_type (type | None, optional): Require the object be of the supplied type, or None for any type.
|
||||||
|
Defaults to None.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
DOMNode: The first descendant encountered with this ID.
|
ExpectType | Widget: The first descendant encountered with this ID.
|
||||||
|
|
||||||
Raises:
|
Raises:
|
||||||
NoMatches: if no children could be found for this ID
|
NoMatches: if no children could be found for this ID
|
||||||
|
WrongType: if the wrong type was found.
|
||||||
"""
|
"""
|
||||||
for child in walk_depth_first(self):
|
for child in walk_depth_first(self):
|
||||||
try:
|
try:
|
||||||
return child.get_child_by_id(id)
|
return child.get_child_by_id(id, expect_type=expect_type)
|
||||||
except NoMatches:
|
except NoMatches:
|
||||||
pass
|
pass
|
||||||
|
except WrongType as exc:
|
||||||
|
raise WrongType(
|
||||||
|
f"Descendant with id={id!r} is wrong type; expected {expect_type},"
|
||||||
|
f" got {type(child)}"
|
||||||
|
) from exc
|
||||||
raise NoMatches(f"No descendant found with id={id!r}")
|
raise NoMatches(f"No descendant found with id={id!r}")
|
||||||
|
|
||||||
def get_component_rich_style(self, name: str, *, partial: bool = False) -> Style:
|
def get_component_rich_style(self, name: str, *, partial: bool = False) -> Style:
|
||||||
@@ -531,7 +571,7 @@ class Widget(DOMNode):
|
|||||||
if count > 1:
|
if count > 1:
|
||||||
raise MountError(
|
raise MountError(
|
||||||
f"Tried to insert {count!r} widgets with the same ID {widget_id!r}. "
|
f"Tried to insert {count!r} widgets with the same ID {widget_id!r}. "
|
||||||
f"Widget IDs must be unique."
|
"Widget IDs must be unique."
|
||||||
)
|
)
|
||||||
|
|
||||||
# Saying you want to mount before *and* after something is an error.
|
# Saying you want to mount before *and* after something is an error.
|
||||||
@@ -755,14 +795,12 @@ class Widget(DOMNode):
|
|||||||
def watch_scroll_x(self, new_value: float) -> None:
|
def watch_scroll_x(self, new_value: float) -> None:
|
||||||
if self.show_horizontal_scrollbar:
|
if self.show_horizontal_scrollbar:
|
||||||
self.horizontal_scrollbar.position = int(new_value)
|
self.horizontal_scrollbar.position = int(new_value)
|
||||||
self.horizontal_scrollbar.refresh()
|
self.refresh(layout=True, repaint=False)
|
||||||
self.refresh(layout=True)
|
|
||||||
|
|
||||||
def watch_scroll_y(self, new_value: float) -> None:
|
def watch_scroll_y(self, new_value: float) -> None:
|
||||||
if self.show_vertical_scrollbar:
|
if self.show_vertical_scrollbar:
|
||||||
self.vertical_scrollbar.position = int(new_value)
|
self.vertical_scrollbar.position = int(new_value)
|
||||||
self.vertical_scrollbar.refresh()
|
self.refresh(layout=True, repaint=False)
|
||||||
self.refresh(layout=True)
|
|
||||||
|
|
||||||
def validate_scroll_x(self, value: float) -> float:
|
def validate_scroll_x(self, value: float) -> float:
|
||||||
return clamp(value, 0, self.max_scroll_x)
|
return clamp(value, 0, self.max_scroll_x)
|
||||||
@@ -2263,7 +2301,7 @@ class Widget(DOMNode):
|
|||||||
def _on_styles_updated(self) -> None:
|
def _on_styles_updated(self) -> None:
|
||||||
self._rich_style_cache.clear()
|
self._rich_style_cache.clear()
|
||||||
|
|
||||||
async def _on_mouse_down(self, event: events.MouseUp) -> None:
|
async def _on_mouse_down(self, event: events.MouseDown) -> None:
|
||||||
await self.broker_event("mouse.down", event)
|
await self.broker_event("mouse.down", event)
|
||||||
|
|
||||||
async def _on_mouse_up(self, event: events.MouseUp) -> None:
|
async def _on_mouse_up(self, event: events.MouseUp) -> None:
|
||||||
|
|||||||
@@ -5,3 +5,11 @@ from ._static import Static
|
|||||||
|
|
||||||
class Label(Static):
|
class Label(Static):
|
||||||
"""A simple label widget for displaying text-oriented renderables."""
|
"""A simple label widget for displaying text-oriented renderables."""
|
||||||
|
|
||||||
|
DEFAULT_CSS = """
|
||||||
|
Label {
|
||||||
|
width: auto;
|
||||||
|
height: auto;
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
"""str: The default styling of a `Label`."""
|
||||||
|
|||||||
@@ -1,63 +1,187 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from rich import box
|
from itertools import cycle
|
||||||
from rich.align import Align
|
|
||||||
from rich.console import RenderableType
|
|
||||||
from rich.panel import Panel
|
|
||||||
from rich.pretty import Pretty
|
|
||||||
import rich.repr
|
|
||||||
from rich.style import Style
|
|
||||||
|
|
||||||
from .. import events
|
from .. import events
|
||||||
from ..reactive import Reactive
|
from ..containers import Container
|
||||||
from ..widget import Widget
|
from ..css._error_tools import friendly_list
|
||||||
|
from ..reactive import Reactive, reactive
|
||||||
|
from ..widget import Widget, RenderResult
|
||||||
|
from ..widgets import Label
|
||||||
|
from .._typing import Literal
|
||||||
|
|
||||||
|
PlaceholderVariant = Literal["default", "size", "text"]
|
||||||
|
_VALID_PLACEHOLDER_VARIANTS_ORDERED: list[PlaceholderVariant] = [
|
||||||
|
"default",
|
||||||
|
"size",
|
||||||
|
"text",
|
||||||
|
]
|
||||||
|
_VALID_PLACEHOLDER_VARIANTS: set[PlaceholderVariant] = set(
|
||||||
|
_VALID_PLACEHOLDER_VARIANTS_ORDERED
|
||||||
|
)
|
||||||
|
_PLACEHOLDER_BACKGROUND_COLORS = [
|
||||||
|
"#881177",
|
||||||
|
"#aa3355",
|
||||||
|
"#cc6666",
|
||||||
|
"#ee9944",
|
||||||
|
"#eedd00",
|
||||||
|
"#99dd55",
|
||||||
|
"#44dd88",
|
||||||
|
"#22ccbb",
|
||||||
|
"#00bbcc",
|
||||||
|
"#0099cc",
|
||||||
|
"#3366bb",
|
||||||
|
"#663399",
|
||||||
|
]
|
||||||
|
_LOREM_IPSUM_PLACEHOLDER_TEXT = "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Etiam feugiat ac elit sit amet accumsan. Suspendisse bibendum nec libero quis gravida. Phasellus id eleifend ligula. Nullam imperdiet sem tellus, sed vehicula nisl faucibus sit amet. Praesent iaculis tempor ultricies. Sed lacinia, tellus id rutrum lacinia, sapien sapien congue mauris, sit amet pellentesque quam quam vel nisl. Curabitur vulputate erat pellentesque mauris posuere, non dictum risus mattis."
|
||||||
|
|
||||||
|
|
||||||
@rich.repr.auto(angular=False)
|
class InvalidPlaceholderVariant(Exception):
|
||||||
class Placeholder(Widget, can_focus=True):
|
pass
|
||||||
|
|
||||||
has_focus: Reactive[bool] = Reactive(False)
|
|
||||||
mouse_over: Reactive[bool] = Reactive(False)
|
class _PlaceholderLabel(Widget):
|
||||||
|
def __init__(self, content, classes) -> None:
|
||||||
|
super().__init__(classes=classes)
|
||||||
|
self._content = content
|
||||||
|
|
||||||
|
def render(self) -> RenderResult:
|
||||||
|
return self._content
|
||||||
|
|
||||||
|
|
||||||
|
class Placeholder(Container):
|
||||||
|
"""A simple placeholder widget to use before you build your custom widgets.
|
||||||
|
|
||||||
|
This placeholder has a couple of variants that show different data.
|
||||||
|
Clicking the placeholder cycles through the available variants, but a placeholder
|
||||||
|
can also be initialised in a specific variant.
|
||||||
|
|
||||||
|
The variants available are:
|
||||||
|
default: shows an identifier label or the ID of the placeholder.
|
||||||
|
size: shows the size of the placeholder.
|
||||||
|
text: shows some Lorem Ipsum text on the placeholder.
|
||||||
|
"""
|
||||||
|
|
||||||
|
DEFAULT_CSS = """
|
||||||
|
Placeholder {
|
||||||
|
align: center middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
Placeholder.-text {
|
||||||
|
padding: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
_PlaceholderLabel {
|
||||||
|
height: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
Placeholder > _PlaceholderLabel {
|
||||||
|
content-align: center middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
Placeholder.-default > _PlaceholderLabel.-size,
|
||||||
|
Placeholder.-default > _PlaceholderLabel.-text,
|
||||||
|
Placeholder.-size > _PlaceholderLabel.-default,
|
||||||
|
Placeholder.-size > _PlaceholderLabel.-text,
|
||||||
|
Placeholder.-text > _PlaceholderLabel.-default,
|
||||||
|
Placeholder.-text > _PlaceholderLabel.-size {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
Placeholder.-default > _PlaceholderLabel.-default,
|
||||||
|
Placeholder.-size > _PlaceholderLabel.-size,
|
||||||
|
Placeholder.-text > _PlaceholderLabel.-text {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
# Consecutive placeholders get assigned consecutive colors.
|
||||||
|
_COLORS = cycle(_PLACEHOLDER_BACKGROUND_COLORS)
|
||||||
|
_SIZE_RENDER_TEMPLATE = "[b]{} x {}[/b]"
|
||||||
|
|
||||||
|
variant: Reactive[PlaceholderVariant] = reactive("default")
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def reset_color_cycle(cls) -> None:
|
||||||
|
"""Reset the placeholder background color cycle."""
|
||||||
|
cls._COLORS = cycle(_PLACEHOLDER_BACKGROUND_COLORS)
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
# parent class constructor signature:
|
|
||||||
self,
|
self,
|
||||||
*children: Widget,
|
label: str | None = None,
|
||||||
|
variant: PlaceholderVariant = "default",
|
||||||
|
*,
|
||||||
name: str | None = None,
|
name: str | None = None,
|
||||||
id: str | None = None,
|
id: str | None = None,
|
||||||
classes: str | None = None,
|
classes: str | None = None,
|
||||||
# ...and now for our own class specific params:
|
|
||||||
title: str | None = None,
|
|
||||||
) -> None:
|
) -> None:
|
||||||
super().__init__(*children, name=name, id=id, classes=classes)
|
"""Create a Placeholder widget.
|
||||||
self.title = title
|
|
||||||
|
|
||||||
def __rich_repr__(self) -> rich.repr.Result:
|
Args:
|
||||||
yield from super().__rich_repr__()
|
label (str | None, optional): The label to identify the placeholder.
|
||||||
yield "has_focus", self.has_focus, False
|
If no label is present, uses the placeholder ID instead. Defaults to None.
|
||||||
yield "mouse_over", self.mouse_over, False
|
variant (PlaceholderVariant, optional): The variant of the placeholder.
|
||||||
|
Defaults to "default".
|
||||||
def render(self) -> RenderableType:
|
name (str | None, optional): The name of the placeholder. Defaults to None.
|
||||||
# Apply colours only inside render_styled
|
id (str | None, optional): The ID of the placeholder in the DOM.
|
||||||
# Pass the full RICH style object into `render` - not the `Styles`
|
Defaults to None.
|
||||||
return Panel(
|
classes (str | None, optional): A space separated string with the CSS classes
|
||||||
Align.center(
|
of the placeholder, if any. Defaults to None.
|
||||||
Pretty(self, no_wrap=True, overflow="ellipsis"),
|
"""
|
||||||
vertical="middle",
|
# Create and cache labels for all the variants.
|
||||||
),
|
self._default_label = _PlaceholderLabel(
|
||||||
title=self.title or self.__class__.__name__,
|
label if label else f"#{id}" if id else "Placeholder",
|
||||||
border_style="green" if self.mouse_over else "blue",
|
"-default",
|
||||||
box=box.HEAVY if self.has_focus else box.ROUNDED,
|
)
|
||||||
|
self._size_label = _PlaceholderLabel(
|
||||||
|
"",
|
||||||
|
"-size",
|
||||||
|
)
|
||||||
|
self._text_label = _PlaceholderLabel(
|
||||||
|
_LOREM_IPSUM_PLACEHOLDER_TEXT,
|
||||||
|
"-text",
|
||||||
|
)
|
||||||
|
super().__init__(
|
||||||
|
self._default_label,
|
||||||
|
self._size_label,
|
||||||
|
self._text_label,
|
||||||
|
name=name,
|
||||||
|
id=id,
|
||||||
|
classes=classes,
|
||||||
)
|
)
|
||||||
|
|
||||||
async def on_focus(self, event: events.Focus) -> None:
|
self.styles.background = f"{next(Placeholder._COLORS)} 70%"
|
||||||
self.has_focus = True
|
|
||||||
|
|
||||||
async def on_blur(self, event: events.Blur) -> None:
|
self.variant = self.validate_variant(variant)
|
||||||
self.has_focus = False
|
# 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
|
||||||
|
|
||||||
async def on_enter(self, event: events.Enter) -> None:
|
def cycle_variant(self) -> None:
|
||||||
self.mouse_over = True
|
"""Get the next variant in the cycle."""
|
||||||
|
self.variant = next(self._variants_cycle)
|
||||||
|
|
||||||
async def on_leave(self, event: events.Leave) -> None:
|
def watch_variant(
|
||||||
self.mouse_over = False
|
self, old_variant: PlaceholderVariant, variant: PlaceholderVariant
|
||||||
|
) -> None:
|
||||||
|
self.remove_class(f"-{old_variant}")
|
||||||
|
self.add_class(f"-{variant}")
|
||||||
|
|
||||||
|
def validate_variant(self, variant: PlaceholderVariant) -> PlaceholderVariant:
|
||||||
|
"""Validate the variant to which the placeholder was set."""
|
||||||
|
if variant not in _VALID_PLACEHOLDER_VARIANTS:
|
||||||
|
raise InvalidPlaceholderVariant(
|
||||||
|
"Valid placeholder variants are "
|
||||||
|
+ f"{friendly_list(_VALID_PLACEHOLDER_VARIANTS)}"
|
||||||
|
)
|
||||||
|
return variant
|
||||||
|
|
||||||
|
def on_click(self) -> None:
|
||||||
|
"""Click handler to cycle through the placeholder variants."""
|
||||||
|
self.cycle_variant()
|
||||||
|
|
||||||
|
def on_resize(self, event: events.Resize) -> None:
|
||||||
|
"""Update the placeholder "size" variant with the new placeholder size."""
|
||||||
|
self._size_label._content = self._SIZE_RENDER_TEMPLATE.format(*self.size)
|
||||||
|
if self.variant == "size":
|
||||||
|
self._size_label.refresh(layout=True)
|
||||||
|
|||||||
71
tests/snapshot_tests/snapshot_apps/order_independence.py
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
from textual.app import App, ComposeResult
|
||||||
|
from textual.screen import Screen
|
||||||
|
from textual.widgets import Header, Footer, Label
|
||||||
|
from textual.containers import Vertical, Container
|
||||||
|
|
||||||
|
|
||||||
|
class Overlay(Container):
|
||||||
|
def compose(self) -> ComposeResult:
|
||||||
|
yield Label("This should float over the top")
|
||||||
|
|
||||||
|
|
||||||
|
class Body1(Vertical):
|
||||||
|
def compose(self) -> ComposeResult:
|
||||||
|
yield Label("My God! It's full of stars! " * 300)
|
||||||
|
|
||||||
|
|
||||||
|
class Body2(Vertical):
|
||||||
|
def compose(self) -> ComposeResult:
|
||||||
|
yield Label("I'm sorry, Dave. I'm afraid I can't do that. " * 300)
|
||||||
|
|
||||||
|
|
||||||
|
class Good(Screen):
|
||||||
|
def compose(self) -> ComposeResult:
|
||||||
|
yield Header()
|
||||||
|
yield Overlay()
|
||||||
|
yield Body1()
|
||||||
|
yield Footer()
|
||||||
|
|
||||||
|
|
||||||
|
class Bad(Screen):
|
||||||
|
def compose(self) -> ComposeResult:
|
||||||
|
yield Overlay()
|
||||||
|
yield Header()
|
||||||
|
yield Body2()
|
||||||
|
yield Footer()
|
||||||
|
|
||||||
|
|
||||||
|
class Layers(App[None]):
|
||||||
|
|
||||||
|
CSS = """
|
||||||
|
Screen {
|
||||||
|
layers: base higher;
|
||||||
|
}
|
||||||
|
|
||||||
|
Overlay {
|
||||||
|
layer: higher;
|
||||||
|
dock: top;
|
||||||
|
width: auto;
|
||||||
|
height: auto;
|
||||||
|
padding: 2;
|
||||||
|
border: solid yellow;
|
||||||
|
background: red;
|
||||||
|
color: yellow;
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
|
||||||
|
SCREENS = {"good": Good, "bad": Bad}
|
||||||
|
|
||||||
|
BINDINGS = [("t", "toggle", "Toggle Screen")]
|
||||||
|
|
||||||
|
def on_mount(self):
|
||||||
|
self.push_screen("good")
|
||||||
|
|
||||||
|
def action_toggle(self):
|
||||||
|
self.switch_screen(
|
||||||
|
"bad" if self.screen.__class__.__name__ == "Good" else "good"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
Layers().run()
|
||||||
48
tests/snapshot_tests/snapshot_apps/visibility.py
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
from textual.app import App
|
||||||
|
from textual.containers import Vertical
|
||||||
|
from textual.widgets import Static
|
||||||
|
|
||||||
|
|
||||||
|
class Visibility(App):
|
||||||
|
"""Check that visibility: hidden also makes children invisible;"""
|
||||||
|
|
||||||
|
CSS = """
|
||||||
|
Screen {
|
||||||
|
layout: horizontal;
|
||||||
|
}
|
||||||
|
Vertical {
|
||||||
|
width: 1fr;
|
||||||
|
border: solid red;
|
||||||
|
}
|
||||||
|
|
||||||
|
#container1 {
|
||||||
|
visibility: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.float {
|
||||||
|
border: solid blue;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Make a child of a hidden widget visible again */
|
||||||
|
#container1 .float {
|
||||||
|
visibility: visible;
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
|
||||||
|
def compose(self):
|
||||||
|
|
||||||
|
yield Vertical(
|
||||||
|
Static("foo"),
|
||||||
|
Static("float", classes="float"),
|
||||||
|
id="container1",
|
||||||
|
)
|
||||||
|
yield Vertical(
|
||||||
|
Static("bar"),
|
||||||
|
Static("float", classes="float"),
|
||||||
|
id="container2",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
app = Visibility()
|
||||||
|
app.run()
|
||||||
@@ -2,6 +2,8 @@ from pathlib import Path
|
|||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
|
from textual.widgets import Placeholder
|
||||||
|
|
||||||
# These paths should be relative to THIS directory.
|
# These paths should be relative to THIS directory.
|
||||||
WIDGET_EXAMPLES_DIR = Path("../../docs/examples/widgets")
|
WIDGET_EXAMPLES_DIR = Path("../../docs/examples/widgets")
|
||||||
LAYOUT_EXAMPLES_DIR = Path("../../docs/examples/guide/layout")
|
LAYOUT_EXAMPLES_DIR = Path("../../docs/examples/guide/layout")
|
||||||
@@ -79,6 +81,12 @@ def test_buttons_render(snap_compare):
|
|||||||
assert snap_compare(WIDGET_EXAMPLES_DIR / "button.py", press=["tab"])
|
assert snap_compare(WIDGET_EXAMPLES_DIR / "button.py", press=["tab"])
|
||||||
|
|
||||||
|
|
||||||
|
def test_placeholder_render(snap_compare):
|
||||||
|
# Testing the rendering of the multiple placeholder variants and labels.
|
||||||
|
Placeholder.reset_color_cycle()
|
||||||
|
assert snap_compare(WIDGET_EXAMPLES_DIR / "placeholder.py")
|
||||||
|
|
||||||
|
|
||||||
def test_datatable_render(snap_compare):
|
def test_datatable_render(snap_compare):
|
||||||
press = ["tab", "down", "down", "right", "up", "left"]
|
press = ["tab", "down", "down", "right", "up", "left"]
|
||||||
assert snap_compare(WIDGET_EXAMPLES_DIR / "data_table.py", press=press)
|
assert snap_compare(WIDGET_EXAMPLES_DIR / "data_table.py", press=press)
|
||||||
@@ -100,6 +108,10 @@ def test_fr_units(snap_compare):
|
|||||||
assert snap_compare("snapshot_apps/fr_units.py")
|
assert snap_compare("snapshot_apps/fr_units.py")
|
||||||
|
|
||||||
|
|
||||||
|
def test_visibility(snap_compare):
|
||||||
|
assert snap_compare("snapshot_apps/visibility.py")
|
||||||
|
|
||||||
|
|
||||||
def test_tree_example(snap_compare):
|
def test_tree_example(snap_compare):
|
||||||
assert snap_compare(WIDGET_EXAMPLES_DIR / "tree.py")
|
assert snap_compare(WIDGET_EXAMPLES_DIR / "tree.py")
|
||||||
|
|
||||||
@@ -126,6 +138,16 @@ def test_multiple_css(snap_compare):
|
|||||||
assert snap_compare("snapshot_apps/multiple_css/multiple_css.py")
|
assert snap_compare("snapshot_apps/multiple_css/multiple_css.py")
|
||||||
|
|
||||||
|
|
||||||
|
def test_order_independence(snap_compare):
|
||||||
|
# Interaction between multiple CSS files and app-level/classvar CSS
|
||||||
|
assert snap_compare("snapshot_apps/order_independence.py")
|
||||||
|
|
||||||
|
|
||||||
|
def test_order_independence_toggle(snap_compare):
|
||||||
|
# Interaction between multiple CSS files and app-level/classvar CSS
|
||||||
|
assert snap_compare("snapshot_apps/order_independence.py", press="t,_")
|
||||||
|
|
||||||
|
|
||||||
# --- Other ---
|
# --- Other ---
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
15
tests/test_placeholder.py
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import pytest
|
||||||
|
|
||||||
|
from textual.widgets import Placeholder
|
||||||
|
from textual.widgets._placeholder import InvalidPlaceholderVariant
|
||||||
|
|
||||||
|
|
||||||
|
def test_invalid_placeholder_variant():
|
||||||
|
with pytest.raises(InvalidPlaceholderVariant):
|
||||||
|
Placeholder(variant="this is clearly not a valid variant!")
|
||||||
|
|
||||||
|
|
||||||
|
def test_invalid_reactive_variant_change():
|
||||||
|
p = Placeholder()
|
||||||
|
with pytest.raises(InvalidPlaceholderVariant):
|
||||||
|
p.variant = "this is clearly not a valid variant!"
|
||||||
@@ -8,12 +8,15 @@ def test_query():
|
|||||||
class View(Widget):
|
class View(Widget):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
class View2(View):
|
||||||
|
pass
|
||||||
|
|
||||||
class App(Widget):
|
class App(Widget):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
app = App()
|
app = App()
|
||||||
main_view = View(id="main")
|
main_view = View(id="main")
|
||||||
help_view = View(id="help")
|
help_view = View2(id="help")
|
||||||
app._add_child(main_view)
|
app._add_child(main_view)
|
||||||
app._add_child(help_view)
|
app._add_child(help_view)
|
||||||
|
|
||||||
@@ -53,6 +56,7 @@ def test_query():
|
|||||||
assert list(app.query("App")) == [app]
|
assert list(app.query("App")) == [app]
|
||||||
assert list(app.query("#main")) == [main_view]
|
assert list(app.query("#main")) == [main_view]
|
||||||
assert list(app.query("View#main")) == [main_view]
|
assert list(app.query("View#main")) == [main_view]
|
||||||
|
assert list(app.query("View2#help")) == [help_view]
|
||||||
assert list(app.query("#widget1")) == [widget1]
|
assert list(app.query("#widget1")) == [widget1]
|
||||||
assert list(app.query("#Widget1")) == [] # Note case.
|
assert list(app.query("#Widget1")) == [] # Note case.
|
||||||
assert list(app.query("#widget2")) == [widget2]
|
assert list(app.query("#widget2")) == [widget2]
|
||||||
|
|||||||