Merge branch 'main' of https://github.com/Textualize/Textual into placeholder
21
CHANGELOG.md
@@ -1,12 +1,28 @@
|
||||
# Change Log
|
||||
|
||||
|
||||
All notable changes to this project will be documented in this file.
|
||||
|
||||
The format is based on [Keep a Changelog](http://keepachangelog.com/)
|
||||
and this project adheres to [Semantic Versioning](http://semver.org/).
|
||||
|
||||
## [0.5.0] - Unreleased
|
||||
|
||||
## [0.6.0] - Unreleased
|
||||
|
||||
### Added
|
||||
|
||||
- Added "inherited bindings" -- BINDINGS classvar will be merged with base classes, unless inherit_bindings is set to False
|
||||
- Added `Tree` widget which replaces `TreeControl`.
|
||||
|
||||
### Changed
|
||||
|
||||
- Rebuilt `DirectoryTree` with new `Tree` control.
|
||||
- The default `width` of a `Label` is now `auto`.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Type selectors can now contain numbers https://github.com/Textualize/textual/issues/1253
|
||||
|
||||
## [0.5.0] - 2022-11-20
|
||||
|
||||
### Added
|
||||
|
||||
@@ -46,6 +62,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
|
||||
- Fixed containers with transparent background not showing borders https://github.com/Textualize/textual/issues/1175
|
||||
- Fixed auto-width in horizontal containers https://github.com/Textualize/textual/pull/1155
|
||||
- Fixed Input cursor invisible when placeholder empty https://github.com/Textualize/textual/pull/1202
|
||||
- Fixed deadlock when removing widgets from the App https://github.com/Textualize/textual/pull/1219
|
||||
|
||||
## [0.4.0] - 2022-11-08
|
||||
|
||||
|
||||
1
docs/api/directory_tree.md
Normal file
@@ -0,0 +1 @@
|
||||
::: textual.widgets.DirectoryTree
|
||||
1
docs/api/tree.md
Normal file
@@ -0,0 +1 @@
|
||||
::: textual.widgets.Tree
|
||||
1
docs/api/tree_node.md
Normal file
@@ -0,0 +1 @@
|
||||
::: textual.widgets.TreeNode
|
||||
@@ -2,3 +2,15 @@ willmcgugan:
|
||||
name: Will McGugan
|
||||
description: CEO / code-monkey
|
||||
avatar: https://github.com/willmcgugan.png
|
||||
darrenburns:
|
||||
name: Darren Burns
|
||||
description: Code-monkey
|
||||
avatar: https://github.com/darrenburns.png
|
||||
davep:
|
||||
name: Dave Pearson
|
||||
description: Code-monkey
|
||||
avatar: https://github.com/davep.png
|
||||
rodrigo:
|
||||
name: Rodrigo Girão Serrão
|
||||
description: Code-monkey
|
||||
avatar: https://github.com/rodrigogiraoserrao.png
|
||||
|
||||
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 |
165
docs/blog/images/placeholder-example.svg
Normal file
|
After Width: | Height: | Size: 30 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...
|
||||
233
docs/blog/posts/placeholder-pr.md
Normal file
@@ -0,0 +1,233 @@
|
||||
---
|
||||
draft: false
|
||||
date: 2022-11-22
|
||||
categories:
|
||||
- DevLog
|
||||
authors:
|
||||
- rodrigo
|
||||
---
|
||||
|
||||
|
||||
# What I learned from my first non-trivial PR
|
||||
|
||||
<div>
|
||||
--8<-- "docs/blog/images/placeholder-example.svg"
|
||||
</div>
|
||||
|
||||
It's 8:59 am and, by my Portuguese standards, it is freezing cold outside: 5 or 6 degrees Celsius.
|
||||
It is my second day at Textualize and I just got into the office.
|
||||
I undress my many layers of clothing to protect me from the Scottish cold and I sit down in my improvised corner of the Textualize office.
|
||||
As I sit down, I turn myself in my chair to face my boss and colleagues to ask “So, what should I do today?”.
|
||||
I was not expecting Will's answer, but the challenge excited me:
|
||||
|
||||
<!-- more -->
|
||||
|
||||
> “I thought I'll just throw you in the deep end and have you write some code.”
|
||||
|
||||
What happened next was that I spent two days [working on PR #1229](https://github.com/Textualize/textual/pull/1229) to add a new widget to the [Textual](https://github.com/Textualize/textual) code base.
|
||||
At the time of writing, the pull request has not been merged yet.
|
||||
Well, to be honest with you, it hasn't even been reviewed by anyone...
|
||||
But that won't stop me from blogging about some of the things I learned while creating this PR.
|
||||
|
||||
|
||||
## The placeholder widget
|
||||
|
||||
This PR adds a widget called `Placeholder` to Textual.
|
||||
As per the documentation, this widget “is meant to have no complex functionality.
|
||||
Use the placeholder widget when studying the layout of your app before having to develop your custom widgets.”
|
||||
|
||||
The point of the placeholder widget is that you can focus on building the layout of your app without having to have all of your (custom) widgets ready.
|
||||
The placeholder widget also displays a couple of useful pieces of information to help you work out the layout of your app, namely the ID of the widget itself (or a custom label, if you provide one) and the width and height of the widget.
|
||||
|
||||
As an example of usage of the placeholder widget, you can refer to the screenshot at the top of this blog post, which I included below so you don't have to scroll up:
|
||||
|
||||
<div>
|
||||
--8<-- "docs/blog/images/placeholder-example.svg"
|
||||
</div>
|
||||
|
||||
The top left and top right widgets have custom labels.
|
||||
Immediately under the top right placeholder, you can see some placeholders identified as `#p3`, `#p4`, and `#p5`.
|
||||
Those are the IDs of the respective placeholders.
|
||||
Then, rows 2 and 3 contain some placeholders that show their respective size and some placeholders that just contain some text.
|
||||
|
||||
|
||||
## Bootstrapping the code for the widget
|
||||
|
||||
So, how does a code monkey start working on a non-trivial PR within 24 hours of joining a company?
|
||||
The answer is simple: just copy and paste code!
|
||||
But instead of copying and pasting from Stack Overflow, I decided to copy and paste from the internal code base.
|
||||
|
||||
My task was to create a new widget, so I thought it would be a good idea to take a look at the implementation of other Textual widgets.
|
||||
For some reason I cannot seem to recall, I decided to take a look at the implementation of the button widget that you can find in [_button.py](https://github.com/Textualize/textual/blob/main/src/textual/widgets/_button.py).
|
||||
By looking at how the button widget is implemented, I could immediately learn a few useful things about what I needed to do and some other things about how Textual works.
|
||||
|
||||
For example, a widget can have a class attribute called `DEFAULT_CSS` that specifies the default CSS for that widget.
|
||||
I learned this just from staring at the code for the button widget.
|
||||
|
||||
Studying the code base will also reveal the standards that are in place.
|
||||
For example, I learned that for a widget with variants (like the button with its “success” and “error” variants), the widget gets a CSS class with the name of the variant prefixed by a dash.
|
||||
You can learn this by looking at the method `Button.watch_variant`:
|
||||
|
||||
```py
|
||||
class Button(Static, can_focus=True):
|
||||
# ...
|
||||
|
||||
def watch_variant(self, old_variant: str, variant: str):
|
||||
self.remove_class(f"-{old_variant}")
|
||||
self.add_class(f"-{variant}")
|
||||
```
|
||||
|
||||
In short, looking at code and files that are related to the things you need to do is a great way to get information about things you didn't even know you needed.
|
||||
|
||||
|
||||
## Handling the placeholder variant
|
||||
|
||||
A button widget can have a different variant, which is mostly used by Textual to determine the CSS that should apply to the given button.
|
||||
For the placeholder widget, we want the variant to determine what information the placeholder shows.
|
||||
The [original GitHub issue](https://github.com/Textualize/textual/issues/1200) mentions 5 variants for the placeholder:
|
||||
|
||||
- a variant that just shows a label or the placeholder ID;
|
||||
- a variant that shows the size and location of the placeholder;
|
||||
- a variant that shows the state of the placeholder (does it have focus? is the mouse over it?);
|
||||
- a variant that shows the CSS that is applied to the placeholder itself; and
|
||||
- a variant that shows some text inside the placeholder.
|
||||
|
||||
The variant can be assigned when the placeholder is first instantiated, for example, `Placeholder("css")` would create a placeholder that shows its own CSS.
|
||||
However, we also want to have an `on_click` handler that cycles through all the possible variants.
|
||||
I was getting ready to reinvent the wheel when I remembered that the standard module [`itertools`](https://docs.python.org/3/library/itertools) has a lovely tool that does exactly what I needed!
|
||||
Thus, all I needed to do was create a new `cycle` through the variants each time a placeholder is created and then grab the next variant whenever the placeholder is clicked:
|
||||
|
||||
```py
|
||||
class Placeholder(Static):
|
||||
def __init__(
|
||||
self,
|
||||
variant: PlaceholderVariant = "default",
|
||||
*,
|
||||
label: str | None = None,
|
||||
name: str | None = None,
|
||||
id: str | None = None,
|
||||
classes: str | None = None,
|
||||
) -> None:
|
||||
# ...
|
||||
|
||||
self.variant = self.validate_variant(variant)
|
||||
# Set a cycle through the variants with the correct starting point.
|
||||
self._variants_cycle = cycle(_VALID_PLACEHOLDER_VARIANTS_ORDERED)
|
||||
while next(self._variants_cycle) != self.variant:
|
||||
pass
|
||||
|
||||
def on_click(self) -> None:
|
||||
"""Click handler to cycle through the placeholder variants."""
|
||||
self.cycle_variant()
|
||||
|
||||
def cycle_variant(self) -> None:
|
||||
"""Get the next variant in the cycle."""
|
||||
self.variant = next(self._variants_cycle)
|
||||
```
|
||||
|
||||
I am just happy that I had the insight to add this little `while` loop when a placeholder is instantiated:
|
||||
|
||||
```py
|
||||
from itertools import cycle
|
||||
# ...
|
||||
class Placeholder(Static):
|
||||
# ...
|
||||
def __init__(...):
|
||||
# ...
|
||||
self._variants_cycle = cycle(_VALID_PLACEHOLDER_VARIANTS_ORDERED)
|
||||
while next(self._variants_cycle) != self.variant:
|
||||
pass
|
||||
```
|
||||
|
||||
Can you see what would be wrong if this loop wasn't there?
|
||||
|
||||
|
||||
## Updating the render of the placeholder on variant change
|
||||
|
||||
If the variant of the placeholder is supposed to determine what information the placeholder shows, then that information must be updated every time the variant of the placeholder changes.
|
||||
Thankfully, Textual has reactive attributes and watcher methods, so all I needed to do was...
|
||||
Defer the problem to another method:
|
||||
|
||||
```py
|
||||
class Placeholder(Static):
|
||||
# ...
|
||||
variant = reactive("default")
|
||||
# ...
|
||||
def watch_variant(
|
||||
self, old_variant: PlaceholderVariant, variant: PlaceholderVariant
|
||||
) -> None:
|
||||
self.validate_variant(variant)
|
||||
self.remove_class(f"-{old_variant}")
|
||||
self.add_class(f"-{variant}")
|
||||
self.call_variant_update() # <-- let this method do the heavy lifting!
|
||||
```
|
||||
|
||||
Doing this properly required some thinking.
|
||||
Not that the current proposed solution is the best possible, but I did think of worse alternatives while I was thinking how to tackle this.
|
||||
I wasn't entirely sure how I would manage the variant-dependant rendering because I am not a fan of huge conditional statements that look like switch statements:
|
||||
|
||||
```py
|
||||
if variant == "default":
|
||||
# render the default placeholder
|
||||
elif variant == "size":
|
||||
# render the placeholder with its size
|
||||
elif variant == "state":
|
||||
# render the state of the placeholder
|
||||
elif variant == "css":
|
||||
# render the placeholder with its CSS rules
|
||||
elif variant == "text":
|
||||
# render the placeholder with some text inside
|
||||
```
|
||||
|
||||
However, I am a fan of using the built-in `getattr` and I thought of creating a rendering method for each different variant.
|
||||
Then, all I needed to do was make sure the variant is part of the name of the method so that I can programmatically determine the name of the method that I need to call.
|
||||
This means that the method `Placeholder.call_variant_update` is just this:
|
||||
|
||||
```py
|
||||
class Placeholder(Static):
|
||||
# ...
|
||||
def call_variant_update(self) -> None:
|
||||
"""Calls the appropriate method to update the render of the placeholder."""
|
||||
update_variant_method = getattr(self, f"_update_{self.variant}_variant")
|
||||
update_variant_method()
|
||||
```
|
||||
|
||||
If `self.variant` is, say, `"size"`, then `update_variant_method` refers to `_update_size_variant`:
|
||||
|
||||
```py
|
||||
class Placeholder(Static):
|
||||
# ...
|
||||
def _update_size_variant(self) -> None:
|
||||
"""Update the placeholder with the size of the placeholder."""
|
||||
width, height = self.size
|
||||
self._placeholder_label.update(f"[b]{width} x {height}[/b]")
|
||||
```
|
||||
|
||||
This variant `"size"` also interacts with resizing events, so we have to watch out for those:
|
||||
|
||||
```py
|
||||
class Placeholder(Static):
|
||||
# ...
|
||||
def on_resize(self, event: events.Resize) -> None:
|
||||
"""Update the placeholder "size" variant with the new placeholder size."""
|
||||
if self.variant == "size":
|
||||
self._update_size_variant()
|
||||
```
|
||||
|
||||
|
||||
## Deleting code is a (hurtful) blessing
|
||||
|
||||
To conclude this blog post, let me muse about the fact that the original issue mentioned five placeholder variants and that my PR only includes two and a half.
|
||||
|
||||
After careful consideration and after coming up with the `getattr` mechanism to update the display of the placeholder according to the active variant, I started showing the “final” product to Will and my other colleagues.
|
||||
Eventually, we ended up getting rid of the variant for CSS and the variant that shows the placeholder state.
|
||||
This means that I had to **delete part of my code** even before it saw the light of day.
|
||||
|
||||
On the one hand, deleting those chunks of code made me a bit sad.
|
||||
After all, I had spent quite some time thinking about how to best implement that functionality!
|
||||
But then, it was time to write documentation and tests, and I verified that the **best code** is the code that you don't even write!
|
||||
The code you don't write is guaranteed to have zero bugs and it also does not need any documentation whatsoever!
|
||||
|
||||
So, it was a shame that some lines of code I poured my heart and keyboard into did not get merged into the Textual code base.
|
||||
On the other hand, I am quite grateful that I won't have to fix the bugs that will certainly reveal themselves in a couple of weeks or months from now.
|
||||
Heck, the code hasn't been merged yet and just by writing this blog post I noticed a couple of tweaks that were missing!
|
||||
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:
|
||||
133
docs/blog/posts/steal-this-code.md
Normal file
@@ -0,0 +1,133 @@
|
||||
---
|
||||
draft: false
|
||||
date: 2022-11-20
|
||||
categories:
|
||||
- DevLog
|
||||
authors:
|
||||
- willmcgugan
|
||||
---
|
||||
|
||||
# Stealing Open Source code from Textual
|
||||
|
||||
I would like to talk about a serious issue in the Free and Open Source software world. Stealing code. You wouldn't steal a car would you?
|
||||
|
||||
<div class="video-wrapper">
|
||||
<iframe width="auto" src="https://www.youtube.com/embed/HmZm8vNHBSU" title="YouTube video player" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture" allowfullscreen></iframe>
|
||||
</div>
|
||||
|
||||
But you *should* steal code from Open Source projects. Respect the license (you may need to give attribution) but stealing code is not like stealing a car. If I steal your car, I have deprived you of a car. If you steal my open source code, I haven't lost anything.
|
||||
|
||||
!!! warning
|
||||
|
||||
I'm not advocating for *piracy*. Open source code gives you explicit permission to use it.
|
||||
|
||||
|
||||
From my point of view, I feel like code has greater value when it has been copied / modified in another project.
|
||||
|
||||
There are a number of files and modules in [Textual](https://github.com/Textualize/textual) that could either be lifted as is, or wouldn't require much work to extract. I'd like to cover a few here. You might find them useful in your next project.
|
||||
|
||||
<!-- more -->
|
||||
|
||||
## Loop first / last
|
||||
|
||||
How often do you find yourself looping over an iterable and needing to know if an element is the first and/or last in the sequence? It's a simple thing, but I find myself nedding this a *lot*, so I wrote some helpers in [_loop.py](https://github.com/Textualize/textual/blob/main/src/textual/_loop.py).
|
||||
|
||||
I'm sure there is an equivalent implementation on PyPI, but steal this if you need it.
|
||||
|
||||
Here's an example of use:
|
||||
|
||||
```python
|
||||
for last, (y, line) in loop_last(enumerate(self.lines, self.region.y)):
|
||||
yield move_to(x, y)
|
||||
yield from line
|
||||
if not last:
|
||||
yield new_line
|
||||
```
|
||||
|
||||
## LRU Cache
|
||||
|
||||
Python's [lru_cache](https://docs.python.org/3/library/functools.html#functools.lru_cache) can be the one-liner that makes your code orders of magnitude faster. But it has a few gotchas.
|
||||
|
||||
The main issue is managing the lifetime of these caches. The decorator keeps a single global cache, which will keep a reference to every object in the function call. On an instance method that means you keep references to `self` for the lifetime of your app.
|
||||
|
||||
For a more flexibility you can use the [LRUCache](https://github.com/Textualize/textual/blob/main/src/textual/_cache.py) implementation from Textual. This uses essentially the same algorithm as the stdlib decorator, but it is implemented as a container.
|
||||
|
||||
Here's a quick example of its use. It works like a dictionary until you reach a maximum size. After that, new elements will kick out the element that was used least recently.
|
||||
|
||||
```python
|
||||
>>> from textual._cache import LRUCache
|
||||
>>> cache = LRUCache(maxsize=3)
|
||||
>>> cache["foo"] = 1
|
||||
>>> cache["bar"] = 2
|
||||
>>> cache["baz"] = 3
|
||||
>>> dict(cache)
|
||||
{'foo': 1, 'bar': 2, 'baz': 3}
|
||||
>>> cache["egg"] = 4
|
||||
>>> dict(cache)
|
||||
{'bar': 2, 'baz': 3, 'egg': 4}
|
||||
```
|
||||
|
||||
In Textual, we use a [LRUCache](https://github.com/Textualize/textual/search?q=LRUCache) to store the results of rendering content to the terminal. For example, in a [datatable](https://twitter.com/search?q=%23textualdatatable&src=typed_query&f=live) it is too costly to render everything up front. So Textual renders only the lines that are currently visible on the "screen". The cache ensures that scrolling only needs to render the newly exposed lines, and lines that haven't been displayed in a while are discarded to save memory.
|
||||
|
||||
|
||||
## Color
|
||||
|
||||
Textual has a [Color](https://github.com/Textualize/textual/blob/main/src/textual/color.py) class which could be extracted in to a module of its own.
|
||||
|
||||
The Color class can parse colors encoded in a variety of HTML and CSS formats. Color object support a variety of methods and operators you can use to manipulate colors, in a fairly natural way.
|
||||
|
||||
Here's some examples in the REPL.
|
||||
|
||||
|
||||
```python
|
||||
>>> from textual.color import Color
|
||||
>>> color = Color.parse("lime")
|
||||
>>> color
|
||||
Color(0, 255, 0, a=1.0)
|
||||
>>> color.darken(0.8)
|
||||
Color(0, 45, 0, a=1.0)
|
||||
>>> color + Color.parse("red").with_alpha(0.1)
|
||||
Color(25, 229, 0, a=1.0)
|
||||
>>> color = Color.parse("#12a30a")
|
||||
>>> color
|
||||
Color(18, 163, 10, a=1.0)
|
||||
>>> color.css
|
||||
'rgb(18,163,10)'
|
||||
>>> color.hex
|
||||
'#12A30A'
|
||||
>>> color.monochrome
|
||||
Color(121, 121, 121, a=1.0)
|
||||
>>> color.monochrome.hex
|
||||
'#797979'
|
||||
>>> color.hsl
|
||||
HSL(h=0.3246187363834423, s=0.8843930635838151, l=0.33921568627450976)
|
||||
>>>
|
||||
```
|
||||
|
||||
There are some very good color libraries in PyPI, which you should also consider using. But Textual's Color class is lean and performant, with no C dependencies.
|
||||
|
||||
## Geometry
|
||||
|
||||
This may be my favorite module in Textual: [geometry.py](https://github.com/Textualize/textual/blob/main/src/textual/geometry.py).
|
||||
|
||||
The geometry module contains a number of classes responsible for storing and manipulating 2D geometry. There is an `Offset` class which is a two dimensional point. A `Region` class which is a rectangular region defined by a coordinate and dimensions. There is a `Spacing` class which defines additional space around a region. And there is a `Size` class which defines the dimensions of an area by its width and height.
|
||||
|
||||
These objects are used by Textual's layout engine and compositor, which makes them the oldest and most thoroughly tested part of the project.
|
||||
|
||||
There's a lot going on in this module, but the docstrings are quite detailed and have unicode art like this to help explain things.
|
||||
|
||||
```
|
||||
cut_x ↓
|
||||
┌────────┐ ┌───┐
|
||||
│ │ │ │
|
||||
│ 0 │ │ 1 │
|
||||
│ │ │ │
|
||||
cut_y → └────────┘ └───┘
|
||||
┌────────┐ ┌───┐
|
||||
│ 2 │ │ 3 │
|
||||
└────────┘ └───┘
|
||||
```
|
||||
|
||||
## You should steal our code
|
||||
|
||||
There is a lot going on in the [Textual Repository](https://github.com/Textualize/textual). Including a CSS parser, renderer, layout and compositing engine. All written in pure Python. Steal it with my blessing.
|
||||
@@ -48,7 +48,7 @@ class Hello(Static):
|
||||
|
||||
|
||||
class CustomApp(App):
|
||||
CSS_PATH = "hello03.css"
|
||||
CSS_PATH = "hello04.css"
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
yield Hello()
|
||||
|
||||
@@ -32,13 +32,13 @@ class TimeDisplay(Static):
|
||||
self.start_time = monotonic()
|
||||
self.update_timer.resume()
|
||||
|
||||
def stop(self):
|
||||
def stop(self) -> None:
|
||||
"""Method to stop the time display updating."""
|
||||
self.update_timer.pause()
|
||||
self.total += monotonic() - self.start_time
|
||||
self.time = self.total
|
||||
|
||||
def reset(self):
|
||||
def reset(self) -> None:
|
||||
"""Method to reset the time display to zero."""
|
||||
self.total = 0
|
||||
self.time = 0
|
||||
|
||||
12
docs/examples/widgets/directory_tree.py
Normal file
@@ -0,0 +1,12 @@
|
||||
from textual.app import App, ComposeResult
|
||||
from textual.widgets import DirectoryTree
|
||||
|
||||
|
||||
class DirectoryTreeApp(App):
|
||||
def compose(self) -> ComposeResult:
|
||||
yield DirectoryTree("./")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
app = DirectoryTreeApp()
|
||||
app.run()
|
||||
18
docs/examples/widgets/tree.py
Normal file
@@ -0,0 +1,18 @@
|
||||
from textual.app import App, ComposeResult
|
||||
from textual.widgets import Tree
|
||||
|
||||
|
||||
class TreeApp(App):
|
||||
def compose(self) -> ComposeResult:
|
||||
tree: Tree = Tree("Dune")
|
||||
tree.root.expand()
|
||||
characters = tree.root.add("Characters", expand=True)
|
||||
characters.add_leaf("Paul")
|
||||
characters.add_leaf("Jessica")
|
||||
characters.add_leaf("Channi")
|
||||
yield tree
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
app = TreeApp()
|
||||
app.run()
|
||||
@@ -68,7 +68,7 @@ Let's look at a trivial Textual app.
|
||||
|
||||
=== "Output"
|
||||
|
||||
```{.textual path="docs/examples/guide/dom1.py"}
|
||||
```{.textual path="docs/examples/guide/dom1.py" press="_"}
|
||||
```
|
||||
|
||||
This example creates an instance of `ExampleApp`, which will implicitly create a `Screen` object. In DOM terms, the `Screen` is a _child_ of `ExampleApp`.
|
||||
|
||||
@@ -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:
|
||||
|
||||
```{.textual path="docs/examples/app/simple02.py"}
|
||||
```{.textual path="docs/examples/app/simple02.py" press="_"}
|
||||
```
|
||||
|
||||
When you call [App.run()][textual.app.App.run] Textual puts the terminal in to a special state called *application mode*. When in application mode the terminal will no longer echo what you type. Textual will take over responding to user input (keyboard and mouse) and will update the visible portion of the terminal (i.e. the *screen*).
|
||||
@@ -56,7 +56,7 @@ Another such event is the *key* event which is sent when the user presses a key.
|
||||
|
||||
The `on_mount` handler sets the `self.screen.styles.background` attribute to `"darkblue"` which (as you can probably guess) turns the background blue. Since the mount event is sent immediately after entering application mode, you will see a blue screen when you run this code.
|
||||
|
||||
```{.textual path="docs/examples/app/event01.py" hl_lines="23-25"}
|
||||
```{.textual path="docs/examples/app/event01.py" hl_lines="23-25" press="_"}
|
||||
```
|
||||
|
||||
The key event handler (`on_key`) has an `event` parameter which will receive a [Key][textual.events.Key] instance. Every event has an associated event object which will be passed to the handler method if it is present in the method's parameter list.
|
||||
|
||||
@@ -217,7 +217,7 @@ The following example uses a computed attribute. It displays three inputs for ea
|
||||
```
|
||||
|
||||
1. Combines color components in to a Color object.
|
||||
2. The compute method is called when the _result_ of `compute_color` changes.
|
||||
2. The watch method is called when the _result_ of `compute_color` changes.
|
||||
|
||||
=== "computed01.css"
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@ The first line sets the [background](../styles/background.md) style to `"darkblu
|
||||
|
||||
The second line sets [border](../styles/border.md) to a tuple of `("heavy", "white")` which tells Textual to draw a white border with a style of `"heavy"`. Running this code will show the following:
|
||||
|
||||
```{.textual path="docs/examples/guide/styles/screen.py"}
|
||||
```{.textual path="docs/examples/guide/styles/screen.py" press="_"}
|
||||
```
|
||||
|
||||
## Styling widgets
|
||||
@@ -48,9 +48,9 @@ Note how the combined height of the widget is three rows in the terminal. This i
|
||||
|
||||
## Colors
|
||||
|
||||
There are a number of style attributes which accept colors. The most commonly used are [color](../styles/color.md) which sets the default color of text on a widget, and [background](..styles/background/md) which sets the background color (beneath the text).
|
||||
There are a number of style attributes which accept colors. The most commonly used are [color](../styles/color.md) which sets the default color of text on a widget, and [background](../styles/background.md) which sets the background color (beneath the text).
|
||||
|
||||
You can set a color value to one of a number of pre-defined color constants, such as `"crimson"`, `"lime"`, and `"palegreen"`. You can find a full list in the [Color reference](../reference/color.md#textual.color--named-colors).
|
||||
You can set a color value to one of a number of pre-defined color constants, such as `"crimson"`, `"lime"`, and `"palegreen"`. You can find a full list in the [Color API](../api/color.md#textual.color--named-colors).
|
||||
|
||||
Here's how you would set the screen background to lime:
|
||||
|
||||
|
||||
@@ -39,7 +39,7 @@ height: 10;
|
||||
height: 50%;
|
||||
|
||||
/* Automatic height */
|
||||
width: auto
|
||||
height: auto
|
||||
```
|
||||
|
||||
## Python
|
||||
|
||||
@@ -24,3 +24,21 @@ body[data-md-color-primary="black"] .excalidraw svg rect {
|
||||
.excalidraw {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.video-wrapper {
|
||||
position: relative;
|
||||
display: block;
|
||||
height: 0;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
padding-bottom: 56.25%;
|
||||
}
|
||||
.video-wrapper > iframe {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
43
docs/widgets/directory_tree.md
Normal file
@@ -0,0 +1,43 @@
|
||||
# DirectoryTree
|
||||
|
||||
A tree control to navigate the contents of your filesystem.
|
||||
|
||||
- [x] Focusable
|
||||
- [ ] Container
|
||||
|
||||
|
||||
## Example
|
||||
|
||||
The example below creates a simple tree to navigate the current working directory.
|
||||
|
||||
```python
|
||||
--8<-- "docs/examples/widgets/directory_tree.py"
|
||||
```
|
||||
|
||||
## Messages
|
||||
|
||||
### FileSelected
|
||||
|
||||
The `DirectoryTree.FileSelected` message is sent when the user selects a file in the tree
|
||||
|
||||
- [x] Bubbles
|
||||
|
||||
#### Attributes
|
||||
|
||||
| attribute | type | purpose |
|
||||
| --------- | ----- | ----------------- |
|
||||
| `path` | `str` | Path of the file. |
|
||||
|
||||
## Reactive Attributes
|
||||
|
||||
| Name | Type | Default | Description |
|
||||
| ------------- | ------ | ------- | ----------------------------------------------- |
|
||||
| `show_root` | `bool` | `True` | Show the root node. |
|
||||
| `show_guides` | `bool` | `True` | Show guide lines between levels. |
|
||||
| `guide_depth` | `int` | `4` | Amount of indentation between parent and child. |
|
||||
|
||||
|
||||
## See Also
|
||||
|
||||
* [Tree][textual.widgets.DirectoryTree] code reference
|
||||
* [Tree][textual.widgets.Tree] code reference
|
||||
80
docs/widgets/tree.md
Normal file
@@ -0,0 +1,80 @@
|
||||
# Tree
|
||||
|
||||
A tree control widget.
|
||||
|
||||
- [x] Focusable
|
||||
- [ ] Container
|
||||
|
||||
|
||||
## Example
|
||||
|
||||
The example below creates a simple tree.
|
||||
|
||||
=== "Output"
|
||||
|
||||
```{.textual path="docs/examples/widgets/tree.py"}
|
||||
```
|
||||
|
||||
=== "tree.py"
|
||||
|
||||
```python
|
||||
--8<-- "docs/examples/widgets/tree.py"
|
||||
```
|
||||
|
||||
A each tree widget has a "root" attribute which is an instance of a [TreeNode][textual.widgets.TreeNode]. Call [add()][textual.widgets.TreeNode.add] or [add_leaf()][textual.widgets.TreeNode.add_leaf] to add new nodes underneath the root. Both these methods return a TreeNode for the child, so you can add more levels.
|
||||
|
||||
|
||||
## Reactive Attributes
|
||||
|
||||
| Name | Type | Default | Description |
|
||||
| ------------- | ------ | ------- | ----------------------------------------------- |
|
||||
| `show_root` | `bool` | `True` | Show the root node. |
|
||||
| `show_guides` | `bool` | `True` | Show guide lines between levels. |
|
||||
| `guide_depth` | `int` | `4` | Amount of indentation between parent and child. |
|
||||
|
||||
|
||||
|
||||
## Messages
|
||||
|
||||
### NodeSelected
|
||||
|
||||
The `Tree.NodeSelected` message is sent when the user selects a tree node.
|
||||
|
||||
|
||||
#### Attributes
|
||||
|
||||
| attribute | type | purpose |
|
||||
| --------- | ------------------------------------ | -------------- |
|
||||
| `node` | [TreeNode][textual.widgets.TreeNode] | Selected node. |
|
||||
|
||||
|
||||
### NodeExpanded
|
||||
|
||||
The `Tree.NodeExpanded` message is sent when the user expands a node in the tree.
|
||||
|
||||
#### Attributes
|
||||
|
||||
| attribute | type | purpose |
|
||||
| --------- | ------------------------------------ | -------------- |
|
||||
| `node` | [TreeNode][textual.widgets.TreeNode] | Expanded node. |
|
||||
|
||||
|
||||
### NodeCollapsed
|
||||
|
||||
|
||||
The `Tree.NodeCollapsed` message is sent when the user expands a node in the tree.
|
||||
|
||||
|
||||
#### Attributes
|
||||
|
||||
| attribute | type | purpose |
|
||||
| --------- | ------------------------------------ | --------------- |
|
||||
| `node` | [TreeNode][textual.widgets.TreeNode] | Collapsed node. |
|
||||
|
||||
|
||||
|
||||
|
||||
## See Also
|
||||
|
||||
* [Tree][textual.widgets.Tree] code reference
|
||||
* [TreeNode][textual.widgets.TreeNode] code reference
|
||||
@@ -1 +0,0 @@
|
||||
# TreeControl
|
||||
@@ -5,20 +5,17 @@ Screen {
|
||||
#tree-view {
|
||||
display: none;
|
||||
scrollbar-gutter: stable;
|
||||
overflow: auto;
|
||||
width: auto;
|
||||
height: 100%;
|
||||
dock: left;
|
||||
}
|
||||
|
||||
CodeBrowser.-show-tree #tree-view {
|
||||
display: block;
|
||||
dock: left;
|
||||
height: 100%;
|
||||
max-width: 50%;
|
||||
background: #151C25;
|
||||
}
|
||||
|
||||
DirectoryTree {
|
||||
padding-right: 1;
|
||||
}
|
||||
|
||||
#code-view {
|
||||
overflow: auto scroll;
|
||||
|
||||
@@ -39,7 +39,7 @@ class CodeBrowser(App):
|
||||
path = "./" if len(sys.argv) < 2 else sys.argv[1]
|
||||
yield Header()
|
||||
yield Container(
|
||||
Vertical(DirectoryTree(path), id="tree-view"),
|
||||
DirectoryTree(path, id="tree-view"),
|
||||
Vertical(Static(id="code", expand=True), id="code-view"),
|
||||
)
|
||||
yield Footer()
|
||||
@@ -47,8 +47,11 @@ class CodeBrowser(App):
|
||||
def on_mount(self, event: events.Mount) -> None:
|
||||
self.query_one(DirectoryTree).focus()
|
||||
|
||||
def on_directory_tree_file_click(self, event: DirectoryTree.FileClick) -> None:
|
||||
def on_directory_tree_file_selected(
|
||||
self, event: DirectoryTree.FileSelected
|
||||
) -> None:
|
||||
"""Called when the user click a file in the directory tree."""
|
||||
event.stop()
|
||||
code_view = self.query_one("#code", Static)
|
||||
try:
|
||||
syntax = Syntax.from_path(
|
||||
|
||||
1944
examples/food.json
Normal file
79
examples/json_tree.py
Normal file
@@ -0,0 +1,79 @@
|
||||
import json
|
||||
|
||||
from rich.text import Text
|
||||
|
||||
from textual.app import App, ComposeResult
|
||||
from textual.widgets import Header, Footer, Tree, TreeNode
|
||||
|
||||
|
||||
class TreeApp(App):
|
||||
|
||||
BINDINGS = [
|
||||
("a", "add", "Add node"),
|
||||
("c", "clear", "Clear"),
|
||||
("t", "toggle_root", "Toggle root"),
|
||||
]
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
yield Header()
|
||||
yield Footer()
|
||||
yield Tree("Root")
|
||||
|
||||
@classmethod
|
||||
def add_json(cls, node: TreeNode, json_data: object) -> None:
|
||||
"""Adds JSON data to a node.
|
||||
|
||||
Args:
|
||||
node (TreeNode): A Tree node.
|
||||
json_data (object): An object decoded from JSON.
|
||||
"""
|
||||
|
||||
from rich.highlighter import ReprHighlighter
|
||||
|
||||
highlighter = ReprHighlighter()
|
||||
|
||||
def add_node(name: str, node: TreeNode, data: object) -> None:
|
||||
if isinstance(data, dict):
|
||||
node._label = Text(f"{{}} {name}")
|
||||
for key, value in data.items():
|
||||
new_node = node.add("")
|
||||
add_node(key, new_node, value)
|
||||
elif isinstance(data, list):
|
||||
node._label = Text(f"[] {name}")
|
||||
for index, value in enumerate(data):
|
||||
new_node = node.add("")
|
||||
add_node(str(index), new_node, value)
|
||||
else:
|
||||
node._allow_expand = False
|
||||
if name:
|
||||
label = Text.assemble(
|
||||
Text.from_markup(f"[b]{name}[/b]="), highlighter(repr(data))
|
||||
)
|
||||
else:
|
||||
label = Text(repr(data))
|
||||
node._label = label
|
||||
|
||||
add_node("JSON", node, json_data)
|
||||
|
||||
def on_mount(self) -> None:
|
||||
with open("food.json") as data_file:
|
||||
self.json_data = json.load(data_file)
|
||||
|
||||
def action_add(self) -> None:
|
||||
tree = self.query_one(Tree)
|
||||
json_node = tree.root.add("JSON")
|
||||
self.add_json(json_node, self.json_data)
|
||||
tree.root.expand()
|
||||
|
||||
def action_clear(self) -> None:
|
||||
tree = self.query_one(Tree)
|
||||
tree.clear()
|
||||
|
||||
def action_toggle_root(self) -> None:
|
||||
tree = self.query_one(Tree)
|
||||
tree.show_root = not tree.show_root
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
app = TreeApp()
|
||||
app.run()
|
||||
@@ -89,28 +89,33 @@ nav:
|
||||
- "styles/visibility.md"
|
||||
- "styles/width.md"
|
||||
- Widgets:
|
||||
- "widgets/index.md"
|
||||
- "widgets/button.md"
|
||||
- "widgets/checkbox.md"
|
||||
- "widgets/data_table.md"
|
||||
- "widgets/directory_tree.md"
|
||||
- "widgets/footer.md"
|
||||
- "widgets/header.md"
|
||||
- "widgets/index.md"
|
||||
- "widgets/input.md"
|
||||
- "widgets/label.md"
|
||||
- "widgets/placeholder.md"
|
||||
- "widgets/static.md"
|
||||
- "widgets/tree_control.md"
|
||||
- "widgets/tree.md"
|
||||
- API:
|
||||
- "api/index.md"
|
||||
- "api/app.md"
|
||||
- "api/button.md"
|
||||
- "api/checkbox.md"
|
||||
- "api/color.md"
|
||||
- "api/containers.md"
|
||||
- "api/data_table.md"
|
||||
- "api/directory_tree.md"
|
||||
- "api/dom_node.md"
|
||||
- "api/events.md"
|
||||
- "api/footer.md"
|
||||
- "api/geometry.md"
|
||||
- "api/header.md"
|
||||
- "api/input.md"
|
||||
- "api/label.md"
|
||||
- "api/message_pump.md"
|
||||
- "api/message.md"
|
||||
|
||||
170
poetry.lock
generated
@@ -31,6 +31,24 @@ python-versions = ">=3.7"
|
||||
[package.dependencies]
|
||||
frozenlist = ">=1.1.0"
|
||||
|
||||
[[package]]
|
||||
name = "anyio"
|
||||
version = "3.6.2"
|
||||
description = "High level compatibility layer for multiple asynchronous event loop implementations"
|
||||
category = "dev"
|
||||
optional = false
|
||||
python-versions = ">=3.6.2"
|
||||
|
||||
[package.dependencies]
|
||||
idna = ">=2.8"
|
||||
sniffio = ">=1.1"
|
||||
typing-extensions = {version = "*", markers = "python_version < \"3.8\""}
|
||||
|
||||
[package.extras]
|
||||
doc = ["packaging", "sphinx-autodoc-typehints (>=1.2.0)", "sphinx-rtd-theme"]
|
||||
test = ["contextlib2", "coverage[toml] (>=4.5)", "hypothesis (>=4.0)", "mock (>=4)", "pytest (>=7.0)", "pytest-mock (>=3.6.1)", "trustme", "uvloop (<0.15)", "uvloop (>=0.15)"]
|
||||
trio = ["trio (>=0.16,<0.22)"]
|
||||
|
||||
[[package]]
|
||||
name = "async-timeout"
|
||||
version = "4.0.2"
|
||||
@@ -144,7 +162,7 @@ python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7
|
||||
|
||||
[[package]]
|
||||
name = "colored"
|
||||
version = "1.4.3"
|
||||
version = "1.4.4"
|
||||
description = "Simple library for color and formatting to terminal"
|
||||
category = "dev"
|
||||
optional = false
|
||||
@@ -182,7 +200,7 @@ python-versions = "*"
|
||||
|
||||
[[package]]
|
||||
name = "exceptiongroup"
|
||||
version = "1.0.1"
|
||||
version = "1.0.4"
|
||||
description = "Backport of PEP 654 (exception groups)"
|
||||
category = "dev"
|
||||
optional = false
|
||||
@@ -250,7 +268,7 @@ typing-extensions = {version = ">=3.7.4.3", markers = "python_version < \"3.8\""
|
||||
|
||||
[[package]]
|
||||
name = "griffe"
|
||||
version = "0.23.0"
|
||||
version = "0.24.0"
|
||||
description = "Signatures for entire Python programs. Extract the structure, the frame, the skeleton of your project, to generate API documentation or find breaking changes in your API."
|
||||
category = "dev"
|
||||
optional = false
|
||||
@@ -258,10 +276,60 @@ python-versions = ">=3.7"
|
||||
|
||||
[package.dependencies]
|
||||
cached-property = {version = "*", markers = "python_version < \"3.8\""}
|
||||
colorama = ">=0.4"
|
||||
|
||||
[package.extras]
|
||||
async = ["aiofiles (>=0.7,<1.0)"]
|
||||
|
||||
[[package]]
|
||||
name = "h11"
|
||||
version = "0.14.0"
|
||||
description = "A pure-Python, bring-your-own-I/O implementation of HTTP/1.1"
|
||||
category = "dev"
|
||||
optional = false
|
||||
python-versions = ">=3.7"
|
||||
|
||||
[package.dependencies]
|
||||
typing-extensions = {version = "*", markers = "python_version < \"3.8\""}
|
||||
|
||||
[[package]]
|
||||
name = "httpcore"
|
||||
version = "0.16.1"
|
||||
description = "A minimal low-level HTTP client."
|
||||
category = "dev"
|
||||
optional = false
|
||||
python-versions = ">=3.7"
|
||||
|
||||
[package.dependencies]
|
||||
anyio = ">=3.0,<5.0"
|
||||
certifi = "*"
|
||||
h11 = ">=0.13,<0.15"
|
||||
sniffio = ">=1.0.0,<2.0.0"
|
||||
|
||||
[package.extras]
|
||||
http2 = ["h2 (>=3,<5)"]
|
||||
socks = ["socksio (>=1.0.0,<2.0.0)"]
|
||||
|
||||
[[package]]
|
||||
name = "httpx"
|
||||
version = "0.23.1"
|
||||
description = "The next generation HTTP client."
|
||||
category = "dev"
|
||||
optional = false
|
||||
python-versions = ">=3.7"
|
||||
|
||||
[package.dependencies]
|
||||
certifi = "*"
|
||||
httpcore = ">=0.15.0,<0.17.0"
|
||||
rfc3986 = {version = ">=1.3,<2", extras = ["idna2008"]}
|
||||
sniffio = "*"
|
||||
|
||||
[package.extras]
|
||||
brotli = ["brotli", "brotlicffi"]
|
||||
cli = ["click (>=8.0.0,<9.0.0)", "pygments (>=2.0.0,<3.0.0)", "rich (>=10,<13)"]
|
||||
http2 = ["h2 (>=3,<5)"]
|
||||
socks = ["socksio (>=1.0.0,<2.0.0)"]
|
||||
|
||||
[[package]]
|
||||
name = "identify"
|
||||
version = "2.5.8"
|
||||
@@ -390,7 +458,7 @@ mkdocs = ">=1.1"
|
||||
|
||||
[[package]]
|
||||
name = "mkdocs-material"
|
||||
version = "8.5.9"
|
||||
version = "8.5.10"
|
||||
description = "Documentation that simply works"
|
||||
category = "dev"
|
||||
optional = false
|
||||
@@ -455,14 +523,14 @@ python-legacy = ["mkdocstrings-python-legacy (>=0.2.1)"]
|
||||
|
||||
[[package]]
|
||||
name = "mkdocstrings-python"
|
||||
version = "0.7.1"
|
||||
version = "0.8.0"
|
||||
description = "A Python handler for mkdocstrings."
|
||||
category = "dev"
|
||||
optional = false
|
||||
python-versions = ">=3.7"
|
||||
|
||||
[package.dependencies]
|
||||
griffe = ">=0.11.1"
|
||||
griffe = ">=0.24"
|
||||
mkdocstrings = ">=0.19"
|
||||
|
||||
[[package]]
|
||||
@@ -541,7 +609,7 @@ pyparsing = ">=2.0.2,<3.0.5 || >3.0.5"
|
||||
|
||||
[[package]]
|
||||
name = "pathspec"
|
||||
version = "0.10.1"
|
||||
version = "0.10.2"
|
||||
description = "Utility library for gitignore style pattern matching of file paths."
|
||||
category = "dev"
|
||||
optional = false
|
||||
@@ -549,7 +617,7 @@ python-versions = ">=3.7"
|
||||
|
||||
[[package]]
|
||||
name = "platformdirs"
|
||||
version = "2.5.3"
|
||||
version = "2.5.4"
|
||||
description = "A small Python package for determining appropriate platform-specific dirs, e.g. a \"user data dir\"."
|
||||
category = "dev"
|
||||
optional = false
|
||||
@@ -663,7 +731,7 @@ testing = ["coverage (==6.2)", "mypy (==0.931)"]
|
||||
|
||||
[[package]]
|
||||
name = "pytest-asyncio"
|
||||
version = "0.20.1"
|
||||
version = "0.20.2"
|
||||
description = "Pytest support for asyncio"
|
||||
category = "dev"
|
||||
optional = false
|
||||
@@ -748,6 +816,20 @@ urllib3 = ">=1.21.1,<1.27"
|
||||
socks = ["PySocks (>=1.5.6,!=1.5.7)"]
|
||||
use-chardet-on-py3 = ["chardet (>=3.0.2,<6)"]
|
||||
|
||||
[[package]]
|
||||
name = "rfc3986"
|
||||
version = "1.5.0"
|
||||
description = "Validating URI References per RFC 3986"
|
||||
category = "dev"
|
||||
optional = false
|
||||
python-versions = "*"
|
||||
|
||||
[package.dependencies]
|
||||
idna = {version = "*", optional = true, markers = "extra == \"idna2008\""}
|
||||
|
||||
[package.extras]
|
||||
idna2008 = ["idna"]
|
||||
|
||||
[[package]]
|
||||
name = "rich"
|
||||
version = "12.6.0"
|
||||
@@ -793,6 +875,14 @@ category = "dev"
|
||||
optional = false
|
||||
python-versions = ">=3.6"
|
||||
|
||||
[[package]]
|
||||
name = "sniffio"
|
||||
version = "1.3.0"
|
||||
description = "Sniff out which async library your code is running under"
|
||||
category = "dev"
|
||||
optional = false
|
||||
python-versions = ">=3.7"
|
||||
|
||||
[[package]]
|
||||
name = "syrupy"
|
||||
version = "3.0.5"
|
||||
@@ -871,7 +961,7 @@ socks = ["PySocks (>=1.5.6,!=1.5.7,<2.0)"]
|
||||
|
||||
[[package]]
|
||||
name = "virtualenv"
|
||||
version = "20.16.6"
|
||||
version = "20.16.7"
|
||||
description = "Virtual Python Environment builder"
|
||||
category = "dev"
|
||||
optional = false
|
||||
@@ -929,7 +1019,7 @@ dev = ["aiohttp", "click", "msgpack"]
|
||||
[metadata]
|
||||
lock-version = "1.1"
|
||||
python-versions = "^3.7"
|
||||
content-hash = "0c33f462f79d31068c0d74743f47c1d713ecc8ba009eaef1bef832f1272e4b1a"
|
||||
content-hash = "578d7f611a797d406b8db7c61f28796e81af2e637d9671caab9b4ea2b1cf93c6"
|
||||
|
||||
[metadata.files]
|
||||
aiohttp = [
|
||||
@@ -1025,6 +1115,10 @@ aiosignal = [
|
||||
{file = "aiosignal-1.3.1-py3-none-any.whl", hash = "sha256:f8376fb07dd1e86a584e4fcdec80b36b7f81aac666ebc724e2c090300dd83b17"},
|
||||
{file = "aiosignal-1.3.1.tar.gz", hash = "sha256:54cd96e15e1649b75d6c87526a6ff0b6c1b0dd3459f43d9ca11d48c339b68cfc"},
|
||||
]
|
||||
anyio = [
|
||||
{file = "anyio-3.6.2-py3-none-any.whl", hash = "sha256:fbbe32bd270d2a2ef3ed1c5d45041250284e31fc0a4df4a5a6071842051a51e3"},
|
||||
{file = "anyio-3.6.2.tar.gz", hash = "sha256:25ea0d673ae30af41a0c442f81cf3b38c7e79fdc7b60335a4c14e05eb0947421"},
|
||||
]
|
||||
async-timeout = [
|
||||
{file = "async-timeout-4.0.2.tar.gz", hash = "sha256:2163e1640ddb52b7a8c80d0a67a08587e5d245cc9c553a74a847056bc2976b15"},
|
||||
{file = "async_timeout-4.0.2-py3-none-any.whl", hash = "sha256:8ca1e4fcf50d07413d66d1a5e416e42cfdf5851c981d679a09851a6853383b3c"},
|
||||
@@ -1085,7 +1179,7 @@ colorama = [
|
||||
{file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"},
|
||||
]
|
||||
colored = [
|
||||
{file = "colored-1.4.3.tar.gz", hash = "sha256:b7b48b9f40e8a65bbb54813d5d79dd008dc8b8c5638d5bbfd30fc5a82e6def7a"},
|
||||
{file = "colored-1.4.4.tar.gz", hash = "sha256:04ff4d4dd514274fe3b99a21bb52fb96f2688c01e93fba7bef37221e7cb56ce0"},
|
||||
]
|
||||
commonmark = [
|
||||
{file = "commonmark-0.9.1-py2.py3-none-any.whl", hash = "sha256:da2f38c92590f83de410ba1a3cbceafbc74fee9def35f9251ba9a971d6d66fd9"},
|
||||
@@ -1148,8 +1242,8 @@ distlib = [
|
||||
{file = "distlib-0.3.6.tar.gz", hash = "sha256:14bad2d9b04d3a36127ac97f30b12a19268f211063d8f8ee4f47108896e11b46"},
|
||||
]
|
||||
exceptiongroup = [
|
||||
{file = "exceptiongroup-1.0.1-py3-none-any.whl", hash = "sha256:4d6c0aa6dd825810941c792f53d7b8d71da26f5e5f84f20f9508e8f2d33b140a"},
|
||||
{file = "exceptiongroup-1.0.1.tar.gz", hash = "sha256:73866f7f842ede6cb1daa42c4af078e2035e5f7607f0e2c762cc51bb31bbe7b2"},
|
||||
{file = "exceptiongroup-1.0.4-py3-none-any.whl", hash = "sha256:542adf9dea4055530d6e1279602fa5cb11dab2395fa650b8674eaec35fc4a828"},
|
||||
{file = "exceptiongroup-1.0.4.tar.gz", hash = "sha256:bd14967b79cd9bdb54d97323216f8fdf533e278df937aa2a90089e7d6e06e5ec"},
|
||||
]
|
||||
filelock = [
|
||||
{file = "filelock-3.8.0-py3-none-any.whl", hash = "sha256:617eb4e5eedc82fc5f47b6d61e4d11cb837c56cb4544e39081099fa17ad109d4"},
|
||||
@@ -1244,8 +1338,20 @@ gitpython = [
|
||||
{file = "GitPython-3.1.29.tar.gz", hash = "sha256:cc36bfc4a3f913e66805a28e84703e419d9c264c1077e537b54f0e1af85dbefd"},
|
||||
]
|
||||
griffe = [
|
||||
{file = "griffe-0.23.0-py3-none-any.whl", hash = "sha256:cfca5f523808109da3f8cfaa46e325fa2e5bef51120d1146e908c121b56475f0"},
|
||||
{file = "griffe-0.23.0.tar.gz", hash = "sha256:a639e2968c8e27f56ebcc57f869a03cea7ac7e7f5684bd2429c665f761c4e7bd"},
|
||||
{file = "griffe-0.24.0-py3-none-any.whl", hash = "sha256:6c6b64716155f27ef63377e2b04749079c359f06d9a6e638bb2f885cbe463360"},
|
||||
{file = "griffe-0.24.0.tar.gz", hash = "sha256:afa92aeb8c5a4f2501693ffd607f820d7ade3ac2a36e34c43d39ee3486cec392"},
|
||||
]
|
||||
h11 = [
|
||||
{file = "h11-0.14.0-py3-none-any.whl", hash = "sha256:e3fe4ac4b851c468cc8363d500db52c2ead036020723024a109d37346efaa761"},
|
||||
{file = "h11-0.14.0.tar.gz", hash = "sha256:8f19fbbe99e72420ff35c00b27a34cb9937e902a8b810e2c88300c6f0a3b699d"},
|
||||
]
|
||||
httpcore = [
|
||||
{file = "httpcore-0.16.1-py3-none-any.whl", hash = "sha256:8d393db683cc8e35cc6ecb02577c5e1abfedde52b38316d038932a84b4875ecb"},
|
||||
{file = "httpcore-0.16.1.tar.gz", hash = "sha256:3d3143ff5e1656a5740ea2f0c167e8e9d48c5a9bbd7f00ad1f8cff5711b08543"},
|
||||
]
|
||||
httpx = [
|
||||
{file = "httpx-0.23.1-py3-none-any.whl", hash = "sha256:0b9b1f0ee18b9978d637b0776bfd7f54e2ca278e063e3586d8f01cda89e042a8"},
|
||||
{file = "httpx-0.23.1.tar.gz", hash = "sha256:202ae15319be24efe9a8bd4ed4360e68fde7b38bcc2ce87088d416f026667d19"},
|
||||
]
|
||||
identify = [
|
||||
{file = "identify-2.5.8-py2.py3-none-any.whl", hash = "sha256:48b7925fe122720088aeb7a6c34f17b27e706b72c61070f27fe3789094233440"},
|
||||
@@ -1326,8 +1432,8 @@ mkdocs-autorefs = [
|
||||
{file = "mkdocs_autorefs-0.4.1-py3-none-any.whl", hash = "sha256:a2248a9501b29dc0cc8ba4c09f4f47ff121945f6ce33d760f145d6f89d313f5b"},
|
||||
]
|
||||
mkdocs-material = [
|
||||
{file = "mkdocs_material-8.5.9-py3-none-any.whl", hash = "sha256:143ea55843b3747b640e1110824d91e8a4c670352380e166e64959f9abe98862"},
|
||||
{file = "mkdocs_material-8.5.9.tar.gz", hash = "sha256:45eeabb23d2caba8fa3b85c91d9ec8e8b22add716e9bba8faf16d56af8aa5622"},
|
||||
{file = "mkdocs_material-8.5.10-py3-none-any.whl", hash = "sha256:51760fa4c9ee3ca0b3a661ec9f9817ec312961bb84ff19e5b523fdc5256e1d6c"},
|
||||
{file = "mkdocs_material-8.5.10.tar.gz", hash = "sha256:7623608f746c6d9ff68a8ef01f13eddf32fa2cae5e15badb251f26d1196bc8f1"},
|
||||
]
|
||||
mkdocs-material-extensions = [
|
||||
{file = "mkdocs_material_extensions-1.1-py3-none-any.whl", hash = "sha256:bcc2e5fc70c0ec50e59703ee6e639d87c7e664c0c441c014ea84461a90f1e902"},
|
||||
@@ -1342,8 +1448,8 @@ mkdocstrings = [
|
||||
{file = "mkdocstrings-0.19.0.tar.gz", hash = "sha256:efa34a67bad11229d532d89f6836a8a215937548623b64f3698a1df62e01cc3e"},
|
||||
]
|
||||
mkdocstrings-python = [
|
||||
{file = "mkdocstrings-python-0.7.1.tar.gz", hash = "sha256:c334b382dca202dfa37071c182418a6df5818356a95d54362a2b24822ca3af71"},
|
||||
{file = "mkdocstrings_python-0.7.1-py3-none-any.whl", hash = "sha256:a22060bfa374697678e9af4e62b020d990dad2711c98f7a9fac5c0345bef93c7"},
|
||||
{file = "mkdocstrings-python-0.8.0.tar.gz", hash = "sha256:67f674a8b252fca0b9411c10fb923dd6aacc49ac55c59f738b78b06592ace43d"},
|
||||
{file = "mkdocstrings_python-0.8.0-py3-none-any.whl", hash = "sha256:cbee42e53aeaae340d79d72e9bcf42f2b6abe4d11696597c76e3e86a4d9f05a0"},
|
||||
]
|
||||
msgpack = [
|
||||
{file = "msgpack-1.0.4-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:4ab251d229d10498e9a2f3b1e68ef64cb393394ec477e3370c457f9430ce9250"},
|
||||
@@ -1509,12 +1615,12 @@ packaging = [
|
||||
{file = "packaging-21.3.tar.gz", hash = "sha256:dd47c42927d89ab911e606518907cc2d3a1f38bbd026385970643f9c5b8ecfeb"},
|
||||
]
|
||||
pathspec = [
|
||||
{file = "pathspec-0.10.1-py3-none-any.whl", hash = "sha256:46846318467efc4556ccfd27816e004270a9eeeeb4d062ce5e6fc7a87c573f93"},
|
||||
{file = "pathspec-0.10.1.tar.gz", hash = "sha256:7ace6161b621d31e7902eb6b5ae148d12cfd23f4a249b9ffb6b9fee12084323d"},
|
||||
{file = "pathspec-0.10.2-py3-none-any.whl", hash = "sha256:88c2606f2c1e818b978540f73ecc908e13999c6c3a383daf3705652ae79807a5"},
|
||||
{file = "pathspec-0.10.2.tar.gz", hash = "sha256:8f6bf73e5758fd365ef5d58ce09ac7c27d2833a8d7da51712eac6e27e35141b0"},
|
||||
]
|
||||
platformdirs = [
|
||||
{file = "platformdirs-2.5.3-py3-none-any.whl", hash = "sha256:0cb405749187a194f444c25c82ef7225232f11564721eabffc6ec70df83b11cb"},
|
||||
{file = "platformdirs-2.5.3.tar.gz", hash = "sha256:6e52c21afff35cb659c6e52d8b4d61b9bd544557180440538f255d9382c8cbe0"},
|
||||
{file = "platformdirs-2.5.4-py3-none-any.whl", hash = "sha256:af0276409f9a02373d540bf8480021a048711d572745aef4b7842dad245eba10"},
|
||||
{file = "platformdirs-2.5.4.tar.gz", hash = "sha256:1006647646d80f16130f052404c6b901e80ee4ed6bef6792e1f238a8969106f7"},
|
||||
]
|
||||
pluggy = [
|
||||
{file = "pluggy-1.0.0-py2.py3-none-any.whl", hash = "sha256:74134bbf457f031a36d68416e1509f34bd5ccc019f0bcc952c7b909d06b37bd3"},
|
||||
@@ -1545,8 +1651,8 @@ pytest-aiohttp = [
|
||||
{file = "pytest_aiohttp-1.0.4-py3-none-any.whl", hash = "sha256:1d2dc3a304c2be1fd496c0c2fb6b31ab60cd9fc33984f761f951f8ea1eb4ca95"},
|
||||
]
|
||||
pytest-asyncio = [
|
||||
{file = "pytest-asyncio-0.20.1.tar.gz", hash = "sha256:626699de2a747611f3eeb64168b3575f70439b06c3d0206e6ceaeeb956e65519"},
|
||||
{file = "pytest_asyncio-0.20.1-py3-none-any.whl", hash = "sha256:2c85a835df33fda40fe3973b451e0c194ca11bc2c007eabff90bb3d156fc172b"},
|
||||
{file = "pytest-asyncio-0.20.2.tar.gz", hash = "sha256:32a87a9836298a881c0ec637ebcc952cfe23a56436bdc0d09d1511941dd8a812"},
|
||||
{file = "pytest_asyncio-0.20.2-py3-none-any.whl", hash = "sha256:07e0abf9e6e6b95894a39f688a4a875d63c2128f76c02d03d16ccbc35bcc0f8a"},
|
||||
]
|
||||
pytest-cov = [
|
||||
{file = "pytest-cov-2.12.1.tar.gz", hash = "sha256:261ceeb8c227b726249b376b8526b600f38667ee314f910353fa318caa01f4d7"},
|
||||
@@ -1610,6 +1716,10 @@ requests = [
|
||||
{file = "requests-2.28.1-py3-none-any.whl", hash = "sha256:8fefa2a1a1365bf5520aac41836fbee479da67864514bdb821f31ce07ce65349"},
|
||||
{file = "requests-2.28.1.tar.gz", hash = "sha256:7c5599b102feddaa661c826c56ab4fee28bfd17f5abca1ebbe3e7f19d7c97983"},
|
||||
]
|
||||
rfc3986 = [
|
||||
{file = "rfc3986-1.5.0-py2.py3-none-any.whl", hash = "sha256:a86d6e1f5b1dc238b218b012df0aa79409667bb209e58da56d0b94704e712a97"},
|
||||
{file = "rfc3986-1.5.0.tar.gz", hash = "sha256:270aaf10d87d0d4e095063c65bf3ddbc6ee3d0b226328ce21e036f946e421835"},
|
||||
]
|
||||
rich = [
|
||||
{file = "rich-12.6.0-py3-none-any.whl", hash = "sha256:a4eb26484f2c82589bd9a17c73d32a010b1e29d89f1604cd9bf3a2097b81bb5e"},
|
||||
{file = "rich-12.6.0.tar.gz", hash = "sha256:ba3a3775974105c221d31141f2c116f4fd65c5ceb0698657a11e9f295ec93fd0"},
|
||||
@@ -1626,6 +1736,10 @@ smmap = [
|
||||
{file = "smmap-5.0.0-py3-none-any.whl", hash = "sha256:2aba19d6a040e78d8b09de5c57e96207b09ed71d8e55ce0959eeee6c8e190d94"},
|
||||
{file = "smmap-5.0.0.tar.gz", hash = "sha256:c840e62059cd3be204b0c9c9f74be2c09d5648eddd4580d9314c3ecde0b30936"},
|
||||
]
|
||||
sniffio = [
|
||||
{file = "sniffio-1.3.0-py3-none-any.whl", hash = "sha256:eecefdce1e5bbfb7ad2eeaabf7c1eeb404d7757c379bd1f7e5cce9d8bf425384"},
|
||||
{file = "sniffio-1.3.0.tar.gz", hash = "sha256:e60305c5e5d314f5389259b7f22aaa33d8f7dee49763119234af3755c55b9101"},
|
||||
]
|
||||
syrupy = [
|
||||
{file = "syrupy-3.0.5-py3-none-any.whl", hash = "sha256:6dc0472cc690782b517d509a2854b5ca552a9851acf43b8a847cfa1aed6f6b01"},
|
||||
{file = "syrupy-3.0.5.tar.gz", hash = "sha256:928962a6c14abb2695be9a49b42a016a4e4abdb017a69104cde958f2faf01f98"},
|
||||
@@ -1729,8 +1843,8 @@ urllib3 = [
|
||||
{file = "urllib3-1.26.12.tar.gz", hash = "sha256:3fa96cf423e6987997fc326ae8df396db2a8b7c667747d47ddd8ecba91f4a74e"},
|
||||
]
|
||||
virtualenv = [
|
||||
{file = "virtualenv-20.16.6-py3-none-any.whl", hash = "sha256:186ca84254abcbde98180fd17092f9628c5fe742273c02724972a1d8a2035108"},
|
||||
{file = "virtualenv-20.16.6.tar.gz", hash = "sha256:530b850b523c6449406dfba859d6345e48ef19b8439606c5d74d7d3c9e14d76e"},
|
||||
{file = "virtualenv-20.16.7-py3-none-any.whl", hash = "sha256:efd66b00386fdb7dbe4822d172303f40cd05e50e01740b19ea42425cbe653e29"},
|
||||
{file = "virtualenv-20.16.7.tar.gz", hash = "sha256:8691e3ff9387f743e00f6bb20f70121f5e4f596cae754531f2b3b3a1b1ac696e"},
|
||||
]
|
||||
watchdog = [
|
||||
{file = "watchdog-2.1.9-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:a735a990a1095f75ca4f36ea2ef2752c99e6ee997c46b0de507ba40a09bf7330"},
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[tool.poetry]
|
||||
name = "textual"
|
||||
version = "0.4.0"
|
||||
version = "0.5.0"
|
||||
homepage = "https://github.com/Textualize/textual"
|
||||
description = "Modern Text User Interface framework"
|
||||
authors = ["Will McGugan <will@textualize.io>"]
|
||||
@@ -44,7 +44,7 @@ nanoid = ">=2.0.0"
|
||||
[tool.poetry.extras]
|
||||
dev = ["aiohttp", "click", "msgpack"]
|
||||
|
||||
[tool.poetry.dev-dependencies]
|
||||
[tool.poetry.group.dev.dependencies]
|
||||
pytest = "^7.1.3"
|
||||
black = "^22.3.0"
|
||||
mypy = "^0.990"
|
||||
@@ -57,9 +57,8 @@ pytest-aiohttp = "^1.0.4"
|
||||
time-machine = "^2.6.0"
|
||||
Jinja2 = "<3.1.0"
|
||||
syrupy = "^3.0.0"
|
||||
|
||||
[tool.poetry.group.dev.dependencies]
|
||||
mkdocs-rss-plugin = "^1.5.0"
|
||||
httpx = "^0.23.1"
|
||||
|
||||
[tool.black]
|
||||
includes = "src"
|
||||
|
||||
@@ -41,7 +41,6 @@ def arrange(
|
||||
|
||||
placements: list[WidgetPlacement] = []
|
||||
add_placement = placements.append
|
||||
region = size.region
|
||||
|
||||
_WidgetPlacement = WidgetPlacement
|
||||
top_z = TOP_Z
|
||||
@@ -50,7 +49,9 @@ def arrange(
|
||||
get_dock = attrgetter("styles.dock")
|
||||
styles = widget.styles
|
||||
|
||||
layer_region = size.region
|
||||
for widgets in dock_layers.values():
|
||||
region = layer_region
|
||||
|
||||
layout_widgets, dock_widgets = partition(get_dock, widgets)
|
||||
|
||||
|
||||
@@ -47,6 +47,7 @@ class LRUCache(Generic[CacheKey, CacheValue]):
|
||||
|
||||
@property
|
||||
def maxsize(self) -> int:
|
||||
"""int: Maximum size of cache, before new values evict old values."""
|
||||
return self._maxsize
|
||||
|
||||
@maxsize.setter
|
||||
@@ -59,6 +60,14 @@ class LRUCache(Generic[CacheKey, CacheValue]):
|
||||
def __len__(self) -> int:
|
||||
return len(self._cache)
|
||||
|
||||
def grow(self, maxsize: int) -> None:
|
||||
"""Grow the maximum size to at least `maxsize` elements.
|
||||
|
||||
Args:
|
||||
maxsize (int): New maximum size.
|
||||
"""
|
||||
self.maxsize = max(self.maxsize, maxsize)
|
||||
|
||||
def clear(self) -> None:
|
||||
"""Clear the cache."""
|
||||
with self._lock:
|
||||
|
||||
@@ -10,7 +10,7 @@ if TYPE_CHECKING:
|
||||
from .widget import 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):
|
||||
|
||||
@@ -101,8 +101,8 @@ class NodeList(Sequence):
|
||||
if widget_id in self._nodes_by_id:
|
||||
raise DuplicateIds(
|
||||
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. "
|
||||
f"The children of a widget must have unique IDs."
|
||||
"already exists with that ID in this list of children. "
|
||||
"The children of a widget must have unique IDs."
|
||||
)
|
||||
|
||||
def _remove(self, widget: Widget) -> None:
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from functools import lru_cache
|
||||
from sys import intern
|
||||
from typing import TYPE_CHECKING, Callable, Iterable, List
|
||||
|
||||
from rich.segment import Segment
|
||||
@@ -52,6 +54,20 @@ def style_links(
|
||||
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:
|
||||
"""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 = get_box(border_right, inner, outer, right_style)[1][2]
|
||||
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:
|
||||
line = [left, Segment(" " * (width - 1), background_style)]
|
||||
line = [left, make_blank(width - 1, background_style)]
|
||||
elif border_right:
|
||||
line = [Segment(" " * (width - 1), background_style), right]
|
||||
line = [make_blank(width - 1, background_style), right]
|
||||
else:
|
||||
line = [Segment(" " * width, background_style)]
|
||||
line = [make_blank(width, background_style)]
|
||||
else:
|
||||
# Content with border and padding (C)
|
||||
content_y = y - gutter.top
|
||||
if content_y < content_height:
|
||||
line = render_content_line(y - gutter.top)
|
||||
else:
|
||||
line = [Segment(" " * content_width, inner)]
|
||||
line = [make_blank(content_width, inner)]
|
||||
if inner:
|
||||
line = Segment.apply_style(line, inner)
|
||||
line = line_pad(line, pad_left, pad_right, inner)
|
||||
|
||||
@@ -18,6 +18,7 @@ from time import perf_counter
|
||||
from typing import (
|
||||
TYPE_CHECKING,
|
||||
Any,
|
||||
Callable,
|
||||
Generic,
|
||||
Iterable,
|
||||
List,
|
||||
@@ -25,7 +26,7 @@ from typing import (
|
||||
TypeVar,
|
||||
Union,
|
||||
cast,
|
||||
Callable,
|
||||
overload,
|
||||
)
|
||||
from weakref import WeakSet, WeakValueDictionary
|
||||
|
||||
@@ -45,7 +46,8 @@ from ._context import active_app
|
||||
from ._event_broker import NoHandler, extract_handler_actions
|
||||
from ._filter import LineFilter, Monochrome
|
||||
from ._path import _make_path_object_relative
|
||||
from ._typing import TypeAlias, Final
|
||||
from ._typing import Final, TypeAlias
|
||||
from .await_remove import AwaitRemove
|
||||
from .binding import Binding, Bindings
|
||||
from .css.query import NoMatches
|
||||
from .css.stylesheet import Stylesheet
|
||||
@@ -61,7 +63,8 @@ from .messages import CallbackType
|
||||
from .reactive import Reactive
|
||||
from .renderables.blank import Blank
|
||||
from .screen import Screen
|
||||
from .widget import AwaitMount, Widget
|
||||
from .widget import AwaitMount, MountError, Widget
|
||||
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .devtools.client import DevtoolsClient
|
||||
@@ -352,6 +355,7 @@ class App(Generic[ReturnType], DOMNode):
|
||||
else None
|
||||
)
|
||||
self._screenshot: str | None = None
|
||||
self._dom_lock = asyncio.Lock()
|
||||
|
||||
@property
|
||||
def return_value(self) -> ReturnType | None:
|
||||
@@ -889,23 +893,52 @@ class App(Generic[ReturnType], DOMNode):
|
||||
def render(self) -> RenderableType:
|
||||
return Blank(self.styles.background)
|
||||
|
||||
ExpectType = TypeVar("ExpectType", bound=Widget)
|
||||
|
||||
@overload
|
||||
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)
|
||||
Returns the first child (immediate descendent) of this DOMNode
|
||||
with the given ID.
|
||||
|
||||
Args:
|
||||
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:
|
||||
DOMNode: The first child of this node with the specified ID.
|
||||
ExpectType | Widget: The first child of this node with the specified ID.
|
||||
|
||||
Raises:
|
||||
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:
|
||||
...
|
||||
|
||||
@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)
|
||||
Return the first descendant widget with the given ID.
|
||||
|
||||
@@ -915,14 +948,21 @@ class App(Generic[ReturnType], DOMNode):
|
||||
|
||||
Args:
|
||||
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:
|
||||
DOMNode: The first descendant encountered with this ID.
|
||||
ExpectType | Widget: The first descendant encountered with this ID.
|
||||
|
||||
Raises:
|
||||
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:
|
||||
"""Request update of styles.
|
||||
@@ -1460,7 +1500,6 @@ class App(Generic[ReturnType], DOMNode):
|
||||
|
||||
# If we don't already know about this widget...
|
||||
if child not in self._registry:
|
||||
|
||||
# Now to figure out where to place it. If we've got a `before`...
|
||||
if before is not None:
|
||||
# ...it's safe to NodeList._insert before that location.
|
||||
@@ -1951,6 +1990,48 @@ class App(Generic[ReturnType], DOMNode):
|
||||
for child in widget.children:
|
||||
push(child)
|
||||
|
||||
def _remove_nodes(self, widgets: list[Widget]) -> AwaitRemove:
|
||||
"""Remove nodes from DOM, and return an awaitable that awaits cleanup.
|
||||
|
||||
Args:
|
||||
widgets (list[Widget]): List of nodes to remvoe.
|
||||
|
||||
Returns:
|
||||
AwaitRemove: Awaitable that returns when the nodes have been fully removed.
|
||||
"""
|
||||
|
||||
async def prune_widgets_task(
|
||||
widgets: list[Widget], finished_event: asyncio.Event
|
||||
) -> None:
|
||||
"""Prune widgets as a background task.
|
||||
|
||||
Args:
|
||||
widgets (list[Widget]): Widgets to prune.
|
||||
finished_event (asyncio.Event): Event to set when complete.
|
||||
"""
|
||||
try:
|
||||
await self._prune_nodes(widgets)
|
||||
finally:
|
||||
finished_event.set()
|
||||
|
||||
removed_widgets = self._detach_from_dom(widgets)
|
||||
self.refresh(layout=True)
|
||||
|
||||
finished_event = asyncio.Event()
|
||||
asyncio.create_task(prune_widgets_task(removed_widgets, finished_event))
|
||||
|
||||
return AwaitRemove(finished_event)
|
||||
|
||||
async def _prune_nodes(self, widgets: list[Widget]) -> None:
|
||||
"""Remove nodes and children.
|
||||
|
||||
Args:
|
||||
widgets (Widget): _description_
|
||||
"""
|
||||
async with self._dom_lock:
|
||||
for widget in widgets:
|
||||
await self._prune_node(widget)
|
||||
|
||||
async def _prune_node(self, root: Widget) -> None:
|
||||
"""Remove a node and its children. Children are removed before parents.
|
||||
|
||||
@@ -1966,7 +2047,7 @@ class App(Generic[ReturnType], DOMNode):
|
||||
for children in reversed(node_children):
|
||||
# Closing children can be done asynchronously.
|
||||
close_messages = [
|
||||
child._close_messages() for child in children if child._running
|
||||
child._close_messages(wait=True) for child in children if child._running
|
||||
]
|
||||
# TODO: What if a message pump refuses to exit?
|
||||
if close_messages:
|
||||
@@ -1974,7 +2055,7 @@ class App(Generic[ReturnType], DOMNode):
|
||||
for child in children:
|
||||
self._unregister(child)
|
||||
|
||||
await root._close_messages()
|
||||
await root._close_messages(wait=False)
|
||||
self._unregister(root)
|
||||
|
||||
async def action_check_bindings(self, key: str) -> None:
|
||||
|
||||
@@ -62,6 +62,8 @@ def get_box_model(
|
||||
content_width = Fraction(
|
||||
get_content_width(content_container - styles.margin.totals, viewport)
|
||||
)
|
||||
if styles.scrollbar_gutter == "stable" and styles.overflow_x == "auto":
|
||||
content_width += styles.scrollbar_size_vertical
|
||||
else:
|
||||
# An explicit width
|
||||
styles_width = styles.width
|
||||
@@ -97,6 +99,8 @@ def get_box_model(
|
||||
content_height = Fraction(
|
||||
get_content_height(content_container, viewport, int(content_width))
|
||||
)
|
||||
if styles.scrollbar_gutter == "stable" and styles.overflow_y == "auto":
|
||||
content_height += styles.scrollbar_size_horizontal
|
||||
else:
|
||||
styles_height = styles.height
|
||||
# Explicit height set
|
||||
|
||||
@@ -305,7 +305,7 @@ class BoxProperty:
|
||||
current_value: tuple[str, Color] = cast(
|
||||
"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)
|
||||
if obj.set_rule(self.name, new_value):
|
||||
obj.refresh(layout=has_edge != new_edge)
|
||||
|
||||
@@ -356,16 +356,9 @@ class DOMQuery(Generic[QueryType]):
|
||||
Returns:
|
||||
AwaitRemove: An awaitable object that waits for the widgets to be removed.
|
||||
"""
|
||||
prune_finished_event = asyncio.Event()
|
||||
app = active_app.get()
|
||||
app.post_message_no_wait(
|
||||
events.Prune(
|
||||
app,
|
||||
widgets=app._detach_from_dom(list(self)),
|
||||
finished_flag=prune_finished_event,
|
||||
)
|
||||
)
|
||||
return AwaitRemove(prune_finished_event)
|
||||
await_remove = app._remove_nodes(list(self))
|
||||
return await_remove
|
||||
|
||||
def set_styles(
|
||||
self, css: str | None = None, **update_styles
|
||||
|
||||
@@ -551,6 +551,22 @@ class StylesBase(ABC):
|
||||
self._align_height(height, parent_height),
|
||||
)
|
||||
|
||||
@property
|
||||
def partial_rich_style(self) -> Style:
|
||||
"""Get the style properties associated with this node only (not including parents in the DOM).
|
||||
|
||||
Returns:
|
||||
Style: Rich Style object.
|
||||
"""
|
||||
style = Style(
|
||||
color=(self.color.rich_color if self.has_rule("color") else None),
|
||||
bgcolor=(
|
||||
self.background.rich_color if self.has_rule("background") else None
|
||||
),
|
||||
)
|
||||
style += self.text_style
|
||||
return style
|
||||
|
||||
|
||||
@rich.repr.auto
|
||||
@dataclass
|
||||
@@ -578,8 +594,10 @@ class Styles(StylesBase):
|
||||
Returns:
|
||||
bool: ``True`` if a rule was cleared, or ``False`` if it was already not set.
|
||||
"""
|
||||
self._updates += 1
|
||||
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
|
||||
|
||||
def get_rules(self) -> RulesMap:
|
||||
return self._rules.copy()
|
||||
@@ -594,12 +612,17 @@ class Styles(StylesBase):
|
||||
Returns:
|
||||
bool: ``True`` if the rule changed, otherwise ``False``.
|
||||
"""
|
||||
self._updates += 1
|
||||
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)
|
||||
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:
|
||||
return self._rules.get(rule, default)
|
||||
|
||||
@@ -49,7 +49,7 @@ expect_root_scope = Expect(
|
||||
selector_start_id=r"\#" + IDENTIFIER,
|
||||
selector_start_class=r"\." + IDENTIFIER,
|
||||
selector_start_universal=r"\*",
|
||||
selector_start=r"[a-zA-Z_\-]+",
|
||||
selector_start=IDENTIFIER,
|
||||
variable_name=rf"{VARIABLE_REF}:",
|
||||
).expect_eof(True)
|
||||
|
||||
|
||||
@@ -22,7 +22,7 @@ from rich.tree import Tree
|
||||
|
||||
from ._context import NoActiveAppError
|
||||
from ._node_list import NodeList
|
||||
from .binding import Bindings, BindingType
|
||||
from .binding import Binding, Bindings, BindingType
|
||||
from .color import BLACK, WHITE, Color
|
||||
from .css._error_tools import friendly_list
|
||||
from .css.constants import VALID_DISPLAY, VALID_VISIBILITY
|
||||
@@ -97,9 +97,16 @@ class DOMNode(MessagePump):
|
||||
|
||||
# True if this node inherits the CSS from the base class.
|
||||
_inherit_css: ClassVar[bool] = True
|
||||
|
||||
# True to inherit bindings from base class
|
||||
_inherit_bindings: ClassVar[bool] = True
|
||||
|
||||
# List of names of base classes that inherit CSS
|
||||
_css_type_names: ClassVar[frozenset[str]] = frozenset()
|
||||
|
||||
# Generated list of bindings
|
||||
_merged_bindings: ClassVar[Bindings] | None = None
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
@@ -127,7 +134,7 @@ class DOMNode(MessagePump):
|
||||
self._auto_refresh: float | None = None
|
||||
self._auto_refresh_timer: Timer | None = None
|
||||
self._css_types = {cls.__name__ for cls in self._css_bases(self.__class__)}
|
||||
self._bindings = Bindings(self.BINDINGS)
|
||||
self._bindings = self._merged_bindings or Bindings()
|
||||
self._has_hover_style: bool = False
|
||||
self._has_focus_within: bool = False
|
||||
|
||||
@@ -152,12 +159,16 @@ class DOMNode(MessagePump):
|
||||
"""Perform an automatic refresh (set with auto_refresh property)."""
|
||||
self.refresh()
|
||||
|
||||
def __init_subclass__(cls, inherit_css: bool = True) -> None:
|
||||
def __init_subclass__(
|
||||
cls, inherit_css: bool = True, inherit_bindings: bool = True
|
||||
) -> None:
|
||||
super().__init_subclass__()
|
||||
cls._inherit_css = inherit_css
|
||||
cls._inherit_bindings = inherit_bindings
|
||||
css_type_names: set[str] = set()
|
||||
for base in cls._css_bases(cls):
|
||||
css_type_names.add(base.__name__)
|
||||
cls._merged_bindings = cls._merge_bindings()
|
||||
cls._css_type_names = frozenset(css_type_names)
|
||||
|
||||
def get_component_styles(self, name: str) -> RenderStyles:
|
||||
@@ -205,6 +216,25 @@ class DOMNode(MessagePump):
|
||||
else:
|
||||
break
|
||||
|
||||
@classmethod
|
||||
def _merge_bindings(cls) -> Bindings:
|
||||
"""Merge bindings from base classes.
|
||||
|
||||
Returns:
|
||||
Bindings: Merged bindings.
|
||||
"""
|
||||
bindings: list[Bindings] = []
|
||||
|
||||
for base in reversed(cls.__mro__):
|
||||
if issubclass(base, DOMNode):
|
||||
if not base._inherit_bindings:
|
||||
bindings.clear()
|
||||
bindings.append(Bindings(base.BINDINGS))
|
||||
keys = {}
|
||||
for bindings_ in bindings:
|
||||
keys.update(bindings_.keys)
|
||||
return Bindings(keys.values())
|
||||
|
||||
def _post_register(self, app: App) -> None:
|
||||
"""Called when the widget is registered
|
||||
|
||||
|
||||
@@ -176,6 +176,7 @@ class LinuxDriver(Driver):
|
||||
self.exit_event.set()
|
||||
if self._key_thread is not None:
|
||||
self._key_thread.join()
|
||||
self.exit_event.clear()
|
||||
termios.tcflush(self.fileno, termios.TCIFLUSH)
|
||||
except Exception as error:
|
||||
# TODO: log this
|
||||
|
||||
@@ -84,6 +84,7 @@ class WindowsDriver(Driver):
|
||||
if self._event_thread is not None:
|
||||
self._event_thread.join()
|
||||
self._event_thread = None
|
||||
self.exit_event.clear()
|
||||
except Exception as error:
|
||||
# TODO: log this
|
||||
pass
|
||||
|
||||
@@ -127,28 +127,6 @@ class Unmount(Mount, bubble=False, verbose=False):
|
||||
"""Sent when a widget is unmounted and may not longer receive messages."""
|
||||
|
||||
|
||||
class Prune(Event, bubble=False):
|
||||
"""Sent to the app to ask it to prune one or more widgets from the DOM.
|
||||
|
||||
Attributes:
|
||||
widgets (list[Widgets]): The list of widgets to prune.
|
||||
finished_flag (asyncio.Event): An asyncio Event to that will be flagged when the prune is done.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self, sender: MessageTarget, widgets: list[Widget], finished_flag: asyncio.Event
|
||||
) -> None:
|
||||
"""Initialise the event.
|
||||
|
||||
Args:
|
||||
widgets (list[Widgets]): The list of widgets to prune.
|
||||
finished_flag (asyncio.Event): An asyncio Event to that will be flagged when the prune is done.
|
||||
"""
|
||||
super().__init__(sender)
|
||||
self.finished_flag = finished_flag
|
||||
self.widgets = widgets
|
||||
|
||||
|
||||
class Show(Event, bubble=False):
|
||||
"""Sent when a widget has become visible."""
|
||||
|
||||
|
||||
@@ -284,7 +284,7 @@ class MessagePump(metaclass=MessagePumpMeta):
|
||||
async def _on_close_messages(self, message: messages.CloseMessages) -> None:
|
||||
await self._close_messages()
|
||||
|
||||
async def _close_messages(self) -> None:
|
||||
async def _close_messages(self, wait: bool = True) -> None:
|
||||
"""Close message queue, and optionally wait for queue to finish processing."""
|
||||
if self._closed or self._closing:
|
||||
return
|
||||
@@ -296,7 +296,7 @@ class MessagePump(metaclass=MessagePumpMeta):
|
||||
await self._message_queue.put(events.Unmount(sender=self))
|
||||
Reactive._reset_object(self)
|
||||
await self._message_queue.put(None)
|
||||
if self._task is not None and asyncio.current_task() != self._task:
|
||||
if wait and self._task is not None and asyncio.current_task() != self._task:
|
||||
# Ensure everything is closed before returning
|
||||
await self._task
|
||||
|
||||
|
||||
@@ -310,18 +310,19 @@ class Screen(Widget):
|
||||
# Check for any widgets marked as 'dirty' (needs a repaint)
|
||||
event.prevent_default()
|
||||
|
||||
if self.is_current:
|
||||
if self._layout_required:
|
||||
self._refresh_layout()
|
||||
self._layout_required = False
|
||||
self._dirty_widgets.clear()
|
||||
if self._repaint_required:
|
||||
self._dirty_widgets.clear()
|
||||
self._dirty_widgets.add(self)
|
||||
self._repaint_required = False
|
||||
async with self.app._dom_lock:
|
||||
if self.is_current:
|
||||
if self._layout_required:
|
||||
self._refresh_layout()
|
||||
self._layout_required = False
|
||||
self._dirty_widgets.clear()
|
||||
if self._repaint_required:
|
||||
self._dirty_widgets.clear()
|
||||
self._dirty_widgets.add(self)
|
||||
self._repaint_required = False
|
||||
|
||||
if self._dirty_widgets:
|
||||
self.update_timer.resume()
|
||||
if self._dirty_widgets:
|
||||
self.update_timer.resume()
|
||||
|
||||
# The Screen is idle - a good opportunity to invoke the scheduled callbacks
|
||||
await self._invoke_and_clear_callbacks()
|
||||
|
||||
@@ -75,8 +75,9 @@ class ScrollView(Widget):
|
||||
):
|
||||
self._size = size
|
||||
virtual_size = self.virtual_size
|
||||
self._scroll_update(virtual_size)
|
||||
self._container_size = size - self.styles.gutter.totals
|
||||
self._scroll_update(virtual_size)
|
||||
|
||||
self.scroll_to(self.scroll_x, self.scroll_y, animate=False)
|
||||
self.refresh()
|
||||
|
||||
|
||||
@@ -1,19 +1,22 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from asyncio import Event as AsyncEvent
|
||||
from asyncio import Lock, create_task, wait
|
||||
from collections import Counter
|
||||
from asyncio import Lock, wait, create_task, Event as AsyncEvent
|
||||
from fractions import Fraction
|
||||
from itertools import islice
|
||||
from operator import attrgetter
|
||||
from typing import (
|
||||
Generator,
|
||||
TYPE_CHECKING,
|
||||
ClassVar,
|
||||
Collection,
|
||||
Generator,
|
||||
Iterable,
|
||||
NamedTuple,
|
||||
Sequence,
|
||||
TypeVar,
|
||||
cast,
|
||||
overload,
|
||||
)
|
||||
|
||||
import rich.repr
|
||||
@@ -32,7 +35,7 @@ from rich.style import Style
|
||||
from rich.text import Text
|
||||
|
||||
from . import errors, events, messages
|
||||
from ._animator import BoundAnimator, DEFAULT_EASING, Animatable, EasingFunction
|
||||
from ._animator import DEFAULT_EASING, Animatable, BoundAnimator, EasingFunction
|
||||
from ._arrange import DockArrangeResult, arrange
|
||||
from ._context import active_app
|
||||
from ._easing import DEFAULT_SCROLL_EASING
|
||||
@@ -40,9 +43,10 @@ from ._layout import Layout
|
||||
from ._segment_tools import align_lines
|
||||
from ._styles_cache import StylesCache
|
||||
from ._types import Lines
|
||||
from .binding import NoBinding
|
||||
from .await_remove import AwaitRemove
|
||||
from .binding import Binding
|
||||
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 .dom import DOMNode, NoScreen
|
||||
from .geometry import Offset, Region, Size, Spacing, clamp
|
||||
@@ -51,7 +55,6 @@ from .message import Message
|
||||
from .messages import CallbackType
|
||||
from .reactive import Reactive
|
||||
from .render import measure
|
||||
from .await_remove import AwaitRemove
|
||||
from .walk import walk_depth_first
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@@ -169,6 +172,17 @@ class Widget(DOMNode):
|
||||
|
||||
"""
|
||||
|
||||
BINDINGS = [
|
||||
Binding("up", "scroll_up", "Scroll Up", show=False),
|
||||
Binding("down", "scroll_down", "Scroll Down", show=False),
|
||||
Binding("left", "scroll_left", "Scroll Up", show=False),
|
||||
Binding("right", "scroll_right", "Scroll Right", show=False),
|
||||
Binding("home", "scroll_home", "Scroll Home", show=False),
|
||||
Binding("end", "scroll_end", "Scroll End", show=False),
|
||||
Binding("pageup", "page_up", "Page Up", show=False),
|
||||
Binding("pagedown", "page_down", "Page Down", show=False),
|
||||
]
|
||||
|
||||
DEFAULT_CSS = """
|
||||
Widget{
|
||||
scrollbar-background: $panel-darken-1;
|
||||
@@ -209,7 +223,6 @@ class Widget(DOMNode):
|
||||
id: str | None = None,
|
||||
classes: str | None = None,
|
||||
) -> None:
|
||||
|
||||
self._size = Size(0, 0)
|
||||
self._container_size = Size(0, 0)
|
||||
self._layout_required = False
|
||||
@@ -237,7 +250,7 @@ class Widget(DOMNode):
|
||||
self._arrangement_cache_key: tuple[int, Size] = (-1, Size())
|
||||
|
||||
self._styles_cache = StylesCache()
|
||||
self._rich_style_cache: dict[str, Style] = {}
|
||||
self._rich_style_cache: dict[str, tuple[Style, Style]] = {}
|
||||
self._stabilized_scrollbar_size: Size | None = None
|
||||
self._lock = Lock()
|
||||
|
||||
@@ -337,57 +350,103 @@ class Widget(DOMNode):
|
||||
def offset(self, offset: Offset) -> None:
|
||||
self.styles.offset = ScalarOffset.from_offset(offset)
|
||||
|
||||
ExpectType = TypeVar("ExpectType", bound="Widget")
|
||||
|
||||
@overload
|
||||
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.
|
||||
|
||||
Args:
|
||||
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:
|
||||
DOMNode: The first child of this node with the ID.
|
||||
ExpectType | Widget: The first child of this node with the ID.
|
||||
|
||||
Raises:
|
||||
NoMatches: if no children could be found for this ID
|
||||
WrongType: if the wrong type was found.
|
||||
"""
|
||||
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
|
||||
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:
|
||||
...
|
||||
|
||||
@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.
|
||||
Performs a depth-first search rooted at this widget.
|
||||
|
||||
Args:
|
||||
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:
|
||||
DOMNode: The first descendant encountered with this ID.
|
||||
ExpectType | Widget: The first descendant encountered with this ID.
|
||||
|
||||
Raises:
|
||||
NoMatches: if no children could be found for this ID
|
||||
WrongType: if the wrong type was found.
|
||||
"""
|
||||
for child in walk_depth_first(self):
|
||||
try:
|
||||
return child.get_child_by_id(id)
|
||||
return child.get_child_by_id(id, expect_type=expect_type)
|
||||
except NoMatches:
|
||||
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}")
|
||||
|
||||
def get_component_rich_style(self, name: str) -> Style:
|
||||
def get_component_rich_style(self, name: str, *, partial: bool = False) -> Style:
|
||||
"""Get a *Rich* style for a component.
|
||||
|
||||
Args:
|
||||
name (str): Name of component.
|
||||
partial (bool, optional): Return a partial style (not combined with parent).
|
||||
|
||||
Returns:
|
||||
Style: A Rich style object.
|
||||
"""
|
||||
style = self._rich_style_cache.get(name)
|
||||
if style is None:
|
||||
style = self.get_component_styles(name).rich_style
|
||||
self._rich_style_cache[name] = style
|
||||
return style
|
||||
|
||||
if name not in self._rich_style_cache:
|
||||
component_styles = self.get_component_styles(name)
|
||||
style = component_styles.rich_style
|
||||
partial_style = component_styles.partial_rich_style
|
||||
self._rich_style_cache[name] = (style, partial_style)
|
||||
|
||||
style, partial_style = self._rich_style_cache[name]
|
||||
|
||||
return partial_style if partial else style
|
||||
|
||||
def _arrange(self, size: Size) -> DockArrangeResult:
|
||||
"""Arrange children.
|
||||
@@ -512,7 +571,7 @@ class Widget(DOMNode):
|
||||
if count > 1:
|
||||
raise MountError(
|
||||
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.
|
||||
@@ -523,15 +582,19 @@ class Widget(DOMNode):
|
||||
|
||||
# Decide the final resting place depending on what we've been asked
|
||||
# to do.
|
||||
insert_before: int | None = None
|
||||
insert_after: int | None = None
|
||||
if before is not None:
|
||||
parent, before = self._find_mount_point(before)
|
||||
parent, insert_before = self._find_mount_point(before)
|
||||
elif after is not None:
|
||||
parent, after = self._find_mount_point(after)
|
||||
parent, insert_after = self._find_mount_point(after)
|
||||
else:
|
||||
parent = self
|
||||
|
||||
return AwaitMount(
|
||||
self.app._register(parent, *widgets, before=before, after=after)
|
||||
self.app._register(
|
||||
parent, *widgets, before=insert_before, after=insert_after
|
||||
)
|
||||
)
|
||||
|
||||
def move_child(
|
||||
@@ -697,7 +760,6 @@ class Widget(DOMNode):
|
||||
Returns:
|
||||
int: The height of the content.
|
||||
"""
|
||||
|
||||
if self.is_container:
|
||||
assert self._layout is not None
|
||||
height = (
|
||||
@@ -733,14 +795,12 @@ class Widget(DOMNode):
|
||||
def watch_scroll_x(self, new_value: float) -> None:
|
||||
if self.show_horizontal_scrollbar:
|
||||
self.horizontal_scrollbar.position = int(new_value)
|
||||
self.horizontal_scrollbar.refresh()
|
||||
self.refresh(layout=True)
|
||||
self.refresh(layout=True, repaint=False)
|
||||
|
||||
def watch_scroll_y(self, new_value: float) -> None:
|
||||
if self.show_vertical_scrollbar:
|
||||
self.vertical_scrollbar.position = int(new_value)
|
||||
self.vertical_scrollbar.refresh()
|
||||
self.refresh(layout=True)
|
||||
self.refresh(layout=True, repaint=False)
|
||||
|
||||
def validate_scroll_x(self, value: float) -> float:
|
||||
return clamp(value, 0, self.max_scroll_x)
|
||||
@@ -900,8 +960,6 @@ class Widget(DOMNode):
|
||||
int: Number of rows in the horizontal scrollbar.
|
||||
"""
|
||||
styles = self.styles
|
||||
if styles.scrollbar_gutter == "stable" and styles.overflow_x == "auto":
|
||||
return styles.scrollbar_size_horizontal
|
||||
return styles.scrollbar_size_horizontal if self.show_horizontal_scrollbar else 0
|
||||
|
||||
@property
|
||||
@@ -963,6 +1021,18 @@ class Widget(DOMNode):
|
||||
content_region = self.region.shrink(self.styles.gutter)
|
||||
return content_region
|
||||
|
||||
@property
|
||||
def scrollable_content_region(self) -> Region:
|
||||
"""Gets an absolute region containing the scrollable content (minus padding, border, and scrollbars).
|
||||
|
||||
Returns:
|
||||
Region: Screen region that contains a widget's content.
|
||||
"""
|
||||
content_region = self.region.shrink(self.styles.gutter).shrink(
|
||||
self.scrollbar_gutter
|
||||
)
|
||||
return content_region
|
||||
|
||||
@property
|
||||
def content_offset(self) -> Offset:
|
||||
"""An offset from the Widget origin where the content begins.
|
||||
@@ -1728,7 +1798,7 @@ class Widget(DOMNode):
|
||||
Returns:
|
||||
Offset: The distance that was scrolled.
|
||||
"""
|
||||
window = self.content_region.at_offset(self.scroll_offset)
|
||||
window = self.scrollable_content_region.at_offset(self.scroll_offset)
|
||||
if spacing is not None:
|
||||
window = window.shrink(spacing)
|
||||
|
||||
@@ -1790,9 +1860,13 @@ class Widget(DOMNode):
|
||||
can_focus: bool | None = None,
|
||||
can_focus_children: bool | None = None,
|
||||
inherit_css: bool = True,
|
||||
inherit_bindings: bool = True,
|
||||
) -> None:
|
||||
base = cls.__mro__[0]
|
||||
super().__init_subclass__(inherit_css=inherit_css)
|
||||
super().__init_subclass__(
|
||||
inherit_css=inherit_css,
|
||||
inherit_bindings=inherit_bindings,
|
||||
)
|
||||
if issubclass(base, Widget):
|
||||
cls.can_focus = base.can_focus if can_focus is None else can_focus
|
||||
cls.can_focus_children = (
|
||||
@@ -2114,15 +2188,9 @@ class Widget(DOMNode):
|
||||
Returns:
|
||||
AwaitRemove: An awaitable object that waits for the widget to be removed.
|
||||
"""
|
||||
prune_finished_event = AsyncEvent()
|
||||
self.app.post_message_no_wait(
|
||||
events.Prune(
|
||||
self,
|
||||
widgets=self.app._detach_from_dom([self]),
|
||||
finished_flag=prune_finished_event,
|
||||
)
|
||||
)
|
||||
return AwaitRemove(prune_finished_event)
|
||||
|
||||
await_remove = self.app._remove_nodes([self])
|
||||
return await_remove
|
||||
|
||||
def render(self) -> RenderableType:
|
||||
"""Get renderable for widget.
|
||||
@@ -2233,7 +2301,7 @@ class Widget(DOMNode):
|
||||
def _on_styles_updated(self) -> None:
|
||||
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)
|
||||
|
||||
async def _on_mouse_up(self, event: events.MouseUp) -> None:
|
||||
@@ -2325,50 +2393,34 @@ class Widget(DOMNode):
|
||||
def _on_scroll_to_region(self, message: messages.ScrollToRegion) -> None:
|
||||
self.scroll_to_region(message.region, animate=True)
|
||||
|
||||
def _key_home(self) -> bool:
|
||||
def action_scroll_home(self) -> None:
|
||||
if self._allow_scroll:
|
||||
self.scroll_home()
|
||||
return True
|
||||
return False
|
||||
|
||||
def _key_end(self) -> bool:
|
||||
def action_scroll_end(self) -> None:
|
||||
if self._allow_scroll:
|
||||
self.scroll_end()
|
||||
return True
|
||||
return False
|
||||
|
||||
def _key_left(self) -> bool:
|
||||
def action_scroll_left(self) -> None:
|
||||
if self.allow_horizontal_scroll:
|
||||
self.scroll_left()
|
||||
return True
|
||||
return False
|
||||
|
||||
def _key_right(self) -> bool:
|
||||
def action_scroll_right(self) -> None:
|
||||
if self.allow_horizontal_scroll:
|
||||
self.scroll_right()
|
||||
return True
|
||||
return False
|
||||
|
||||
def _key_down(self) -> bool:
|
||||
if self.allow_vertical_scroll:
|
||||
self.scroll_down()
|
||||
return True
|
||||
return False
|
||||
|
||||
def _key_up(self) -> bool:
|
||||
def action_scroll_up(self) -> None:
|
||||
if self.allow_vertical_scroll:
|
||||
self.scroll_up()
|
||||
return True
|
||||
return False
|
||||
|
||||
def _key_pagedown(self) -> bool:
|
||||
def action_scroll_down(self) -> None:
|
||||
if self.allow_vertical_scroll:
|
||||
self.scroll_down()
|
||||
|
||||
def action_page_down(self) -> None:
|
||||
if self.allow_vertical_scroll:
|
||||
self.scroll_page_down()
|
||||
return True
|
||||
return False
|
||||
|
||||
def _key_pageup(self) -> bool:
|
||||
def action_page_up(self) -> None:
|
||||
if self.allow_vertical_scroll:
|
||||
self.scroll_page_up()
|
||||
return True
|
||||
return False
|
||||
|
||||
@@ -8,21 +8,23 @@ from ..case import camel_to_snake
|
||||
# but also to the `__init__.pyi` file in this same folder - otherwise text editors and type checkers won't
|
||||
# be able to "see" them.
|
||||
if typing.TYPE_CHECKING:
|
||||
from ..widget import Widget
|
||||
|
||||
from ._button import Button
|
||||
from ._checkbox import Checkbox
|
||||
from ._data_table import DataTable
|
||||
from ._directory_tree import DirectoryTree
|
||||
from ._footer import Footer
|
||||
from ._header import Header
|
||||
from ._input import Input
|
||||
from ._label import Label
|
||||
from ._placeholder import Placeholder
|
||||
from ._pretty import Pretty
|
||||
from ._static import Static
|
||||
from ._input import Input
|
||||
from ._text_log import TextLog
|
||||
from ._tree_control import TreeControl
|
||||
from ._tree import Tree
|
||||
from ._tree_node import TreeNode
|
||||
from ._welcome import Welcome
|
||||
from ..widget import Widget
|
||||
|
||||
__all__ = [
|
||||
"Button",
|
||||
@@ -31,13 +33,14 @@ __all__ = [
|
||||
"DirectoryTree",
|
||||
"Footer",
|
||||
"Header",
|
||||
"Input",
|
||||
"Label",
|
||||
"Placeholder",
|
||||
"Pretty",
|
||||
"Static",
|
||||
"Input",
|
||||
"TextLog",
|
||||
"TreeControl",
|
||||
"Tree",
|
||||
"TreeNode",
|
||||
"Welcome",
|
||||
]
|
||||
|
||||
|
||||
@@ -11,5 +11,6 @@ from ._pretty import Pretty as Pretty
|
||||
from ._static import Static as Static
|
||||
from ._input import Input as Input
|
||||
from ._text_log import TextLog as TextLog
|
||||
from ._tree_control import TreeControl as TreeControl
|
||||
from ._tree import Tree as Tree
|
||||
from ._tree_node import TreeNode as TreeNode
|
||||
from ._welcome import Welcome as Welcome
|
||||
|
||||
@@ -652,7 +652,7 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True):
|
||||
|
||||
def _scroll_cursor_in_to_view(self, animate: bool = False) -> None:
|
||||
region = self._get_cell_region(self.cursor_row, self.cursor_column)
|
||||
spacing = self._get_cell_border() + self.scrollbar_gutter
|
||||
spacing = self._get_cell_border()
|
||||
self.scroll_to_region(region, animate=animate, spacing=spacing)
|
||||
|
||||
def on_click(self, event: events.Click) -> None:
|
||||
|
||||
@@ -1,29 +1,61 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from functools import lru_cache
|
||||
from os import scandir
|
||||
import os.path
|
||||
from pathlib import Path
|
||||
from typing import ClassVar
|
||||
|
||||
from rich.console import RenderableType
|
||||
import rich.repr
|
||||
|
||||
from rich.text import Text
|
||||
from rich.style import Style
|
||||
from rich.text import Text, TextType
|
||||
|
||||
from ..message import Message
|
||||
from ._tree import Tree, TreeNode, TOGGLE_STYLE
|
||||
from .._types import MessageTarget
|
||||
from ._tree_control import TreeControl, TreeNode
|
||||
|
||||
|
||||
@dataclass
|
||||
class DirEntry:
|
||||
"""Attaches directory information ot a node."""
|
||||
|
||||
path: str
|
||||
is_dir: bool
|
||||
loaded: bool = False
|
||||
|
||||
|
||||
class DirectoryTree(TreeControl[DirEntry]):
|
||||
@rich.repr.auto
|
||||
class FileClick(Message, bubble=True):
|
||||
class DirectoryTree(Tree[DirEntry]):
|
||||
|
||||
COMPONENT_CLASSES: ClassVar[set[str]] = {
|
||||
"tree--label",
|
||||
"tree--guides",
|
||||
"tree--guides-hover",
|
||||
"tree--guides-selected",
|
||||
"tree--cursor",
|
||||
"tree--highlight",
|
||||
"tree--highlight-line",
|
||||
"directory-tree--folder",
|
||||
"directory-tree--file",
|
||||
"directory-tree--extension",
|
||||
"directory-tree--hidden",
|
||||
}
|
||||
|
||||
DEFAULT_CSS = """
|
||||
DirectoryTree > .directory-tree--folder {
|
||||
text-style: bold;
|
||||
}
|
||||
|
||||
DirectoryTree > .directory-tree--file {
|
||||
|
||||
}
|
||||
|
||||
DirectoryTree > .directory-tree--extension {
|
||||
text-style: italic;
|
||||
}
|
||||
|
||||
DirectoryTree > .directory-tree--hidden {
|
||||
color: $text 50%;
|
||||
}
|
||||
"""
|
||||
|
||||
class FileSelected(Message, bubble=True):
|
||||
def __init__(self, sender: MessageTarget, path: str) -> None:
|
||||
self.path = path
|
||||
super().__init__(sender)
|
||||
@@ -36,84 +68,97 @@ class DirectoryTree(TreeControl[DirEntry]):
|
||||
id: str | None = None,
|
||||
classes: str | None = None,
|
||||
) -> None:
|
||||
self.path = os.path.expanduser(path.rstrip("/"))
|
||||
label = os.path.basename(self.path)
|
||||
data = DirEntry(self.path, True)
|
||||
super().__init__(label, data, name=name, id=id, classes=classes)
|
||||
self.root.tree.guide_style = "black"
|
||||
|
||||
def render_node(self, node: TreeNode[DirEntry]) -> RenderableType:
|
||||
return self.render_tree_label(
|
||||
node,
|
||||
node.data.is_dir,
|
||||
node.expanded,
|
||||
node.is_cursor,
|
||||
node.id == self.hover_node,
|
||||
self.has_focus,
|
||||
self.path = path
|
||||
super().__init__(
|
||||
path,
|
||||
data=DirEntry(path, True),
|
||||
name=name,
|
||||
id=id,
|
||||
classes=classes,
|
||||
)
|
||||
|
||||
@lru_cache(maxsize=1024 * 32)
|
||||
def render_tree_label(
|
||||
self,
|
||||
node: TreeNode[DirEntry],
|
||||
is_dir: bool,
|
||||
expanded: bool,
|
||||
is_cursor: bool,
|
||||
is_hover: bool,
|
||||
has_focus: bool,
|
||||
) -> RenderableType:
|
||||
meta = {
|
||||
"@click": f"click_label({node.id})",
|
||||
"tree_node": node.id,
|
||||
"cursor": node.is_cursor,
|
||||
}
|
||||
label = Text(node.label) if isinstance(node.label, str) else node.label
|
||||
if is_hover:
|
||||
label.stylize("underline")
|
||||
if is_dir:
|
||||
label.stylize("bold")
|
||||
icon = "📂" if expanded else "📁"
|
||||
def process_label(self, label: TextType):
|
||||
"""Process a str or Text in to a label. Maybe overridden in a subclass to change modify how labels are rendered.
|
||||
|
||||
Args:
|
||||
label (TextType): Label.
|
||||
|
||||
Returns:
|
||||
Text: A Rich Text object.
|
||||
"""
|
||||
if isinstance(label, str):
|
||||
text_label = Text(label)
|
||||
else:
|
||||
icon = "📄"
|
||||
label.highlight_regex(r"\..*$", "italic")
|
||||
text_label = label
|
||||
first_line = text_label.split()[0]
|
||||
return first_line
|
||||
|
||||
if label.plain.startswith("."):
|
||||
label.stylize("dim")
|
||||
def render_label(self, node: TreeNode[DirEntry], base_style: Style, style: Style):
|
||||
node_label = node._label.copy()
|
||||
node_label.stylize(style)
|
||||
|
||||
if is_cursor and has_focus:
|
||||
cursor_style = self.get_component_styles("tree--cursor").rich_style
|
||||
label.stylize(cursor_style)
|
||||
if node._allow_expand:
|
||||
prefix = ("📂 " if node.is_expanded else "📁 ", base_style + TOGGLE_STYLE)
|
||||
node_label.stylize_before(
|
||||
self.get_component_rich_style("directory-tree--folder", partial=True)
|
||||
)
|
||||
else:
|
||||
prefix = (
|
||||
"📄 ",
|
||||
base_style,
|
||||
)
|
||||
node_label.stylize_before(
|
||||
self.get_component_rich_style("directory-tree--file", partial=True),
|
||||
)
|
||||
node_label.highlight_regex(
|
||||
r"\..+$",
|
||||
self.get_component_rich_style(
|
||||
"directory-tree--extension", partial=True
|
||||
),
|
||||
)
|
||||
|
||||
icon_label = Text(f"{icon} ", no_wrap=True, overflow="ellipsis") + label
|
||||
icon_label.apply_meta(meta)
|
||||
return icon_label
|
||||
if node_label.plain.startswith("."):
|
||||
node_label.stylize_before(
|
||||
self.get_component_rich_style("directory-tree--hidden")
|
||||
)
|
||||
|
||||
def on_styles_updated(self) -> None:
|
||||
self.render_tree_label.cache_clear()
|
||||
text = Text.assemble(prefix, node_label)
|
||||
return text
|
||||
|
||||
def load_directory(self, node: TreeNode[DirEntry]) -> None:
|
||||
assert node.data is not None
|
||||
dir_path = Path(node.data.path)
|
||||
node.data.loaded = True
|
||||
directory = sorted(
|
||||
list(dir_path.iterdir()),
|
||||
key=lambda path: (not path.is_dir(), path.name.lower()),
|
||||
)
|
||||
for path in directory:
|
||||
node.add(
|
||||
path.name,
|
||||
data=DirEntry(str(path), path.is_dir()),
|
||||
allow_expand=path.is_dir(),
|
||||
)
|
||||
node.expand()
|
||||
|
||||
def on_mount(self) -> None:
|
||||
self.call_after_refresh(self.load_directory, self.root)
|
||||
self.load_directory(self.root)
|
||||
|
||||
async def load_directory(self, node: TreeNode[DirEntry]):
|
||||
path = node.data.path
|
||||
directory = sorted(
|
||||
list(scandir(path)), key=lambda entry: (not entry.is_dir(), entry.name)
|
||||
)
|
||||
for entry in directory:
|
||||
node.add(entry.name, DirEntry(entry.path, entry.is_dir()))
|
||||
node.loaded = True
|
||||
node.expand()
|
||||
self.refresh(layout=True)
|
||||
|
||||
async def on_tree_control_node_selected(
|
||||
self, message: TreeControl.NodeSelected[DirEntry]
|
||||
) -> None:
|
||||
dir_entry = message.node.data
|
||||
if not dir_entry.is_dir:
|
||||
await self.emit(self.FileClick(self, dir_entry.path))
|
||||
def on_tree_node_expanded(self, event: Tree.NodeSelected) -> None:
|
||||
event.stop()
|
||||
dir_entry = event.node.data
|
||||
if dir_entry is None:
|
||||
return
|
||||
if dir_entry.is_dir:
|
||||
if not dir_entry.loaded:
|
||||
self.load_directory(event.node)
|
||||
else:
|
||||
if not message.node.loaded:
|
||||
await self.load_directory(message.node)
|
||||
message.node.expand()
|
||||
else:
|
||||
message.node.toggle()
|
||||
self.emit_no_wait(self.FileSelected(self, dir_entry.path))
|
||||
|
||||
def on_tree_node_selected(self, event: Tree.NodeSelected) -> None:
|
||||
event.stop()
|
||||
dir_entry = event.node.data
|
||||
if dir_entry is None:
|
||||
return
|
||||
if not dir_entry.is_dir:
|
||||
self.emit_no_wait(self.FileSelected(self, dir_entry.path))
|
||||
|
||||
@@ -5,3 +5,11 @@ from ._static import Static
|
||||
|
||||
class Label(Static):
|
||||
"""A simple label widget for displaying text-oriented renderables."""
|
||||
|
||||
DEFAULT_CSS = """
|
||||
Label {
|
||||
width: auto;
|
||||
height: auto;
|
||||
}
|
||||
"""
|
||||
"""str: The default styling of a `Label`."""
|
||||
|
||||
849
src/textual/widgets/_tree.py
Normal file
@@ -0,0 +1,849 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import ClassVar, Generic, NewType, TypeVar
|
||||
|
||||
import rich.repr
|
||||
from rich.segment import Segment
|
||||
from rich.style import Style, NULL_STYLE
|
||||
from rich.text import Text, TextType
|
||||
|
||||
|
||||
from ..binding import Binding
|
||||
from ..geometry import clamp, Region, Size
|
||||
from .._loop import loop_last
|
||||
from .._cache import LRUCache
|
||||
from ..message import Message
|
||||
from ..reactive import reactive, var
|
||||
from .._segment_tools import line_crop, line_pad
|
||||
from .._types import MessageTarget
|
||||
from .._typing import TypeAlias
|
||||
from ..scroll_view import ScrollView
|
||||
|
||||
from .. import events
|
||||
|
||||
NodeID = NewType("NodeID", int)
|
||||
TreeDataType = TypeVar("TreeDataType")
|
||||
EventTreeDataType = TypeVar("EventTreeDataType")
|
||||
|
||||
LineCacheKey: TypeAlias = "tuple[int | tuple, ...]"
|
||||
|
||||
TOGGLE_STYLE = Style.from_meta({"toggle": True})
|
||||
|
||||
|
||||
@dataclass
|
||||
class _TreeLine:
|
||||
path: list[TreeNode]
|
||||
last: bool
|
||||
|
||||
@property
|
||||
def node(self) -> TreeNode:
|
||||
"""TreeNode: The node associated with this line."""
|
||||
return self.path[-1]
|
||||
|
||||
def _get_guide_width(self, guide_depth: int, show_root: bool) -> int:
|
||||
"""Get the cell width of the line as rendered.
|
||||
|
||||
Args:
|
||||
guide_depth (int): The guide depth (cells in the indentation).
|
||||
|
||||
Returns:
|
||||
int: Width in cells.
|
||||
"""
|
||||
guides = max(0, len(self.path) - (1 if show_root else 2)) * guide_depth
|
||||
return guides
|
||||
|
||||
|
||||
@rich.repr.auto
|
||||
class TreeNode(Generic[TreeDataType]):
|
||||
"""An object that represents a "node" in a tree control."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
tree: Tree[TreeDataType],
|
||||
parent: TreeNode[TreeDataType] | None,
|
||||
id: NodeID,
|
||||
label: Text,
|
||||
data: TreeDataType | None = None,
|
||||
*,
|
||||
expanded: bool = True,
|
||||
allow_expand: bool = True,
|
||||
) -> None:
|
||||
self._tree = tree
|
||||
self._parent = parent
|
||||
self._id = id
|
||||
self._label = label
|
||||
self.data = data
|
||||
self._expanded = expanded
|
||||
self._children: list[TreeNode] = []
|
||||
|
||||
self._hover_ = False
|
||||
self._selected_ = False
|
||||
self._allow_expand = allow_expand
|
||||
self._updates: int = 0
|
||||
self._line: int = -1
|
||||
|
||||
def __rich_repr__(self) -> rich.repr.Result:
|
||||
yield self._label.plain
|
||||
yield self.data
|
||||
|
||||
def _reset(self) -> None:
|
||||
self._hover_ = False
|
||||
self._selected_ = False
|
||||
self._updates += 1
|
||||
|
||||
@property
|
||||
def line(self) -> int:
|
||||
"""int: Get the line number for this node, or -1 if it is not displayed."""
|
||||
return self._line
|
||||
|
||||
@property
|
||||
def _hover(self) -> bool:
|
||||
"""bool: Check if the mouse is over the node."""
|
||||
return self._hover_
|
||||
|
||||
@_hover.setter
|
||||
def _hover(self, hover: bool) -> None:
|
||||
self._updates += 1
|
||||
self._hover_ = hover
|
||||
|
||||
@property
|
||||
def _selected(self) -> bool:
|
||||
"""bool: Check if the node is selected."""
|
||||
return self._selected_
|
||||
|
||||
@_selected.setter
|
||||
def _selected(self, selected: bool) -> None:
|
||||
self._updates += 1
|
||||
self._selected_ = selected
|
||||
|
||||
@property
|
||||
def id(self) -> NodeID:
|
||||
"""NodeID: Get the node ID."""
|
||||
return self._id
|
||||
|
||||
@property
|
||||
def is_expanded(self) -> bool:
|
||||
"""bool: Check if the node is expanded."""
|
||||
return self._expanded
|
||||
|
||||
@property
|
||||
def is_last(self) -> bool:
|
||||
"""bool: Check if this is the last child."""
|
||||
if self._parent is None:
|
||||
return True
|
||||
return bool(
|
||||
self._parent._children and self._parent._children[-1] == self,
|
||||
)
|
||||
|
||||
@property
|
||||
def allow_expand(self) -> bool:
|
||||
"""bool: Check if the node is allowed to expand."""
|
||||
return self._allow_expand
|
||||
|
||||
@allow_expand.setter
|
||||
def allow_expand(self, allow_expand: bool) -> None:
|
||||
self._allow_expand = allow_expand
|
||||
self._updates += 1
|
||||
|
||||
def expand(self) -> None:
|
||||
"""Expand a node (show its children)."""
|
||||
self._expanded = True
|
||||
self._updates += 1
|
||||
self._tree._invalidate()
|
||||
|
||||
def collapse(self) -> None:
|
||||
"""Collapse the node (hide children)."""
|
||||
self._expanded = False
|
||||
self._updates += 1
|
||||
self._tree._invalidate()
|
||||
|
||||
def toggle(self) -> None:
|
||||
"""Toggle the expanded state."""
|
||||
self._expanded = not self._expanded
|
||||
self._updates += 1
|
||||
self._tree._invalidate()
|
||||
|
||||
def set_label(self, label: TextType) -> None:
|
||||
"""Set a new label for the node.
|
||||
|
||||
Args:
|
||||
label (TextType): A str or Text object with the new label.
|
||||
"""
|
||||
self._updates += 1
|
||||
text_label = self._tree.process_label(label)
|
||||
self._label = text_label
|
||||
|
||||
def add(
|
||||
self,
|
||||
label: TextType,
|
||||
data: TreeDataType | None = None,
|
||||
*,
|
||||
expand: bool = False,
|
||||
allow_expand: bool = True,
|
||||
) -> TreeNode[TreeDataType]:
|
||||
"""Add a node to the sub-tree.
|
||||
|
||||
Args:
|
||||
label (TextType): The new node's label.
|
||||
data (TreeDataType): Data associated with the new node.
|
||||
expand (bool, optional): Node should be expanded. Defaults to True.
|
||||
allow_expand (bool, optional): Allow use to expand the node via keyboard or mouse. Defaults to True.
|
||||
|
||||
Returns:
|
||||
TreeNode[TreeDataType]: A new Tree node
|
||||
"""
|
||||
text_label = self._tree.process_label(label)
|
||||
node = self._tree._add_node(self, text_label, data)
|
||||
node._expanded = expand
|
||||
node._allow_expand = allow_expand
|
||||
self._updates += 1
|
||||
self._children.append(node)
|
||||
self._tree._invalidate()
|
||||
return node
|
||||
|
||||
def add_leaf(
|
||||
self, label: TextType, data: TreeDataType | None = None
|
||||
) -> TreeNode[TreeDataType]:
|
||||
"""Add a 'leaf' node (a node that can not expand).
|
||||
|
||||
Args:
|
||||
label (TextType): Label for the node.
|
||||
data (TreeDataType | None, optional): Optional data. Defaults to None.
|
||||
|
||||
Returns:
|
||||
TreeNode[TreeDataType]: New node.
|
||||
"""
|
||||
node = self.add(label, data, expand=False, allow_expand=False)
|
||||
return node
|
||||
|
||||
|
||||
class Tree(Generic[TreeDataType], ScrollView, can_focus=True):
|
||||
|
||||
BINDINGS = [
|
||||
Binding("enter", "select_cursor", "Select", show=False),
|
||||
Binding("up", "cursor_up", "Cursor Up", show=False),
|
||||
Binding("down", "cursor_down", "Cursor Down", show=False),
|
||||
]
|
||||
|
||||
DEFAULT_CSS = """
|
||||
Tree {
|
||||
background: $panel;
|
||||
color: $text;
|
||||
}
|
||||
Tree > .tree--label {
|
||||
|
||||
}
|
||||
Tree > .tree--guides {
|
||||
color: $success-darken-3;
|
||||
}
|
||||
|
||||
Tree > .tree--guides-hover {
|
||||
color: $success;
|
||||
text-style: bold;
|
||||
}
|
||||
|
||||
Tree > .tree--guides-selected {
|
||||
color: $warning;
|
||||
text-style: bold;
|
||||
}
|
||||
|
||||
Tree > .tree--cursor {
|
||||
background: $secondary;
|
||||
color: $text;
|
||||
text-style: bold;
|
||||
}
|
||||
|
||||
Tree > .tree--highlight {
|
||||
text-style: underline;
|
||||
}
|
||||
|
||||
Tree > .tree--highlight-line {
|
||||
background: $boost;
|
||||
}
|
||||
|
||||
"""
|
||||
|
||||
COMPONENT_CLASSES: ClassVar[set[str]] = {
|
||||
"tree--label",
|
||||
"tree--guides",
|
||||
"tree--guides-hover",
|
||||
"tree--guides-selected",
|
||||
"tree--cursor",
|
||||
"tree--highlight",
|
||||
"tree--highlight-line",
|
||||
}
|
||||
|
||||
show_root = reactive(True)
|
||||
"""bool: Show the root of the tree."""
|
||||
hover_line = var(-1)
|
||||
"""int: The line number under the mouse pointer, or -1 if not under the mouse pointer."""
|
||||
cursor_line = var(-1)
|
||||
"""int: The line with the cursor, or -1 if no cursor."""
|
||||
show_guides = reactive(True)
|
||||
"""bool: Enable display of tree guide lines."""
|
||||
guide_depth = reactive(4, init=False)
|
||||
"""int: The indent depth of tree nodes."""
|
||||
auto_expand = var(True)
|
||||
"""bool: Auto expand tree nodes when clicked."""
|
||||
|
||||
LINES: dict[str, tuple[str, str, str, str]] = {
|
||||
"default": (
|
||||
" ",
|
||||
"│ ",
|
||||
"└─",
|
||||
"├─",
|
||||
),
|
||||
"bold": (
|
||||
" ",
|
||||
"┃ ",
|
||||
"┗━",
|
||||
"┣━",
|
||||
),
|
||||
"double": (
|
||||
" ",
|
||||
"║ ",
|
||||
"╚═",
|
||||
"╠═",
|
||||
),
|
||||
}
|
||||
|
||||
class NodeSelected(Generic[EventTreeDataType], Message, bubble=True):
|
||||
"""Event sent when a node is selected."""
|
||||
|
||||
def __init__(
|
||||
self, sender: MessageTarget, node: TreeNode[EventTreeDataType]
|
||||
) -> None:
|
||||
self.node = node
|
||||
super().__init__(sender)
|
||||
|
||||
class NodeExpanded(Generic[EventTreeDataType], Message, bubble=True):
|
||||
"""Event sent when a node is expanded."""
|
||||
|
||||
def __init__(
|
||||
self, sender: MessageTarget, node: TreeNode[EventTreeDataType]
|
||||
) -> None:
|
||||
self.node = node
|
||||
super().__init__(sender)
|
||||
|
||||
class NodeCollapsed(Generic[EventTreeDataType], Message, bubble=True):
|
||||
"""Event sent when a node is collapsed."""
|
||||
|
||||
def __init__(
|
||||
self, sender: MessageTarget, node: TreeNode[EventTreeDataType]
|
||||
) -> None:
|
||||
self.node = node
|
||||
super().__init__(sender)
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
label: TextType,
|
||||
data: TreeDataType | None = None,
|
||||
*,
|
||||
name: str | None = None,
|
||||
id: str | None = None,
|
||||
classes: str | None = None,
|
||||
) -> None:
|
||||
super().__init__(name=name, id=id, classes=classes)
|
||||
|
||||
text_label = self.process_label(label)
|
||||
|
||||
self._updates = 0
|
||||
self._nodes: dict[NodeID, TreeNode[TreeDataType]] = {}
|
||||
self._current_id = 0
|
||||
self.root = self._add_node(None, text_label, data)
|
||||
|
||||
self._line_cache: LRUCache[LineCacheKey, list[Segment]] = LRUCache(1024)
|
||||
self._tree_lines_cached: list[_TreeLine] | None = None
|
||||
self._cursor_node: TreeNode[TreeDataType] | None = None
|
||||
|
||||
@property
|
||||
def cursor_node(self) -> TreeNode[TreeDataType] | None:
|
||||
"""TreeNode | Node: The currently selected node, or ``None`` if no selection."""
|
||||
return self._cursor_node
|
||||
|
||||
@property
|
||||
def last_line(self) -> int:
|
||||
"""int: the index of the last line."""
|
||||
return len(self._tree_lines) - 1
|
||||
|
||||
def process_label(self, label: TextType):
|
||||
"""Process a str or Text in to a label. Maybe overridden in a subclass to change modify how labels are rendered.
|
||||
|
||||
Args:
|
||||
label (TextType): Label.
|
||||
|
||||
Returns:
|
||||
Text: A Rich Text object.
|
||||
"""
|
||||
if isinstance(label, str):
|
||||
text_label = Text.from_markup(label)
|
||||
else:
|
||||
text_label = label
|
||||
first_line = text_label.split()[0]
|
||||
return first_line
|
||||
|
||||
def _add_node(
|
||||
self,
|
||||
parent: TreeNode[TreeDataType] | None,
|
||||
label: Text,
|
||||
data: TreeDataType | None,
|
||||
expand: bool = False,
|
||||
) -> TreeNode[TreeDataType]:
|
||||
node = TreeNode(self, parent, self._new_id(), label, data, expanded=expand)
|
||||
self._nodes[node._id] = node
|
||||
self._updates += 1
|
||||
return node
|
||||
|
||||
def render_label(
|
||||
self, node: TreeNode[TreeDataType], base_style: Style, style: Style
|
||||
) -> Text:
|
||||
"""Render a label for the given node. Override this to modify how labels are rendered.
|
||||
|
||||
Args:
|
||||
node (TreeNode[TreeDataType]): A tree node.
|
||||
base_style (Style): The base style of the widget.
|
||||
style (Style): The additional style for the label.
|
||||
|
||||
Returns:
|
||||
Text: A Rich Text object containing the label.
|
||||
"""
|
||||
node_label = node._label.copy()
|
||||
node_label.stylize(style)
|
||||
|
||||
if node._allow_expand:
|
||||
prefix = (
|
||||
"▼ " if node.is_expanded else "▶ ",
|
||||
base_style + TOGGLE_STYLE,
|
||||
)
|
||||
else:
|
||||
prefix = ("", base_style)
|
||||
|
||||
text = Text.assemble(prefix, node_label)
|
||||
return text
|
||||
|
||||
def get_label_width(self, node: TreeNode[TreeDataType]) -> int:
|
||||
"""Get the width of the nodes label.
|
||||
|
||||
The default behavior is to call `render_node` and return the cell length. This method may be
|
||||
overridden in a sub-class if it can be done more efficiently.
|
||||
|
||||
Args:
|
||||
node (TreeNode[TreeDataType]): A node.
|
||||
|
||||
Returns:
|
||||
int: Width in cells.
|
||||
"""
|
||||
label = self.render_label(node, NULL_STYLE, NULL_STYLE)
|
||||
return label.cell_len
|
||||
|
||||
def clear(self) -> None:
|
||||
"""Clear all nodes under root."""
|
||||
self._tree_lines_cached = None
|
||||
self._current_id = 0
|
||||
root_label = self.root._label
|
||||
root_data = self.root.data
|
||||
self.root = TreeNode(
|
||||
self,
|
||||
None,
|
||||
self._new_id(),
|
||||
root_label,
|
||||
root_data,
|
||||
expanded=True,
|
||||
)
|
||||
self._updates += 1
|
||||
self.refresh()
|
||||
|
||||
def select_node(self, node: TreeNode | None) -> None:
|
||||
"""Move the cursor to the given node, or reset cursor.
|
||||
|
||||
Args:
|
||||
node (TreeNode | None): A tree node, or None to reset cursor.
|
||||
"""
|
||||
self.cursor_line = -1 if node is None else node._line
|
||||
|
||||
def get_node_at_line(self, line_no: int) -> TreeNode[TreeDataType] | None:
|
||||
"""Get the node for a given line.
|
||||
|
||||
Args:
|
||||
line_no (int): A line number.
|
||||
|
||||
Returns:
|
||||
TreeNode[TreeDataType] | None: A tree node, or ``None`` if there is no node at that line.
|
||||
"""
|
||||
try:
|
||||
line = self._tree_lines[line_no]
|
||||
except IndexError:
|
||||
return None
|
||||
else:
|
||||
return line.node
|
||||
|
||||
def validate_cursor_line(self, value: int) -> int:
|
||||
"""Prevent cursor line from going outside of range."""
|
||||
return clamp(value, 0, len(self._tree_lines) - 1)
|
||||
|
||||
def validate_guide_depth(self, value: int) -> int:
|
||||
"""Restrict guide depth to reasonable range."""
|
||||
return clamp(value, 2, 10)
|
||||
|
||||
def _invalidate(self) -> None:
|
||||
"""Invalidate caches."""
|
||||
self._line_cache.clear()
|
||||
self._tree_lines_cached = None
|
||||
self._updates += 1
|
||||
self.root._reset()
|
||||
self.refresh(layout=True)
|
||||
|
||||
def _on_mouse_move(self, event: events.MouseMove):
|
||||
meta = event.style.meta
|
||||
if meta and "line" in meta:
|
||||
self.hover_line = meta["line"]
|
||||
else:
|
||||
self.hover_line = -1
|
||||
|
||||
def _new_id(self) -> NodeID:
|
||||
"""Create a new node ID.
|
||||
|
||||
Returns:
|
||||
NodeID: A unique node ID.
|
||||
"""
|
||||
id = self._current_id
|
||||
self._current_id += 1
|
||||
return NodeID(id)
|
||||
|
||||
def _get_node(self, line: int) -> TreeNode[TreeDataType] | None:
|
||||
try:
|
||||
tree_line = self._tree_lines[line]
|
||||
except IndexError:
|
||||
return None
|
||||
else:
|
||||
return tree_line.node
|
||||
|
||||
def watch_hover_line(self, previous_hover_line: int, hover_line: int) -> None:
|
||||
previous_node = self._get_node(previous_hover_line)
|
||||
if previous_node is not None:
|
||||
self._refresh_node(previous_node)
|
||||
previous_node._hover = False
|
||||
|
||||
node = self._get_node(hover_line)
|
||||
if node is not None:
|
||||
self._refresh_node(node)
|
||||
node._hover = True
|
||||
|
||||
def watch_cursor_line(self, previous_line: int, line: int) -> None:
|
||||
previous_node = self._get_node(previous_line)
|
||||
if previous_node is not None:
|
||||
self._refresh_node(previous_node)
|
||||
previous_node._selected = False
|
||||
self._cursor_node = None
|
||||
|
||||
node = self._get_node(line)
|
||||
if node is not None:
|
||||
self._refresh_node(node)
|
||||
node._selected = True
|
||||
self._cursor_node = node
|
||||
|
||||
def watch_guide_depth(self, guide_depth: int) -> None:
|
||||
self._invalidate()
|
||||
|
||||
def watch_show_root(self, show_root: bool) -> None:
|
||||
self.cursor_line = -1
|
||||
self._invalidate()
|
||||
|
||||
def scroll_to_line(self, line: int) -> None:
|
||||
"""Scroll to the given line.
|
||||
|
||||
Args:
|
||||
line (int): A line number.
|
||||
"""
|
||||
self.scroll_to_region(Region(0, line, self.size.width, 1))
|
||||
|
||||
def scroll_to_node(self, node: TreeNode) -> None:
|
||||
"""Scroll to the given node.
|
||||
|
||||
Args:
|
||||
node (TreeNode): Node to scroll in to view.
|
||||
"""
|
||||
line = node._line
|
||||
if line != -1:
|
||||
self.scroll_to_line(line)
|
||||
|
||||
def refresh_line(self, line: int) -> None:
|
||||
"""Refresh (repaint) a given line in the tree.
|
||||
|
||||
Args:
|
||||
line (int): Line number.
|
||||
"""
|
||||
region = Region(0, line - self.scroll_offset.y, self.size.width, 1)
|
||||
self.refresh(region)
|
||||
|
||||
def _refresh_node_line(self, line: int) -> None:
|
||||
node = self._get_node(line)
|
||||
if node is not None:
|
||||
self._refresh_node(node)
|
||||
|
||||
def _refresh_node(self, node: TreeNode[TreeDataType]) -> None:
|
||||
"""Refresh a node and all its children.
|
||||
|
||||
Args:
|
||||
node (TreeNode[TreeDataType]): A tree node.
|
||||
"""
|
||||
scroll_y = self.scroll_offset.y
|
||||
height = self.size.height
|
||||
visible_lines = self._tree_lines[scroll_y : scroll_y + height]
|
||||
for line_no, line in enumerate(visible_lines, scroll_y):
|
||||
if node in line.path:
|
||||
self.refresh_line(line_no)
|
||||
|
||||
@property
|
||||
def _tree_lines(self) -> list[_TreeLine]:
|
||||
if self._tree_lines_cached is None:
|
||||
self._build()
|
||||
assert self._tree_lines_cached is not None
|
||||
return self._tree_lines_cached
|
||||
|
||||
def _build(self) -> None:
|
||||
"""Builds the tree by traversing nodes, and creating tree lines."""
|
||||
|
||||
TreeLine = _TreeLine
|
||||
lines: list[_TreeLine] = []
|
||||
add_line = lines.append
|
||||
|
||||
root = self.root
|
||||
|
||||
def add_node(path: list[TreeNode], node: TreeNode, last: bool) -> None:
|
||||
child_path = [*path, node]
|
||||
node._line = len(lines)
|
||||
add_line(TreeLine(child_path, last))
|
||||
if node._expanded:
|
||||
for last, child in loop_last(node._children):
|
||||
add_node(child_path, child, last)
|
||||
|
||||
if self.show_root:
|
||||
add_node([], root, True)
|
||||
else:
|
||||
for node in self.root._children:
|
||||
add_node([], node, True)
|
||||
self._tree_lines_cached = lines
|
||||
|
||||
guide_depth = self.guide_depth
|
||||
show_root = self.show_root
|
||||
get_label_width = self.get_label_width
|
||||
|
||||
def get_line_width(line: _TreeLine) -> int:
|
||||
return get_label_width(line.node) + line._get_guide_width(
|
||||
guide_depth, show_root
|
||||
)
|
||||
|
||||
if lines:
|
||||
width = max([get_line_width(line) for line in lines])
|
||||
else:
|
||||
width = self.size.width
|
||||
|
||||
self.virtual_size = Size(width, len(lines))
|
||||
if self.cursor_line != -1:
|
||||
if self.cursor_node is not None:
|
||||
self.cursor_line = self.cursor_node._line
|
||||
if self.cursor_line >= len(lines):
|
||||
self.cursor_line = -1
|
||||
self.refresh()
|
||||
|
||||
def render_line(self, y: int) -> list[Segment]:
|
||||
width = self.size.width
|
||||
scroll_x, scroll_y = self.scroll_offset
|
||||
style = self.rich_style
|
||||
return self._render_line(
|
||||
y + scroll_y,
|
||||
scroll_x,
|
||||
scroll_x + width,
|
||||
style,
|
||||
)
|
||||
|
||||
def _render_line(
|
||||
self, y: int, x1: int, x2: int, base_style: Style
|
||||
) -> list[Segment]:
|
||||
tree_lines = self._tree_lines
|
||||
width = self.size.width
|
||||
|
||||
if y >= len(tree_lines):
|
||||
return [Segment(" " * width, base_style)]
|
||||
|
||||
line = tree_lines[y]
|
||||
|
||||
is_hover = self.hover_line >= 0 and any(node._hover for node in line.path)
|
||||
|
||||
cache_key = (
|
||||
y,
|
||||
is_hover,
|
||||
width,
|
||||
self._updates,
|
||||
self.has_focus,
|
||||
tuple(node._updates for node in line.path),
|
||||
)
|
||||
if cache_key in self._line_cache:
|
||||
segments = self._line_cache[cache_key]
|
||||
else:
|
||||
base_guide_style = self.get_component_rich_style(
|
||||
"tree--guides", partial=True
|
||||
)
|
||||
guide_hover_style = base_guide_style + self.get_component_rich_style(
|
||||
"tree--guides-hover", partial=True
|
||||
)
|
||||
guide_selected_style = base_guide_style + self.get_component_rich_style(
|
||||
"tree--guides-selected", partial=True
|
||||
)
|
||||
|
||||
hover = self.root._hover
|
||||
selected = self.root._selected and self.has_focus
|
||||
|
||||
def get_guides(style: Style) -> tuple[str, str, str, str]:
|
||||
"""Get the guide strings for a given style.
|
||||
|
||||
Args:
|
||||
style (Style): A Style object.
|
||||
|
||||
Returns:
|
||||
tuple[str, str, str, str]: Strings for space, vertical, terminator and cross.
|
||||
"""
|
||||
if self.show_guides:
|
||||
lines = self.LINES["default"]
|
||||
if style.bold:
|
||||
lines = self.LINES["bold"]
|
||||
elif style.underline2:
|
||||
lines = self.LINES["double"]
|
||||
else:
|
||||
lines = (" ", " ", " ", " ")
|
||||
|
||||
guide_depth = max(0, self.guide_depth - 2)
|
||||
lines = tuple(
|
||||
f"{vertical}{horizontal * guide_depth} "
|
||||
for vertical, horizontal in lines
|
||||
)
|
||||
return lines
|
||||
|
||||
if is_hover:
|
||||
line_style = self.get_component_rich_style("tree--highlight-line")
|
||||
else:
|
||||
line_style = base_style
|
||||
|
||||
guides = Text(style=line_style)
|
||||
guides_append = guides.append
|
||||
|
||||
guide_style = base_guide_style
|
||||
for node in line.path[1:]:
|
||||
if hover:
|
||||
guide_style = guide_hover_style
|
||||
if selected:
|
||||
guide_style = guide_selected_style
|
||||
|
||||
space, vertical, _, _ = get_guides(guide_style)
|
||||
guide = space if node.is_last else vertical
|
||||
if node != line.path[-1]:
|
||||
guides_append(guide, style=guide_style)
|
||||
hover = hover or node._hover
|
||||
selected = (selected or node._selected) and self.has_focus
|
||||
|
||||
if len(line.path) > 1:
|
||||
_, _, terminator, cross = get_guides(guide_style)
|
||||
if line.last:
|
||||
guides.append(terminator, style=guide_style)
|
||||
else:
|
||||
guides.append(cross, style=guide_style)
|
||||
|
||||
label_style = self.get_component_rich_style("tree--label", partial=True)
|
||||
if self.hover_line == y:
|
||||
label_style += self.get_component_rich_style(
|
||||
"tree--highlight", partial=True
|
||||
)
|
||||
if self.cursor_line == y and self.has_focus:
|
||||
label_style += self.get_component_rich_style(
|
||||
"tree--cursor", partial=False
|
||||
)
|
||||
|
||||
label = self.render_label(line.path[-1], line_style, label_style).copy()
|
||||
label.stylize(Style(meta={"node": line.node._id, "line": y}))
|
||||
guides.append(label)
|
||||
|
||||
segments = list(guides.render(self.app.console))
|
||||
pad_width = max(self.virtual_size.width, width)
|
||||
segments = line_pad(segments, 0, pad_width - guides.cell_len, line_style)
|
||||
self._line_cache[cache_key] = segments
|
||||
|
||||
segments = line_crop(segments, x1, x2, width)
|
||||
|
||||
return segments
|
||||
|
||||
def _on_resize(self, event: events.Resize) -> None:
|
||||
self._line_cache.grow(event.size.height)
|
||||
self._invalidate()
|
||||
|
||||
def _toggle_node(self, node: TreeNode[TreeDataType]) -> None:
|
||||
if not node.allow_expand:
|
||||
return
|
||||
if node.is_expanded:
|
||||
node.collapse()
|
||||
self.post_message_no_wait(self.NodeCollapsed(self, node))
|
||||
else:
|
||||
node.expand()
|
||||
self.post_message_no_wait(self.NodeExpanded(self, node))
|
||||
|
||||
async def _on_click(self, event: events.Click) -> None:
|
||||
meta = event.style.meta
|
||||
if "line" in meta:
|
||||
cursor_line = meta["line"]
|
||||
if meta.get("toggle", False):
|
||||
node = self.get_node_at_line(cursor_line)
|
||||
if node is not None and self.auto_expand:
|
||||
self._toggle_node(node)
|
||||
|
||||
else:
|
||||
self.cursor_line = cursor_line
|
||||
await self.action("select_cursor")
|
||||
|
||||
def _on_styles_updated(self) -> None:
|
||||
self._invalidate()
|
||||
|
||||
def action_cursor_up(self) -> None:
|
||||
if self.cursor_line == -1:
|
||||
self.cursor_line = self.last_line
|
||||
else:
|
||||
self.cursor_line -= 1
|
||||
self.scroll_to_line(self.cursor_line)
|
||||
|
||||
def action_cursor_down(self) -> None:
|
||||
if self.cursor_line == -1:
|
||||
self.cursor_line = 0
|
||||
else:
|
||||
self.cursor_line += 1
|
||||
self.scroll_to_line(self.cursor_line)
|
||||
|
||||
def action_page_down(self) -> None:
|
||||
if self.cursor_line == -1:
|
||||
self.cursor_line = 0
|
||||
self.cursor_line += self.scrollable_content_region.height - 1
|
||||
self.scroll_to_line(self.cursor_line)
|
||||
|
||||
def action_page_up(self) -> None:
|
||||
if self.cursor_line == -1:
|
||||
self.cursor_line = self.last_line
|
||||
self.cursor_line -= self.scrollable_content_region.height - 1
|
||||
self.scroll_to_line(self.cursor_line)
|
||||
|
||||
def action_scroll_home(self) -> None:
|
||||
self.cursor_line = 0
|
||||
self.scroll_to_line(self.cursor_line)
|
||||
|
||||
def action_scroll_end(self) -> None:
|
||||
self.cursor_line = self.last_line
|
||||
self.scroll_to_line(self.cursor_line)
|
||||
|
||||
def action_select_cursor(self) -> None:
|
||||
try:
|
||||
line = self._tree_lines[self.cursor_line]
|
||||
except IndexError:
|
||||
pass
|
||||
else:
|
||||
node = line.path[-1]
|
||||
if self.auto_expand:
|
||||
self._toggle_node(node)
|
||||
self.post_message_no_wait(self.NodeSelected(self, node))
|
||||
@@ -1,427 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
from typing import ClassVar, Generic, Iterator, NewType, TypeVar
|
||||
|
||||
import rich.repr
|
||||
from rich.console import RenderableType
|
||||
from rich.style import Style, NULL_STYLE
|
||||
from rich.text import Text, TextType
|
||||
from rich.tree import Tree
|
||||
|
||||
from ..geometry import Region, Size
|
||||
from .. import events
|
||||
from ..reactive import Reactive
|
||||
from .._types import MessageTarget
|
||||
from ..widgets import Static
|
||||
from ..message import Message
|
||||
from .. import messages
|
||||
|
||||
|
||||
NodeID = NewType("NodeID", int)
|
||||
|
||||
|
||||
NodeDataType = TypeVar("NodeDataType")
|
||||
EventNodeDataType = TypeVar("EventNodeDataType")
|
||||
|
||||
|
||||
@rich.repr.auto
|
||||
class TreeNode(Generic[NodeDataType]):
|
||||
def __init__(
|
||||
self,
|
||||
parent: TreeNode[NodeDataType] | None,
|
||||
node_id: NodeID,
|
||||
control: TreeControl,
|
||||
tree: Tree,
|
||||
label: TextType,
|
||||
data: NodeDataType,
|
||||
) -> None:
|
||||
self.parent = parent
|
||||
self.id = node_id
|
||||
self._control = control
|
||||
self._tree = tree
|
||||
self.label = label
|
||||
self.data = data
|
||||
self.loaded = False
|
||||
self._expanded = False
|
||||
self._empty = False
|
||||
self._tree.expanded = False
|
||||
self.children: list[TreeNode] = []
|
||||
|
||||
def __rich_repr__(self) -> rich.repr.Result:
|
||||
yield "id", self.id
|
||||
yield "label", self.label
|
||||
yield "data", self.data
|
||||
|
||||
@property
|
||||
def control(self) -> TreeControl:
|
||||
return self._control
|
||||
|
||||
@property
|
||||
def empty(self) -> bool:
|
||||
return self._empty
|
||||
|
||||
@property
|
||||
def expanded(self) -> bool:
|
||||
return self._expanded
|
||||
|
||||
@property
|
||||
def is_cursor(self) -> bool:
|
||||
return self.control.cursor == self.id and self.control.show_cursor
|
||||
|
||||
@property
|
||||
def tree(self) -> Tree:
|
||||
return self._tree
|
||||
|
||||
@property
|
||||
def next_node(self) -> TreeNode[NodeDataType] | None:
|
||||
"""The next node in the tree, or None if at the end."""
|
||||
|
||||
if self.expanded and self.children:
|
||||
return self.children[0]
|
||||
else:
|
||||
|
||||
sibling = self.next_sibling
|
||||
if sibling is not None:
|
||||
return sibling
|
||||
|
||||
node = self
|
||||
while True:
|
||||
if node.parent is None:
|
||||
return None
|
||||
sibling = node.parent.next_sibling
|
||||
if sibling is not None:
|
||||
return sibling
|
||||
else:
|
||||
node = node.parent
|
||||
|
||||
@property
|
||||
def previous_node(self) -> TreeNode[NodeDataType] | None:
|
||||
"""The previous node in the tree, or None if at the end."""
|
||||
|
||||
sibling = self.previous_sibling
|
||||
if sibling is not None:
|
||||
|
||||
def last_sibling(node) -> TreeNode[NodeDataType]:
|
||||
if node.expanded and node.children:
|
||||
return last_sibling(node.children[-1])
|
||||
else:
|
||||
return (
|
||||
node.children[-1] if (node.children and node.expanded) else node
|
||||
)
|
||||
|
||||
return last_sibling(sibling)
|
||||
|
||||
if self.parent is None:
|
||||
return None
|
||||
return self.parent
|
||||
|
||||
@property
|
||||
def next_sibling(self) -> TreeNode[NodeDataType] | None:
|
||||
"""The next sibling, or None if last sibling."""
|
||||
if self.parent is None:
|
||||
return None
|
||||
iter_siblings = iter(self.parent.children)
|
||||
try:
|
||||
for node in iter_siblings:
|
||||
if node is self:
|
||||
return next(iter_siblings)
|
||||
except StopIteration:
|
||||
pass
|
||||
return None
|
||||
|
||||
@property
|
||||
def previous_sibling(self) -> TreeNode[NodeDataType] | None:
|
||||
"""Previous sibling or None if first sibling."""
|
||||
if self.parent is None:
|
||||
return None
|
||||
iter_siblings = iter(self.parent.children)
|
||||
sibling: TreeNode[NodeDataType] | None = None
|
||||
|
||||
for node in iter_siblings:
|
||||
if node is self:
|
||||
return sibling
|
||||
sibling = node
|
||||
return None
|
||||
|
||||
def expand(self, expanded: bool = True) -> None:
|
||||
self._expanded = expanded
|
||||
self._tree.expanded = expanded
|
||||
self._control.refresh(layout=True)
|
||||
|
||||
def toggle(self) -> None:
|
||||
self.expand(not self._expanded)
|
||||
|
||||
def add(self, label: TextType, data: NodeDataType) -> None:
|
||||
self._control.add(self.id, label, data=data)
|
||||
self._control.refresh(layout=True)
|
||||
self._empty = False
|
||||
|
||||
def __rich__(self) -> RenderableType:
|
||||
return self._control.render_node(self)
|
||||
|
||||
|
||||
class TreeControl(Generic[NodeDataType], Static, can_focus=True):
|
||||
DEFAULT_CSS = """
|
||||
TreeControl {
|
||||
color: $text;
|
||||
height: auto;
|
||||
width: 100%;
|
||||
link-style: not underline;
|
||||
}
|
||||
|
||||
TreeControl > .tree--guides {
|
||||
color: $success;
|
||||
}
|
||||
|
||||
TreeControl > .tree--guides-highlight {
|
||||
color: $success;
|
||||
text-style: uu;
|
||||
}
|
||||
|
||||
TreeControl > .tree--guides-cursor {
|
||||
color: $secondary;
|
||||
text-style: bold;
|
||||
}
|
||||
|
||||
TreeControl > .tree--labels {
|
||||
color: $text;
|
||||
}
|
||||
|
||||
TreeControl > .tree--cursor {
|
||||
background: $secondary;
|
||||
color: $text;
|
||||
}
|
||||
|
||||
"""
|
||||
|
||||
COMPONENT_CLASSES: ClassVar[set[str]] = {
|
||||
"tree--guides",
|
||||
"tree--guides-highlight",
|
||||
"tree--guides-cursor",
|
||||
"tree--labels",
|
||||
"tree--cursor",
|
||||
}
|
||||
|
||||
class NodeSelected(Generic[EventNodeDataType], Message, bubble=False):
|
||||
def __init__(
|
||||
self, sender: MessageTarget, node: TreeNode[EventNodeDataType]
|
||||
) -> None:
|
||||
self.node = node
|
||||
super().__init__(sender)
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
label: TextType,
|
||||
data: NodeDataType,
|
||||
*,
|
||||
name: str | None = None,
|
||||
id: str | None = None,
|
||||
classes: str | None = None,
|
||||
) -> None:
|
||||
super().__init__(name=name, id=id, classes=classes)
|
||||
self.data = data
|
||||
|
||||
self.node_id = NodeID(0)
|
||||
self.nodes: dict[NodeID, TreeNode[NodeDataType]] = {}
|
||||
self._tree = Tree(label)
|
||||
|
||||
self.root: TreeNode[NodeDataType] = TreeNode(
|
||||
None, self.node_id, self, self._tree, label, data
|
||||
)
|
||||
|
||||
self._tree.label = self.root
|
||||
self.nodes[NodeID(self.node_id)] = self.root
|
||||
|
||||
self.auto_links = False
|
||||
|
||||
hover_node: Reactive[NodeID | None] = Reactive(None)
|
||||
cursor: Reactive[NodeID] = Reactive(NodeID(0))
|
||||
cursor_line: Reactive[int] = Reactive(0)
|
||||
show_cursor: Reactive[bool] = Reactive(False)
|
||||
|
||||
def watch_cursor_line(self, value: int) -> None:
|
||||
line_region = Region(0, value, self.size.width, 1)
|
||||
self.emit_no_wait(messages.ScrollToRegion(self, line_region))
|
||||
|
||||
def get_content_height(self, container: Size, viewport: Size, width: int) -> int:
|
||||
def get_size(tree: Tree) -> int:
|
||||
return 1 + sum(
|
||||
get_size(child) if child.expanded else 1 for child in tree.children
|
||||
)
|
||||
|
||||
size = get_size(self._tree)
|
||||
return size
|
||||
|
||||
def add(
|
||||
self,
|
||||
node_id: NodeID,
|
||||
label: TextType,
|
||||
data: NodeDataType,
|
||||
) -> None:
|
||||
|
||||
parent = self.nodes[node_id]
|
||||
self.node_id = NodeID(self.node_id + 1)
|
||||
child_tree = parent._tree.add(label)
|
||||
child_tree.guide_style = self._guide_style
|
||||
child_node: TreeNode[NodeDataType] = TreeNode(
|
||||
parent, self.node_id, self, child_tree, label, data
|
||||
)
|
||||
parent.children.append(child_node)
|
||||
child_tree.label = child_node
|
||||
self.nodes[self.node_id] = child_node
|
||||
|
||||
self.refresh(layout=True)
|
||||
|
||||
def find_cursor(self) -> int | None:
|
||||
"""Find the line location for the cursor node."""
|
||||
|
||||
node_id = self.cursor
|
||||
line = 0
|
||||
|
||||
stack: list[Iterator[TreeNode[NodeDataType]]]
|
||||
stack = [iter([self.root])]
|
||||
|
||||
pop = stack.pop
|
||||
push = stack.append
|
||||
while stack:
|
||||
iter_children = pop()
|
||||
try:
|
||||
node = next(iter_children)
|
||||
except StopIteration:
|
||||
continue
|
||||
else:
|
||||
if node.id == node_id:
|
||||
return line
|
||||
line += 1
|
||||
push(iter_children)
|
||||
if node.children and node.expanded:
|
||||
push(iter(node.children))
|
||||
return None
|
||||
|
||||
def render(self) -> RenderableType:
|
||||
guide_style = self._guide_style
|
||||
|
||||
def update_guide_style(tree: Tree) -> None:
|
||||
tree.guide_style = guide_style
|
||||
for child in tree.children:
|
||||
if child.expanded:
|
||||
update_guide_style(child)
|
||||
|
||||
update_guide_style(self._tree)
|
||||
if self.hover_node is not None:
|
||||
hover = self.nodes.get(self.hover_node)
|
||||
if hover is not None:
|
||||
hover._tree.guide_style = self._highlight_guide_style
|
||||
if self.cursor is not None and self.show_cursor:
|
||||
cursor = self.nodes.get(self.cursor)
|
||||
if cursor is not None:
|
||||
cursor._tree.guide_style = self._cursor_guide_style
|
||||
return self._tree
|
||||
|
||||
def render_node(self, node: TreeNode[NodeDataType]) -> RenderableType:
|
||||
label_style = self.get_component_styles("tree--labels").rich_style
|
||||
label = (
|
||||
Text(node.label, no_wrap=True, style=label_style, overflow="ellipsis")
|
||||
if isinstance(node.label, str)
|
||||
else node.label
|
||||
)
|
||||
if node.id == self.hover_node:
|
||||
label.stylize("underline")
|
||||
label.apply_meta({"@click": f"click_label({node.id})", "tree_node": node.id})
|
||||
return label
|
||||
|
||||
def action_click_label(self, node_id: NodeID) -> None:
|
||||
node = self.nodes[node_id]
|
||||
self.cursor = node.id
|
||||
self.cursor_line = self.find_cursor() or 0
|
||||
self.show_cursor = True
|
||||
self.post_message_no_wait(self.NodeSelected(self, node))
|
||||
|
||||
def on_mount(self) -> None:
|
||||
self._tree.guide_style = self._guide_style
|
||||
|
||||
@property
|
||||
def _guide_style(self) -> Style:
|
||||
return self.get_component_rich_style("tree--guides")
|
||||
|
||||
@property
|
||||
def _highlight_guide_style(self) -> Style:
|
||||
return self.get_component_rich_style("tree--guides-highlight")
|
||||
|
||||
@property
|
||||
def _cursor_guide_style(self) -> Style:
|
||||
return self.get_component_rich_style("tree--guides-cursor")
|
||||
|
||||
def on_mouse_move(self, event: events.MouseMove) -> None:
|
||||
self.hover_node = event.style.meta.get("tree_node")
|
||||
|
||||
def key_down(self, event: events.Key) -> None:
|
||||
event.stop()
|
||||
self.cursor_down()
|
||||
|
||||
def key_up(self, event: events.Key) -> None:
|
||||
event.stop()
|
||||
self.cursor_up()
|
||||
|
||||
def key_pagedown(self) -> None:
|
||||
assert self.parent is not None
|
||||
height = self.container_viewport.height
|
||||
|
||||
cursor = self.cursor
|
||||
cursor_line = self.cursor_line
|
||||
for _ in range(height):
|
||||
cursor_node = self.nodes[cursor]
|
||||
next_node = cursor_node.next_node
|
||||
if next_node is not None:
|
||||
cursor_line += 1
|
||||
cursor = next_node.id
|
||||
self.cursor = cursor
|
||||
self.cursor_line = cursor_line
|
||||
|
||||
def key_pageup(self) -> None:
|
||||
assert self.parent is not None
|
||||
height = self.container_viewport.height
|
||||
cursor = self.cursor
|
||||
cursor_line = self.cursor_line
|
||||
for _ in range(height):
|
||||
cursor_node = self.nodes[cursor]
|
||||
previous_node = cursor_node.previous_node
|
||||
if previous_node is not None:
|
||||
cursor_line -= 1
|
||||
cursor = previous_node.id
|
||||
self.cursor = cursor
|
||||
self.cursor_line = cursor_line
|
||||
|
||||
def key_home(self) -> None:
|
||||
self.cursor_line = 0
|
||||
self.cursor = NodeID(0)
|
||||
|
||||
def key_end(self) -> None:
|
||||
self.cursor = self.nodes[NodeID(0)].children[-1].id
|
||||
self.cursor_line = self.find_cursor() or 0
|
||||
|
||||
def key_enter(self, event: events.Key) -> None:
|
||||
cursor_node = self.nodes[self.cursor]
|
||||
event.stop()
|
||||
self.post_message_no_wait(self.NodeSelected(self, cursor_node))
|
||||
|
||||
def cursor_down(self) -> None:
|
||||
if not self.show_cursor:
|
||||
self.show_cursor = True
|
||||
return
|
||||
cursor_node = self.nodes[self.cursor]
|
||||
next_node = cursor_node.next_node
|
||||
if next_node is not None:
|
||||
self.cursor_line += 1
|
||||
self.cursor = next_node.id
|
||||
|
||||
def cursor_up(self) -> None:
|
||||
if not self.show_cursor:
|
||||
self.show_cursor = True
|
||||
return
|
||||
cursor_node = self.nodes[self.cursor]
|
||||
previous_node = cursor_node.previous_node
|
||||
if previous_node is not None:
|
||||
self.cursor_line -= 1
|
||||
self.cursor = previous_node.id
|
||||
1
src/textual/widgets/_tree_node.py
Normal file
@@ -0,0 +1 @@
|
||||
from ._tree import TreeNode as TreeNode
|
||||
@@ -6807,6 +6807,324 @@
|
||||
|
||||
'''
|
||||
# ---
|
||||
# name: test_order_independence
|
||||
'''
|
||||
<svg class="rich-terminal" viewBox="0 0 994 635.5999999999999" xmlns="http://www.w3.org/2000/svg">
|
||||
<!-- Generated with Rich https://www.textualize.io -->
|
||||
<style>
|
||||
|
||||
@font-face {
|
||||
font-family: "Fira Code";
|
||||
src: local("FiraCode-Regular"),
|
||||
url("https://cdnjs.cloudflare.com/ajax/libs/firacode/6.2.0/woff2/FiraCode-Regular.woff2") format("woff2"),
|
||||
url("https://cdnjs.cloudflare.com/ajax/libs/firacode/6.2.0/woff/FiraCode-Regular.woff") format("woff");
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
}
|
||||
@font-face {
|
||||
font-family: "Fira Code";
|
||||
src: local("FiraCode-Bold"),
|
||||
url("https://cdnjs.cloudflare.com/ajax/libs/firacode/6.2.0/woff2/FiraCode-Bold.woff2") format("woff2"),
|
||||
url("https://cdnjs.cloudflare.com/ajax/libs/firacode/6.2.0/woff/FiraCode-Bold.woff") format("woff");
|
||||
font-style: bold;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.terminal-404849936-matrix {
|
||||
font-family: Fira Code, monospace;
|
||||
font-size: 20px;
|
||||
line-height: 24.4px;
|
||||
font-variant-east-asian: full-width;
|
||||
}
|
||||
|
||||
.terminal-404849936-title {
|
||||
font-size: 18px;
|
||||
font-weight: bold;
|
||||
font-family: arial;
|
||||
}
|
||||
|
||||
.terminal-404849936-r1 { fill: #ffff00 }
|
||||
.terminal-404849936-r2 { fill: #c5c8c6 }
|
||||
.terminal-404849936-r3 { fill: #e8e7e5 }
|
||||
.terminal-404849936-r4 { fill: #e1e1e1 }
|
||||
.terminal-404849936-r5 { fill: #dde8f3;font-weight: bold }
|
||||
.terminal-404849936-r6 { fill: #ddedf9 }
|
||||
</style>
|
||||
|
||||
<defs>
|
||||
<clipPath id="terminal-404849936-clip-terminal">
|
||||
<rect x="0" y="0" width="975.0" height="584.5999999999999" />
|
||||
</clipPath>
|
||||
<clipPath id="terminal-404849936-line-0">
|
||||
<rect x="0" y="1.5" width="976" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-404849936-line-1">
|
||||
<rect x="0" y="25.9" width="976" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-404849936-line-2">
|
||||
<rect x="0" y="50.3" width="976" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-404849936-line-3">
|
||||
<rect x="0" y="74.7" width="976" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-404849936-line-4">
|
||||
<rect x="0" y="99.1" width="976" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-404849936-line-5">
|
||||
<rect x="0" y="123.5" width="976" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-404849936-line-6">
|
||||
<rect x="0" y="147.9" width="976" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-404849936-line-7">
|
||||
<rect x="0" y="172.3" width="976" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-404849936-line-8">
|
||||
<rect x="0" y="196.7" width="976" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-404849936-line-9">
|
||||
<rect x="0" y="221.1" width="976" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-404849936-line-10">
|
||||
<rect x="0" y="245.5" width="976" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-404849936-line-11">
|
||||
<rect x="0" y="269.9" width="976" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-404849936-line-12">
|
||||
<rect x="0" y="294.3" width="976" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-404849936-line-13">
|
||||
<rect x="0" y="318.7" width="976" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-404849936-line-14">
|
||||
<rect x="0" y="343.1" width="976" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-404849936-line-15">
|
||||
<rect x="0" y="367.5" width="976" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-404849936-line-16">
|
||||
<rect x="0" y="391.9" width="976" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-404849936-line-17">
|
||||
<rect x="0" y="416.3" width="976" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-404849936-line-18">
|
||||
<rect x="0" y="440.7" width="976" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-404849936-line-19">
|
||||
<rect x="0" y="465.1" width="976" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-404849936-line-20">
|
||||
<rect x="0" y="489.5" width="976" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-404849936-line-21">
|
||||
<rect x="0" y="513.9" width="976" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-404849936-line-22">
|
||||
<rect x="0" y="538.3" width="976" height="24.65"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
|
||||
<rect fill="#292929" stroke="rgba(255,255,255,0.35)" stroke-width="1" x="1" y="1" width="992" height="633.6" rx="8"/><text class="terminal-404849936-title" fill="#c5c8c6" text-anchor="middle" x="496" y="27">Layers</text>
|
||||
<g transform="translate(26,22)">
|
||||
<circle cx="0" cy="0" r="7" fill="#ff5f57"/>
|
||||
<circle cx="22" cy="0" r="7" fill="#febc2e"/>
|
||||
<circle cx="44" cy="0" r="7" fill="#28c840"/>
|
||||
</g>
|
||||
|
||||
<g transform="translate(9, 41)" clip-path="url(#terminal-404849936-clip-terminal)">
|
||||
<rect fill="#ff0000" x="0" y="1.5" width="97.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#ff0000" x="97.6" y="1.5" width="414.8" height="24.65" shape-rendering="crispEdges"/><rect fill="#534838" x="512.4" y="1.5" width="341.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#534838" x="854" y="1.5" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#534838" x="866.2" y="1.5" width="0" height="24.65" shape-rendering="crispEdges"/><rect fill="#534838" x="866.2" y="1.5" width="109.8" height="24.65" shape-rendering="crispEdges"/><rect fill="#ff0000" x="0" y="25.9" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#ff0000" x="12.2" y="25.9" width="488" height="24.65" shape-rendering="crispEdges"/><rect fill="#ff0000" x="500.2" y="25.9" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="512.4" y="25.9" width="463.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#ff0000" x="0" y="50.3" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#ff0000" x="12.2" y="50.3" width="488" height="24.65" shape-rendering="crispEdges"/><rect fill="#ff0000" x="500.2" y="50.3" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="512.4" y="50.3" width="463.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#ff0000" x="0" y="74.7" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#ff0000" x="12.2" y="74.7" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#ff0000" x="36.6" y="74.7" width="366" height="24.65" shape-rendering="crispEdges"/><rect fill="#ff0000" x="402.6" y="74.7" width="97.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#ff0000" x="500.2" y="74.7" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="512.4" y="74.7" width="463.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#ff0000" x="0" y="99.1" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#ff0000" x="12.2" y="99.1" width="488" height="24.65" shape-rendering="crispEdges"/><rect fill="#ff0000" x="500.2" y="99.1" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="512.4" y="99.1" width="463.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#ff0000" x="0" y="123.5" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#ff0000" x="12.2" y="123.5" width="488" height="24.65" shape-rendering="crispEdges"/><rect fill="#ff0000" x="500.2" y="123.5" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="512.4" y="123.5" width="463.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#ff0000" x="0" y="147.9" width="512.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="512.4" y="147.9" width="463.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="0" y="172.3" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="0" y="196.7" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="0" y="221.1" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="0" y="245.5" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="0" y="269.9" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="0" y="294.3" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="0" y="318.7" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="0" y="343.1" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="0" y="367.5" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="0" y="391.9" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="0" y="416.3" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="0" y="440.7" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="0" y="465.1" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="0" y="489.5" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="0" y="513.9" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="0" y="538.3" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#0053aa" x="0" y="562.7" width="36.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#0178d4" x="36.6" y="562.7" width="183" height="24.65" shape-rendering="crispEdges"/><rect fill="#0178d4" x="219.6" y="562.7" width="756.4" height="24.65" shape-rendering="crispEdges"/>
|
||||
<g class="terminal-404849936-matrix">
|
||||
<text class="terminal-404849936-r1" x="0" y="20" textLength="97.6" clip-path="url(#terminal-404849936-line-0)">┌───────</text><text class="terminal-404849936-r1" x="97.6" y="20" textLength="414.8" clip-path="url(#terminal-404849936-line-0)">─────────────────────────────────┐</text><text class="terminal-404849936-r2" x="976" y="20" textLength="12.2" clip-path="url(#terminal-404849936-line-0)">
|
||||
</text><text class="terminal-404849936-r1" x="0" y="44.4" textLength="12.2" clip-path="url(#terminal-404849936-line-1)">│</text><text class="terminal-404849936-r1" x="500.2" y="44.4" textLength="12.2" clip-path="url(#terminal-404849936-line-1)">│</text><text class="terminal-404849936-r4" x="512.4" y="44.4" textLength="463.6" clip-path="url(#terminal-404849936-line-1)">ull of stars! My God! It's full of sta</text><text class="terminal-404849936-r2" x="976" y="44.4" textLength="12.2" clip-path="url(#terminal-404849936-line-1)">
|
||||
</text><text class="terminal-404849936-r1" x="0" y="68.8" textLength="12.2" clip-path="url(#terminal-404849936-line-2)">│</text><text class="terminal-404849936-r1" x="500.2" y="68.8" textLength="12.2" clip-path="url(#terminal-404849936-line-2)">│</text><text class="terminal-404849936-r2" x="976" y="68.8" textLength="12.2" clip-path="url(#terminal-404849936-line-2)">
|
||||
</text><text class="terminal-404849936-r1" x="0" y="93.2" textLength="12.2" clip-path="url(#terminal-404849936-line-3)">│</text><text class="terminal-404849936-r1" x="36.6" y="93.2" textLength="366" clip-path="url(#terminal-404849936-line-3)">This should float over the top</text><text class="terminal-404849936-r1" x="500.2" y="93.2" textLength="12.2" clip-path="url(#terminal-404849936-line-3)">│</text><text class="terminal-404849936-r2" x="976" y="93.2" textLength="12.2" clip-path="url(#terminal-404849936-line-3)">
|
||||
</text><text class="terminal-404849936-r1" x="0" y="117.6" textLength="12.2" clip-path="url(#terminal-404849936-line-4)">│</text><text class="terminal-404849936-r1" x="500.2" y="117.6" textLength="12.2" clip-path="url(#terminal-404849936-line-4)">│</text><text class="terminal-404849936-r2" x="976" y="117.6" textLength="12.2" clip-path="url(#terminal-404849936-line-4)">
|
||||
</text><text class="terminal-404849936-r1" x="0" y="142" textLength="12.2" clip-path="url(#terminal-404849936-line-5)">│</text><text class="terminal-404849936-r1" x="500.2" y="142" textLength="12.2" clip-path="url(#terminal-404849936-line-5)">│</text><text class="terminal-404849936-r2" x="976" y="142" textLength="12.2" clip-path="url(#terminal-404849936-line-5)">
|
||||
</text><text class="terminal-404849936-r1" x="0" y="166.4" textLength="512.4" clip-path="url(#terminal-404849936-line-6)">└────────────────────────────────────────┘</text><text class="terminal-404849936-r2" x="976" y="166.4" textLength="12.2" clip-path="url(#terminal-404849936-line-6)">
|
||||
</text><text class="terminal-404849936-r2" x="976" y="190.8" textLength="12.2" clip-path="url(#terminal-404849936-line-7)">
|
||||
</text><text class="terminal-404849936-r2" x="976" y="215.2" textLength="12.2" clip-path="url(#terminal-404849936-line-8)">
|
||||
</text><text class="terminal-404849936-r2" x="976" y="239.6" textLength="12.2" clip-path="url(#terminal-404849936-line-9)">
|
||||
</text><text class="terminal-404849936-r2" x="976" y="264" textLength="12.2" clip-path="url(#terminal-404849936-line-10)">
|
||||
</text><text class="terminal-404849936-r2" x="976" y="288.4" textLength="12.2" clip-path="url(#terminal-404849936-line-11)">
|
||||
</text><text class="terminal-404849936-r2" x="976" y="312.8" textLength="12.2" clip-path="url(#terminal-404849936-line-12)">
|
||||
</text><text class="terminal-404849936-r2" x="976" y="337.2" textLength="12.2" clip-path="url(#terminal-404849936-line-13)">
|
||||
</text><text class="terminal-404849936-r2" x="976" y="361.6" textLength="12.2" clip-path="url(#terminal-404849936-line-14)">
|
||||
</text><text class="terminal-404849936-r2" x="976" y="386" textLength="12.2" clip-path="url(#terminal-404849936-line-15)">
|
||||
</text><text class="terminal-404849936-r2" x="976" y="410.4" textLength="12.2" clip-path="url(#terminal-404849936-line-16)">
|
||||
</text><text class="terminal-404849936-r2" x="976" y="434.8" textLength="12.2" clip-path="url(#terminal-404849936-line-17)">
|
||||
</text><text class="terminal-404849936-r2" x="976" y="459.2" textLength="12.2" clip-path="url(#terminal-404849936-line-18)">
|
||||
</text><text class="terminal-404849936-r2" x="976" y="483.6" textLength="12.2" clip-path="url(#terminal-404849936-line-19)">
|
||||
</text><text class="terminal-404849936-r2" x="976" y="508" textLength="12.2" clip-path="url(#terminal-404849936-line-20)">
|
||||
</text><text class="terminal-404849936-r2" x="976" y="532.4" textLength="12.2" clip-path="url(#terminal-404849936-line-21)">
|
||||
</text><text class="terminal-404849936-r2" x="976" y="556.8" textLength="12.2" clip-path="url(#terminal-404849936-line-22)">
|
||||
</text><text class="terminal-404849936-r5" x="0" y="581.2" textLength="36.6" clip-path="url(#terminal-404849936-line-23)"> T </text><text class="terminal-404849936-r6" x="36.6" y="581.2" textLength="183" clip-path="url(#terminal-404849936-line-23)"> Toggle Screen </text>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
||||
'''
|
||||
# ---
|
||||
# name: test_order_independence_toggle
|
||||
'''
|
||||
<svg class="rich-terminal" viewBox="0 0 994 635.5999999999999" xmlns="http://www.w3.org/2000/svg">
|
||||
<!-- Generated with Rich https://www.textualize.io -->
|
||||
<style>
|
||||
|
||||
@font-face {
|
||||
font-family: "Fira Code";
|
||||
src: local("FiraCode-Regular"),
|
||||
url("https://cdnjs.cloudflare.com/ajax/libs/firacode/6.2.0/woff2/FiraCode-Regular.woff2") format("woff2"),
|
||||
url("https://cdnjs.cloudflare.com/ajax/libs/firacode/6.2.0/woff/FiraCode-Regular.woff") format("woff");
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
}
|
||||
@font-face {
|
||||
font-family: "Fira Code";
|
||||
src: local("FiraCode-Bold"),
|
||||
url("https://cdnjs.cloudflare.com/ajax/libs/firacode/6.2.0/woff2/FiraCode-Bold.woff2") format("woff2"),
|
||||
url("https://cdnjs.cloudflare.com/ajax/libs/firacode/6.2.0/woff/FiraCode-Bold.woff") format("woff");
|
||||
font-style: bold;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.terminal-1654293578-matrix {
|
||||
font-family: Fira Code, monospace;
|
||||
font-size: 20px;
|
||||
line-height: 24.4px;
|
||||
font-variant-east-asian: full-width;
|
||||
}
|
||||
|
||||
.terminal-1654293578-title {
|
||||
font-size: 18px;
|
||||
font-weight: bold;
|
||||
font-family: arial;
|
||||
}
|
||||
|
||||
.terminal-1654293578-r1 { fill: #ffff00 }
|
||||
.terminal-1654293578-r2 { fill: #c5c8c6 }
|
||||
.terminal-1654293578-r3 { fill: #e8e7e5 }
|
||||
.terminal-1654293578-r4 { fill: #e1e1e1 }
|
||||
.terminal-1654293578-r5 { fill: #dde8f3;font-weight: bold }
|
||||
.terminal-1654293578-r6 { fill: #ddedf9 }
|
||||
</style>
|
||||
|
||||
<defs>
|
||||
<clipPath id="terminal-1654293578-clip-terminal">
|
||||
<rect x="0" y="0" width="975.0" height="584.5999999999999" />
|
||||
</clipPath>
|
||||
<clipPath id="terminal-1654293578-line-0">
|
||||
<rect x="0" y="1.5" width="976" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-1654293578-line-1">
|
||||
<rect x="0" y="25.9" width="976" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-1654293578-line-2">
|
||||
<rect x="0" y="50.3" width="976" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-1654293578-line-3">
|
||||
<rect x="0" y="74.7" width="976" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-1654293578-line-4">
|
||||
<rect x="0" y="99.1" width="976" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-1654293578-line-5">
|
||||
<rect x="0" y="123.5" width="976" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-1654293578-line-6">
|
||||
<rect x="0" y="147.9" width="976" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-1654293578-line-7">
|
||||
<rect x="0" y="172.3" width="976" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-1654293578-line-8">
|
||||
<rect x="0" y="196.7" width="976" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-1654293578-line-9">
|
||||
<rect x="0" y="221.1" width="976" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-1654293578-line-10">
|
||||
<rect x="0" y="245.5" width="976" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-1654293578-line-11">
|
||||
<rect x="0" y="269.9" width="976" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-1654293578-line-12">
|
||||
<rect x="0" y="294.3" width="976" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-1654293578-line-13">
|
||||
<rect x="0" y="318.7" width="976" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-1654293578-line-14">
|
||||
<rect x="0" y="343.1" width="976" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-1654293578-line-15">
|
||||
<rect x="0" y="367.5" width="976" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-1654293578-line-16">
|
||||
<rect x="0" y="391.9" width="976" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-1654293578-line-17">
|
||||
<rect x="0" y="416.3" width="976" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-1654293578-line-18">
|
||||
<rect x="0" y="440.7" width="976" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-1654293578-line-19">
|
||||
<rect x="0" y="465.1" width="976" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-1654293578-line-20">
|
||||
<rect x="0" y="489.5" width="976" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-1654293578-line-21">
|
||||
<rect x="0" y="513.9" width="976" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-1654293578-line-22">
|
||||
<rect x="0" y="538.3" width="976" height="24.65"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
|
||||
<rect fill="#292929" stroke="rgba(255,255,255,0.35)" stroke-width="1" x="1" y="1" width="992" height="633.6" rx="8"/><text class="terminal-1654293578-title" fill="#c5c8c6" text-anchor="middle" x="496" y="27">Layers</text>
|
||||
<g transform="translate(26,22)">
|
||||
<circle cx="0" cy="0" r="7" fill="#ff5f57"/>
|
||||
<circle cx="22" cy="0" r="7" fill="#febc2e"/>
|
||||
<circle cx="44" cy="0" r="7" fill="#28c840"/>
|
||||
</g>
|
||||
|
||||
<g transform="translate(9, 41)" clip-path="url(#terminal-1654293578-clip-terminal)">
|
||||
<rect fill="#ff0000" x="0" y="1.5" width="97.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#ff0000" x="97.6" y="1.5" width="414.8" height="24.65" shape-rendering="crispEdges"/><rect fill="#534838" x="512.4" y="1.5" width="341.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#534838" x="854" y="1.5" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#534838" x="866.2" y="1.5" width="0" height="24.65" shape-rendering="crispEdges"/><rect fill="#534838" x="866.2" y="1.5" width="109.8" height="24.65" shape-rendering="crispEdges"/><rect fill="#ff0000" x="0" y="25.9" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#ff0000" x="12.2" y="25.9" width="488" height="24.65" shape-rendering="crispEdges"/><rect fill="#ff0000" x="500.2" y="25.9" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="512.4" y="25.9" width="463.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#ff0000" x="0" y="50.3" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#ff0000" x="12.2" y="50.3" width="488" height="24.65" shape-rendering="crispEdges"/><rect fill="#ff0000" x="500.2" y="50.3" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="512.4" y="50.3" width="463.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#ff0000" x="0" y="74.7" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#ff0000" x="12.2" y="74.7" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#ff0000" x="36.6" y="74.7" width="366" height="24.65" shape-rendering="crispEdges"/><rect fill="#ff0000" x="402.6" y="74.7" width="97.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#ff0000" x="500.2" y="74.7" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="512.4" y="74.7" width="463.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#ff0000" x="0" y="99.1" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#ff0000" x="12.2" y="99.1" width="488" height="24.65" shape-rendering="crispEdges"/><rect fill="#ff0000" x="500.2" y="99.1" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="512.4" y="99.1" width="463.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#ff0000" x="0" y="123.5" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#ff0000" x="12.2" y="123.5" width="488" height="24.65" shape-rendering="crispEdges"/><rect fill="#ff0000" x="500.2" y="123.5" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="512.4" y="123.5" width="463.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#ff0000" x="0" y="147.9" width="512.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="512.4" y="147.9" width="463.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="0" y="172.3" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="0" y="196.7" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="0" y="221.1" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="0" y="245.5" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="0" y="269.9" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="0" y="294.3" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="0" y="318.7" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="0" y="343.1" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="0" y="367.5" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="0" y="391.9" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="0" y="416.3" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="0" y="440.7" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="0" y="465.1" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="0" y="489.5" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="0" y="513.9" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="0" y="538.3" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#0053aa" x="0" y="562.7" width="36.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#0178d4" x="36.6" y="562.7" width="183" height="24.65" shape-rendering="crispEdges"/><rect fill="#0178d4" x="219.6" y="562.7" width="756.4" height="24.65" shape-rendering="crispEdges"/>
|
||||
<g class="terminal-1654293578-matrix">
|
||||
<text class="terminal-1654293578-r1" x="0" y="20" textLength="97.6" clip-path="url(#terminal-1654293578-line-0)">┌───────</text><text class="terminal-1654293578-r1" x="97.6" y="20" textLength="414.8" clip-path="url(#terminal-1654293578-line-0)">─────────────────────────────────┐</text><text class="terminal-1654293578-r2" x="976" y="20" textLength="12.2" clip-path="url(#terminal-1654293578-line-0)">
|
||||
</text><text class="terminal-1654293578-r1" x="0" y="44.4" textLength="12.2" clip-path="url(#terminal-1654293578-line-1)">│</text><text class="terminal-1654293578-r1" x="500.2" y="44.4" textLength="12.2" clip-path="url(#terminal-1654293578-line-1)">│</text><text class="terminal-1654293578-r4" x="512.4" y="44.4" textLength="463.6" clip-path="url(#terminal-1654293578-line-1)">t. I'm sorry, Dave. I'm afraid I can't</text><text class="terminal-1654293578-r2" x="976" y="44.4" textLength="12.2" clip-path="url(#terminal-1654293578-line-1)">
|
||||
</text><text class="terminal-1654293578-r1" x="0" y="68.8" textLength="12.2" clip-path="url(#terminal-1654293578-line-2)">│</text><text class="terminal-1654293578-r1" x="500.2" y="68.8" textLength="12.2" clip-path="url(#terminal-1654293578-line-2)">│</text><text class="terminal-1654293578-r2" x="976" y="68.8" textLength="12.2" clip-path="url(#terminal-1654293578-line-2)">
|
||||
</text><text class="terminal-1654293578-r1" x="0" y="93.2" textLength="12.2" clip-path="url(#terminal-1654293578-line-3)">│</text><text class="terminal-1654293578-r1" x="36.6" y="93.2" textLength="366" clip-path="url(#terminal-1654293578-line-3)">This should float over the top</text><text class="terminal-1654293578-r1" x="500.2" y="93.2" textLength="12.2" clip-path="url(#terminal-1654293578-line-3)">│</text><text class="terminal-1654293578-r2" x="976" y="93.2" textLength="12.2" clip-path="url(#terminal-1654293578-line-3)">
|
||||
</text><text class="terminal-1654293578-r1" x="0" y="117.6" textLength="12.2" clip-path="url(#terminal-1654293578-line-4)">│</text><text class="terminal-1654293578-r1" x="500.2" y="117.6" textLength="12.2" clip-path="url(#terminal-1654293578-line-4)">│</text><text class="terminal-1654293578-r2" x="976" y="117.6" textLength="12.2" clip-path="url(#terminal-1654293578-line-4)">
|
||||
</text><text class="terminal-1654293578-r1" x="0" y="142" textLength="12.2" clip-path="url(#terminal-1654293578-line-5)">│</text><text class="terminal-1654293578-r1" x="500.2" y="142" textLength="12.2" clip-path="url(#terminal-1654293578-line-5)">│</text><text class="terminal-1654293578-r2" x="976" y="142" textLength="12.2" clip-path="url(#terminal-1654293578-line-5)">
|
||||
</text><text class="terminal-1654293578-r1" x="0" y="166.4" textLength="512.4" clip-path="url(#terminal-1654293578-line-6)">└────────────────────────────────────────┘</text><text class="terminal-1654293578-r2" x="976" y="166.4" textLength="12.2" clip-path="url(#terminal-1654293578-line-6)">
|
||||
</text><text class="terminal-1654293578-r2" x="976" y="190.8" textLength="12.2" clip-path="url(#terminal-1654293578-line-7)">
|
||||
</text><text class="terminal-1654293578-r2" x="976" y="215.2" textLength="12.2" clip-path="url(#terminal-1654293578-line-8)">
|
||||
</text><text class="terminal-1654293578-r2" x="976" y="239.6" textLength="12.2" clip-path="url(#terminal-1654293578-line-9)">
|
||||
</text><text class="terminal-1654293578-r2" x="976" y="264" textLength="12.2" clip-path="url(#terminal-1654293578-line-10)">
|
||||
</text><text class="terminal-1654293578-r2" x="976" y="288.4" textLength="12.2" clip-path="url(#terminal-1654293578-line-11)">
|
||||
</text><text class="terminal-1654293578-r2" x="976" y="312.8" textLength="12.2" clip-path="url(#terminal-1654293578-line-12)">
|
||||
</text><text class="terminal-1654293578-r2" x="976" y="337.2" textLength="12.2" clip-path="url(#terminal-1654293578-line-13)">
|
||||
</text><text class="terminal-1654293578-r2" x="976" y="361.6" textLength="12.2" clip-path="url(#terminal-1654293578-line-14)">
|
||||
</text><text class="terminal-1654293578-r2" x="976" y="386" textLength="12.2" clip-path="url(#terminal-1654293578-line-15)">
|
||||
</text><text class="terminal-1654293578-r2" x="976" y="410.4" textLength="12.2" clip-path="url(#terminal-1654293578-line-16)">
|
||||
</text><text class="terminal-1654293578-r2" x="976" y="434.8" textLength="12.2" clip-path="url(#terminal-1654293578-line-17)">
|
||||
</text><text class="terminal-1654293578-r2" x="976" y="459.2" textLength="12.2" clip-path="url(#terminal-1654293578-line-18)">
|
||||
</text><text class="terminal-1654293578-r2" x="976" y="483.6" textLength="12.2" clip-path="url(#terminal-1654293578-line-19)">
|
||||
</text><text class="terminal-1654293578-r2" x="976" y="508" textLength="12.2" clip-path="url(#terminal-1654293578-line-20)">
|
||||
</text><text class="terminal-1654293578-r2" x="976" y="532.4" textLength="12.2" clip-path="url(#terminal-1654293578-line-21)">
|
||||
</text><text class="terminal-1654293578-r2" x="976" y="556.8" textLength="12.2" clip-path="url(#terminal-1654293578-line-22)">
|
||||
</text><text class="terminal-1654293578-r5" x="0" y="581.2" textLength="36.6" clip-path="url(#terminal-1654293578-line-23)"> T </text><text class="terminal-1654293578-r6" x="36.6" y="581.2" textLength="183" clip-path="url(#terminal-1654293578-line-23)"> Toggle Screen </text>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
||||
'''
|
||||
# ---
|
||||
# name: test_textlog_max_lines
|
||||
'''
|
||||
<svg class="rich-terminal" viewBox="0 0 994 635.5999999999999" xmlns="http://www.w3.org/2000/svg">
|
||||
@@ -6962,6 +7280,162 @@
|
||||
|
||||
'''
|
||||
# ---
|
||||
# name: test_tree_example
|
||||
'''
|
||||
<svg class="rich-terminal" viewBox="0 0 994 635.5999999999999" xmlns="http://www.w3.org/2000/svg">
|
||||
<!-- Generated with Rich https://www.textualize.io -->
|
||||
<style>
|
||||
|
||||
@font-face {
|
||||
font-family: "Fira Code";
|
||||
src: local("FiraCode-Regular"),
|
||||
url("https://cdnjs.cloudflare.com/ajax/libs/firacode/6.2.0/woff2/FiraCode-Regular.woff2") format("woff2"),
|
||||
url("https://cdnjs.cloudflare.com/ajax/libs/firacode/6.2.0/woff/FiraCode-Regular.woff") format("woff");
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
}
|
||||
@font-face {
|
||||
font-family: "Fira Code";
|
||||
src: local("FiraCode-Bold"),
|
||||
url("https://cdnjs.cloudflare.com/ajax/libs/firacode/6.2.0/woff2/FiraCode-Bold.woff2") format("woff2"),
|
||||
url("https://cdnjs.cloudflare.com/ajax/libs/firacode/6.2.0/woff/FiraCode-Bold.woff") format("woff");
|
||||
font-style: bold;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.terminal-1345646321-matrix {
|
||||
font-family: Fira Code, monospace;
|
||||
font-size: 20px;
|
||||
line-height: 24.4px;
|
||||
font-variant-east-asian: full-width;
|
||||
}
|
||||
|
||||
.terminal-1345646321-title {
|
||||
font-size: 18px;
|
||||
font-weight: bold;
|
||||
font-family: arial;
|
||||
}
|
||||
|
||||
.terminal-1345646321-r1 { fill: #e2e3e3 }
|
||||
.terminal-1345646321-r2 { fill: #c5c8c6 }
|
||||
.terminal-1345646321-r3 { fill: #008139 }
|
||||
</style>
|
||||
|
||||
<defs>
|
||||
<clipPath id="terminal-1345646321-clip-terminal">
|
||||
<rect x="0" y="0" width="975.0" height="584.5999999999999" />
|
||||
</clipPath>
|
||||
<clipPath id="terminal-1345646321-line-0">
|
||||
<rect x="0" y="1.5" width="976" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-1345646321-line-1">
|
||||
<rect x="0" y="25.9" width="976" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-1345646321-line-2">
|
||||
<rect x="0" y="50.3" width="976" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-1345646321-line-3">
|
||||
<rect x="0" y="74.7" width="976" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-1345646321-line-4">
|
||||
<rect x="0" y="99.1" width="976" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-1345646321-line-5">
|
||||
<rect x="0" y="123.5" width="976" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-1345646321-line-6">
|
||||
<rect x="0" y="147.9" width="976" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-1345646321-line-7">
|
||||
<rect x="0" y="172.3" width="976" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-1345646321-line-8">
|
||||
<rect x="0" y="196.7" width="976" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-1345646321-line-9">
|
||||
<rect x="0" y="221.1" width="976" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-1345646321-line-10">
|
||||
<rect x="0" y="245.5" width="976" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-1345646321-line-11">
|
||||
<rect x="0" y="269.9" width="976" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-1345646321-line-12">
|
||||
<rect x="0" y="294.3" width="976" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-1345646321-line-13">
|
||||
<rect x="0" y="318.7" width="976" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-1345646321-line-14">
|
||||
<rect x="0" y="343.1" width="976" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-1345646321-line-15">
|
||||
<rect x="0" y="367.5" width="976" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-1345646321-line-16">
|
||||
<rect x="0" y="391.9" width="976" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-1345646321-line-17">
|
||||
<rect x="0" y="416.3" width="976" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-1345646321-line-18">
|
||||
<rect x="0" y="440.7" width="976" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-1345646321-line-19">
|
||||
<rect x="0" y="465.1" width="976" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-1345646321-line-20">
|
||||
<rect x="0" y="489.5" width="976" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-1345646321-line-21">
|
||||
<rect x="0" y="513.9" width="976" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-1345646321-line-22">
|
||||
<rect x="0" y="538.3" width="976" height="24.65"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
|
||||
<rect fill="#292929" stroke="rgba(255,255,255,0.35)" stroke-width="1" x="1" y="1" width="992" height="633.6" rx="8"/><text class="terminal-1345646321-title" fill="#c5c8c6" text-anchor="middle" x="496" y="27">TreeApp</text>
|
||||
<g transform="translate(26,22)">
|
||||
<circle cx="0" cy="0" r="7" fill="#ff5f57"/>
|
||||
<circle cx="22" cy="0" r="7" fill="#febc2e"/>
|
||||
<circle cx="44" cy="0" r="7" fill="#28c840"/>
|
||||
</g>
|
||||
|
||||
<g transform="translate(9, 41)" clip-path="url(#terminal-1345646321-clip-terminal)">
|
||||
<rect fill="#24292f" x="0" y="1.5" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#24292f" x="24.4" y="1.5" width="48.8" height="24.65" shape-rendering="crispEdges"/><rect fill="#24292f" x="73.2" y="1.5" width="902.8" height="24.65" shape-rendering="crispEdges"/><rect fill="#24292f" x="0" y="25.9" width="48.8" height="24.65" shape-rendering="crispEdges"/><rect fill="#24292f" x="48.8" y="25.9" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#24292f" x="73.2" y="25.9" width="122" height="24.65" shape-rendering="crispEdges"/><rect fill="#24292f" x="195.2" y="25.9" width="780.8" height="24.65" shape-rendering="crispEdges"/><rect fill="#24292f" x="0" y="50.3" width="97.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#24292f" x="97.6" y="50.3" width="48.8" height="24.65" shape-rendering="crispEdges"/><rect fill="#24292f" x="146.4" y="50.3" width="829.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#24292f" x="0" y="74.7" width="97.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#24292f" x="97.6" y="74.7" width="85.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#24292f" x="183" y="74.7" width="793" height="24.65" shape-rendering="crispEdges"/><rect fill="#24292f" x="0" y="99.1" width="97.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#24292f" x="97.6" y="99.1" width="73.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#24292f" x="170.8" y="99.1" width="805.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#24292f" x="0" y="123.5" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#24292f" x="0" y="147.9" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#24292f" x="0" y="172.3" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#24292f" x="0" y="196.7" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#24292f" x="0" y="221.1" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#24292f" x="0" y="245.5" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#24292f" x="0" y="269.9" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#24292f" x="0" y="294.3" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#24292f" x="0" y="318.7" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#24292f" x="0" y="343.1" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#24292f" x="0" y="367.5" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#24292f" x="0" y="391.9" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#24292f" x="0" y="416.3" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#24292f" x="0" y="440.7" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#24292f" x="0" y="465.1" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#24292f" x="0" y="489.5" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#24292f" x="0" y="513.9" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#24292f" x="0" y="538.3" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#24292f" x="0" y="562.7" width="976" height="24.65" shape-rendering="crispEdges"/>
|
||||
<g class="terminal-1345646321-matrix">
|
||||
<text class="terminal-1345646321-r1" x="0" y="20" textLength="24.4" clip-path="url(#terminal-1345646321-line-0)">▼ </text><text class="terminal-1345646321-r1" x="24.4" y="20" textLength="48.8" clip-path="url(#terminal-1345646321-line-0)">Dune</text><text class="terminal-1345646321-r2" x="976" y="20" textLength="12.2" clip-path="url(#terminal-1345646321-line-0)">
|
||||
</text><text class="terminal-1345646321-r3" x="0" y="44.4" textLength="48.8" clip-path="url(#terminal-1345646321-line-1)">└── </text><text class="terminal-1345646321-r1" x="48.8" y="44.4" textLength="24.4" clip-path="url(#terminal-1345646321-line-1)">▼ </text><text class="terminal-1345646321-r1" x="73.2" y="44.4" textLength="122" clip-path="url(#terminal-1345646321-line-1)">Characters</text><text class="terminal-1345646321-r2" x="976" y="44.4" textLength="12.2" clip-path="url(#terminal-1345646321-line-1)">
|
||||
</text><text class="terminal-1345646321-r3" x="0" y="68.8" textLength="97.6" clip-path="url(#terminal-1345646321-line-2)">    ├── </text><text class="terminal-1345646321-r1" x="97.6" y="68.8" textLength="48.8" clip-path="url(#terminal-1345646321-line-2)">Paul</text><text class="terminal-1345646321-r2" x="976" y="68.8" textLength="12.2" clip-path="url(#terminal-1345646321-line-2)">
|
||||
</text><text class="terminal-1345646321-r3" x="0" y="93.2" textLength="97.6" clip-path="url(#terminal-1345646321-line-3)">    ├── </text><text class="terminal-1345646321-r1" x="97.6" y="93.2" textLength="85.4" clip-path="url(#terminal-1345646321-line-3)">Jessica</text><text class="terminal-1345646321-r2" x="976" y="93.2" textLength="12.2" clip-path="url(#terminal-1345646321-line-3)">
|
||||
</text><text class="terminal-1345646321-r3" x="0" y="117.6" textLength="97.6" clip-path="url(#terminal-1345646321-line-4)">    └── </text><text class="terminal-1345646321-r1" x="97.6" y="117.6" textLength="73.2" clip-path="url(#terminal-1345646321-line-4)">Channi</text><text class="terminal-1345646321-r2" x="976" y="117.6" textLength="12.2" clip-path="url(#terminal-1345646321-line-4)">
|
||||
</text><text class="terminal-1345646321-r2" x="976" y="142" textLength="12.2" clip-path="url(#terminal-1345646321-line-5)">
|
||||
</text><text class="terminal-1345646321-r2" x="976" y="166.4" textLength="12.2" clip-path="url(#terminal-1345646321-line-6)">
|
||||
</text><text class="terminal-1345646321-r2" x="976" y="190.8" textLength="12.2" clip-path="url(#terminal-1345646321-line-7)">
|
||||
</text><text class="terminal-1345646321-r2" x="976" y="215.2" textLength="12.2" clip-path="url(#terminal-1345646321-line-8)">
|
||||
</text><text class="terminal-1345646321-r2" x="976" y="239.6" textLength="12.2" clip-path="url(#terminal-1345646321-line-9)">
|
||||
</text><text class="terminal-1345646321-r2" x="976" y="264" textLength="12.2" clip-path="url(#terminal-1345646321-line-10)">
|
||||
</text><text class="terminal-1345646321-r2" x="976" y="288.4" textLength="12.2" clip-path="url(#terminal-1345646321-line-11)">
|
||||
</text><text class="terminal-1345646321-r2" x="976" y="312.8" textLength="12.2" clip-path="url(#terminal-1345646321-line-12)">
|
||||
</text><text class="terminal-1345646321-r2" x="976" y="337.2" textLength="12.2" clip-path="url(#terminal-1345646321-line-13)">
|
||||
</text><text class="terminal-1345646321-r2" x="976" y="361.6" textLength="12.2" clip-path="url(#terminal-1345646321-line-14)">
|
||||
</text><text class="terminal-1345646321-r2" x="976" y="386" textLength="12.2" clip-path="url(#terminal-1345646321-line-15)">
|
||||
</text><text class="terminal-1345646321-r2" x="976" y="410.4" textLength="12.2" clip-path="url(#terminal-1345646321-line-16)">
|
||||
</text><text class="terminal-1345646321-r2" x="976" y="434.8" textLength="12.2" clip-path="url(#terminal-1345646321-line-17)">
|
||||
</text><text class="terminal-1345646321-r2" x="976" y="459.2" textLength="12.2" clip-path="url(#terminal-1345646321-line-18)">
|
||||
</text><text class="terminal-1345646321-r2" x="976" y="483.6" textLength="12.2" clip-path="url(#terminal-1345646321-line-19)">
|
||||
</text><text class="terminal-1345646321-r2" x="976" y="508" textLength="12.2" clip-path="url(#terminal-1345646321-line-20)">
|
||||
</text><text class="terminal-1345646321-r2" x="976" y="532.4" textLength="12.2" clip-path="url(#terminal-1345646321-line-21)">
|
||||
</text><text class="terminal-1345646321-r2" x="976" y="556.8" textLength="12.2" clip-path="url(#terminal-1345646321-line-22)">
|
||||
</text>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
||||
'''
|
||||
# ---
|
||||
# name: test_vertical_layout
|
||||
'''
|
||||
<svg class="rich-terminal" viewBox="0 0 994 635.5999999999999" xmlns="http://www.w3.org/2000/svg">
|
||||
|
||||
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()
|
||||
@@ -13,6 +13,7 @@ SNAPSHOT_APPS_DIR = Path("./snapshot_apps")
|
||||
|
||||
# --- Layout related stuff ---
|
||||
|
||||
|
||||
def test_grid_layout_basic(snap_compare):
|
||||
assert snap_compare(LAYOUT_EXAMPLES_DIR / "grid_layout1.py")
|
||||
|
||||
@@ -50,6 +51,7 @@ def test_dock_layout_sidebar(snap_compare):
|
||||
# When adding a new widget, ideally we should also create a snapshot test
|
||||
# from these examples which test rendering and simple interactions with it.
|
||||
|
||||
|
||||
def test_checkboxes(snap_compare):
|
||||
"""Tests checkboxes but also acts a regression test for using
|
||||
width: auto in a Horizontal layout context."""
|
||||
@@ -106,6 +108,10 @@ def test_fr_units(snap_compare):
|
||||
assert snap_compare("snapshot_apps/fr_units.py")
|
||||
|
||||
|
||||
def test_tree_example(snap_compare):
|
||||
assert snap_compare(WIDGET_EXAMPLES_DIR / "tree.py")
|
||||
|
||||
|
||||
# --- CSS properties ---
|
||||
# We have a canonical example for each CSS property that is shown in their docs.
|
||||
# If any of these change, something has likely broken, so snapshot each of them.
|
||||
@@ -128,7 +134,18 @@ def test_multiple_css(snap_compare):
|
||||
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 ---
|
||||
|
||||
|
||||
def test_key_display(snap_compare):
|
||||
assert snap_compare(SNAPSHOT_APPS_DIR / "key_display.py")
|
||||
|
||||
@@ -8,12 +8,15 @@ def test_query():
|
||||
class View(Widget):
|
||||
pass
|
||||
|
||||
class View2(View):
|
||||
pass
|
||||
|
||||
class App(Widget):
|
||||
pass
|
||||
|
||||
app = App()
|
||||
main_view = View(id="main")
|
||||
help_view = View(id="help")
|
||||
help_view = View2(id="help")
|
||||
app._add_child(main_view)
|
||||
app._add_child(help_view)
|
||||
|
||||
@@ -53,6 +56,7 @@ def test_query():
|
||||
assert list(app.query("App")) == [app]
|
||||
assert list(app.query("#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")) == [] # Note case.
|
||||
assert list(app.query("#widget2")) == [widget2]
|
||||
|
||||
@@ -1,26 +1,240 @@
|
||||
import asyncio
|
||||
|
||||
import pytest
|
||||
|
||||
from textual.app import App, ComposeResult
|
||||
from textual.reactive import reactive
|
||||
from textual.reactive import reactive, var
|
||||
from textual.widget import Widget
|
||||
|
||||
|
||||
class WatchApp(App):
|
||||
|
||||
count = reactive(0, init=False)
|
||||
|
||||
test_count = 0
|
||||
|
||||
def watch_count(self, value: int) -> None:
|
||||
self.test_count = value
|
||||
OLD_VALUE = 5_000
|
||||
NEW_VALUE = 1_000_000
|
||||
|
||||
|
||||
async def test_watch():
|
||||
"""Test that changes to a watched reactive attribute happen immediately."""
|
||||
|
||||
class WatchApp(App):
|
||||
count = reactive(0, init=False)
|
||||
|
||||
watcher_call_count = 0
|
||||
|
||||
def watch_count(self, value: int) -> None:
|
||||
self.watcher_call_count = value
|
||||
|
||||
app = WatchApp()
|
||||
async with app.run_test():
|
||||
app.count += 1
|
||||
assert app.test_count == 1
|
||||
assert app.watcher_call_count == 1
|
||||
app.count += 1
|
||||
assert app.test_count == 2
|
||||
assert app.watcher_call_count == 2
|
||||
app.count -= 1
|
||||
assert app.test_count == 1
|
||||
assert app.watcher_call_count == 1
|
||||
app.count -= 1
|
||||
assert app.test_count == 0
|
||||
assert app.watcher_call_count == 0
|
||||
|
||||
|
||||
async def test_watch_async_init_false():
|
||||
"""Ensure that async watchers are called eventually when set by user code"""
|
||||
|
||||
class WatchAsyncApp(App):
|
||||
count = reactive(OLD_VALUE, init=False)
|
||||
watcher_old_value = None
|
||||
watcher_new_value = None
|
||||
watcher_called_event = asyncio.Event()
|
||||
|
||||
async def watch_count(self, old_value: int, new_value: int) -> None:
|
||||
self.watcher_old_value = old_value
|
||||
self.watcher_new_value = new_value
|
||||
self.watcher_called_event.set()
|
||||
|
||||
app = WatchAsyncApp()
|
||||
async with app.run_test():
|
||||
app.count = NEW_VALUE
|
||||
assert app.count == NEW_VALUE # Value is set immediately
|
||||
try:
|
||||
await asyncio.wait_for(app.watcher_called_event.wait(), timeout=0.05)
|
||||
except TimeoutError:
|
||||
pytest.fail("Async watch method (watch_count) wasn't called within timeout")
|
||||
|
||||
assert app.count == NEW_VALUE # Sanity check
|
||||
assert app.watcher_old_value == OLD_VALUE # old_value passed to watch method
|
||||
assert app.watcher_new_value == NEW_VALUE # new_value passed to watch method
|
||||
|
||||
|
||||
async def test_watch_async_init_true():
|
||||
"""Ensure that when init is True in a reactive, its async watcher gets called
|
||||
by Textual eventually, even when the user does not set the value themselves."""
|
||||
|
||||
class WatchAsyncApp(App):
|
||||
count = reactive(OLD_VALUE, init=True)
|
||||
watcher_called_event = asyncio.Event()
|
||||
watcher_old_value = None
|
||||
watcher_new_value = None
|
||||
|
||||
async def watch_count(self, old_value: int, new_value: int) -> None:
|
||||
self.watcher_old_value = old_value
|
||||
self.watcher_new_value = new_value
|
||||
self.watcher_called_event.set()
|
||||
|
||||
app = WatchAsyncApp()
|
||||
async with app.run_test():
|
||||
try:
|
||||
await asyncio.wait_for(app.watcher_called_event.wait(), timeout=0.05)
|
||||
except TimeoutError:
|
||||
pytest.fail("Async watcher wasn't called within timeout when reactive init = True")
|
||||
|
||||
assert app.count == OLD_VALUE
|
||||
assert app.watcher_old_value == OLD_VALUE
|
||||
assert app.watcher_new_value == OLD_VALUE # The value wasn't changed
|
||||
|
||||
|
||||
@pytest.mark.xfail(reason="Reactive watcher is incorrectly always called the first time it is set, even if value is same [issue#1230]")
|
||||
async def test_watch_init_false_always_update_false():
|
||||
class WatcherInitFalse(App):
|
||||
count = reactive(0, init=False)
|
||||
watcher_call_count = 0
|
||||
|
||||
def watch_count(self, new_value: int) -> None:
|
||||
self.watcher_call_count += 1
|
||||
|
||||
app = WatcherInitFalse()
|
||||
async with app.run_test():
|
||||
app.count = 0 # Value hasn't changed, and always_update=False, so watch_count shouldn't run
|
||||
assert app.watcher_call_count == 0
|
||||
|
||||
|
||||
async def test_watch_init_true():
|
||||
class WatcherInitTrue(App):
|
||||
count = var(OLD_VALUE)
|
||||
watcher_call_count = 0
|
||||
|
||||
def watch_count(self, new_value: int) -> None:
|
||||
self.watcher_call_count += 1
|
||||
|
||||
app = WatcherInitTrue()
|
||||
async with app.run_test():
|
||||
assert app.count == OLD_VALUE
|
||||
assert app.watcher_call_count == 1 # Watcher called on init
|
||||
app.count = NEW_VALUE # User sets the value...
|
||||
assert app.watcher_call_count == 2 # ...resulting in 2nd call
|
||||
app.count = NEW_VALUE # Setting to the SAME value
|
||||
assert app.watcher_call_count == 2 # Watcher is NOT called again
|
||||
|
||||
|
||||
async def test_reactive_always_update():
|
||||
calls = []
|
||||
|
||||
class AlwaysUpdate(App):
|
||||
first_name = reactive("Darren", init=False, always_update=True)
|
||||
last_name = reactive("Burns", init=False)
|
||||
|
||||
def watch_first_name(self, value):
|
||||
calls.append(f"first_name {value}")
|
||||
|
||||
def watch_last_name(self, value):
|
||||
calls.append(f"last_name {value}")
|
||||
|
||||
app = AlwaysUpdate()
|
||||
async with app.run_test():
|
||||
# Value is the same, but always_update=True, so watcher called...
|
||||
app.first_name = "Darren"
|
||||
assert calls == ["first_name Darren"]
|
||||
# TODO: Commented out below due to issue#1230, should work after issue fixed
|
||||
# Value is the same, and always_update=False, so watcher NOT called...
|
||||
# app.last_name = "Burns"
|
||||
# assert calls == ["first_name Darren"]
|
||||
# Values changed, watch method always called regardless of always_update
|
||||
app.first_name = "abc"
|
||||
app.last_name = "def"
|
||||
assert calls == ["first_name Darren", "first_name abc", "last_name def"]
|
||||
|
||||
|
||||
async def test_reactive_with_callable_default():
|
||||
"""A callable can be supplied as the default value for a reactive.
|
||||
Textual will call it in order to retrieve the default value."""
|
||||
called_with_app = None
|
||||
|
||||
def set_called(app: App) -> int:
|
||||
nonlocal called_with_app
|
||||
called_with_app = app
|
||||
return OLD_VALUE
|
||||
|
||||
class ReactiveCallable(App):
|
||||
value = reactive(set_called)
|
||||
watcher_called_with = None
|
||||
|
||||
def watch_value(self, new_value):
|
||||
self.watcher_called_with = new_value
|
||||
|
||||
app = ReactiveCallable()
|
||||
async with app.run_test():
|
||||
assert app.value == OLD_VALUE # The value should be set to the return val of the callable
|
||||
assert called_with_app is app # Ensure the App is passed into the reactive default callable
|
||||
assert app.watcher_called_with == OLD_VALUE
|
||||
|
||||
|
||||
@pytest.mark.xfail(reason="Validator methods not running when init=True [issue#1220]")
|
||||
async def test_validate_init_true():
|
||||
"""When init is True for a reactive attribute, Textual should call the validator
|
||||
AND the watch method when the app starts."""
|
||||
|
||||
class ValidatorInitTrue(App):
|
||||
count = var(5, init=True)
|
||||
|
||||
def validate_count(self, value: int) -> int:
|
||||
return value + 1
|
||||
|
||||
app = ValidatorInitTrue()
|
||||
async with app.run_test():
|
||||
assert app.count == 6 # Validator should run, so value should be 5+1=6
|
||||
|
||||
|
||||
@pytest.mark.xfail(reason="Compute methods not called when init=True [issue#1227]")
|
||||
async def test_reactive_compute_first_time_set():
|
||||
class ReactiveComputeFirstTimeSet(App):
|
||||
number = reactive(1)
|
||||
double_number = reactive(None)
|
||||
|
||||
def compute_double_number(self):
|
||||
return self.number * 2
|
||||
|
||||
app = ReactiveComputeFirstTimeSet()
|
||||
async with app.run_test():
|
||||
await asyncio.sleep(.2) # TODO: We sleep here while issue#1218 is open
|
||||
assert app.double_number == 2
|
||||
|
||||
|
||||
@pytest.mark.xfail(reason="Compute methods not called immediately [issue#1218]")
|
||||
async def test_reactive_method_call_order():
|
||||
class CallOrder(App):
|
||||
count = reactive(OLD_VALUE, init=False)
|
||||
count_times_ten = reactive(OLD_VALUE * 10)
|
||||
calls = []
|
||||
|
||||
def validate_count(self, value: int) -> int:
|
||||
self.calls.append(f"validate {value}")
|
||||
return value + 1
|
||||
|
||||
def watch_count(self, value: int) -> None:
|
||||
self.calls.append(f"watch {value}")
|
||||
|
||||
def compute_count_times_ten(self) -> int:
|
||||
self.calls.append(f"compute {self.count}")
|
||||
return self.count * 10
|
||||
|
||||
app = CallOrder()
|
||||
async with app.run_test():
|
||||
app.count = NEW_VALUE
|
||||
assert app.calls == [
|
||||
# The validator receives NEW_VALUE, since that's what the user
|
||||
# set the reactive attribute to...
|
||||
f"validate {NEW_VALUE}",
|
||||
# The validator adds 1 to the new value, and this is what should
|
||||
# be passed into the watcher...
|
||||
f"watch {NEW_VALUE + 1}",
|
||||
# The compute method accesses the reactive value directly, which
|
||||
# should have been updated by the validator to NEW_VALUE + 1.
|
||||
f"compute {NEW_VALUE + 1}",
|
||||
]
|
||||
assert app.count == NEW_VALUE + 1
|
||||
assert app.count_times_ten == (NEW_VALUE + 1) * 10
|
||||
|
||||