Merge branch 'main' into review-styles-reference

This commit is contained in:
Rodrigo Girão Serrão
2023-01-09 16:19:24 +00:00
17 changed files with 706 additions and 154 deletions

View File

@@ -12,5 +12,4 @@ repos:
rev: 22.10.0
hooks:
- id: black
exclude: ^tests/
exclude: ^tests/snapshot_tests

View File

@@ -17,6 +17,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
- `MouseScrollUp` and `MouseScrollDown` now inherit from `MouseEvent` and have attached modifier keys. https://github.com/Textualize/textual/pull/1458
- Fail-fast and print pretty tracebacks for Widget compose errors https://github.com/Textualize/textual/pull/1505
- Added Widget._refresh_scroll to avoid expensive layout when scrolling https://github.com/Textualize/textual/pull/1524
### Fixed

View File

@@ -0,0 +1,323 @@
---
draft: false
date: 2023-01-09
categories:
- DevLog
authors:
- davep
---
# So you're looking for a wee bit of Textual help...
## Introduction
!!! quote
Patience, Highlander. You have done well. But it'll take time. You are
generations being born and dying. You are at one with all living things.
Each man's thoughts and dreams are yours to know. You have power beyond
imagination. Use it well, my friend. Don't lose your head.
<cite>Juan Sánchez Villalobos Ramírez, Chief metallurgist to King Charles V of Spain</cite>
As of the time of writing, I'm a couple or so days off having been with
Textualize for 3 months. It's been fun, and educational, and every bit as
engaging as I'd hoped, and more. One thing I hadn't quite prepared for
though, but which I really love, is how so many other people are learning
Textual along with me.
<!-- more -->
Even in those three months the library has changed and expanded quite a lot,
and it continues to do so. Meanwhile, more people are turning up and using
the framework; you can see this online in social media, blogs and of course
[in the ever-growing list of projects on GitHub which depend on
Textual](https://github.com/Textualize/textual/network/dependents).
This inevitably means there's a lot of people getting to grips with a new
tool, and one that is still a bit of a moving target. This in turn means
lots of people are coming to us to get help.
As I've watched this happen I've noticed a few patterns emerging. Some of
these good or neutral, some... let's just say not really beneficial to those
seeking the help, or to those trying to provide the help. So I wanted to
write a little bit about the different ways you can get help with Textual
and your Textual-based projects, and to also try and encourage people to
take the most helpful and positive approach to getting that help.
Now, before I go on, I want to make something *very* clear: I'm writing this
as an individual. This is my own personal view, and my own advice from me to
anyone who wishes to take it. It's not Textual (the project) or Textualize
(the company) policy, rules or guidelines. This is just some ageing hacker's
take on how best to go about asking for help, informed by years of asking
for and also providing help in email, on Usenet, on forums, etc.
Or, put another way: if what you read in here seems sensible to you, I
figure we'll likely have already hit it off [over on
GitHub](https://github.com/Textualize/textual) or in [the Discord
server](https://discord.gg/Enf6Z3qhVr). ;-)
## Where to go for help
At this point this is almost a bit of an FAQ itself, so I thought I'd
address it here: where's the best place to ask for help about Textual, and
what's the difference between GitHub Issues, Discussions and our Discord
server?
I'd suggest thinking of them like this:
### Discord
You have a question, or need help with something, and perhaps you could do
with a reply as soon as possible. But, and this is the **really important
part**, it doesn't matter if you don't get a response. If you're in this
situation then the Discord server is possibly a good place to start. If
you're lucky someone will be hanging about who can help out.
I can't speak for anyone else, but keep this in mind: when I look in on
Discord I tend not to go scrolling back much to see if anything has been
missed. If something catches my eye, I'll try and reply, but if it
doesn't... well, it's mostly an instant chat thing so I don't dive too
deeply back in time.
!!! tip inline end "Going from Discord to a GitHub issue"
As a slight aside here: sometimes people will pop up in Discord, ask a
question about something that turns out looking like a bug, and that's
the last we hear of it. Please, please, **please**, if this happens, the
most helpful thing you can do is go raise an issue for us. It'll help us
to keep track of problems, it'll help get your problem fixed, it'll mean
everyone benefits.
My own advice would be to treat Discord as an ephemeral resource. It happens
in the moment but fades away pretty quickly. It's like knocking on a
friend's door to see if they're in. If they're not in, you might leave them
a note, which is sort of like going to...
### GitHub
On the other hand, if you have a question or need some help or something
where you want to stand a good chance of the Textual developers (amongst
others) seeing it and responding, I'd recommend that GitHub is the place to
go. Dropping something into the discussions there, or leaving an issue,
ensures it'll get seen. It won't get lost.
As for which you should use -- a discussion or an issue -- I'd suggest this:
if you need help with something, or you want to check your understanding of
something, or you just want to be sure something is a problem before taking
it further, a discussion might be the best thing. On the other hand, if
you've got a clear bug or feature request on your hands, an issue makes a
lot of sense.
Don't worry if you're not sure which camp your question or whatever falls
into though; go with what you think is right. There's no harm done either
way (I may move an issue to a discussion first before replying, if it's
really just a request for help -- but that's mostly so everyone can benefit
from finding it in the right place later on down the line).
## The dos and don'ts of getting help
Now on to the fun part. This is where I get a bit preachy. Ish. Kinda. A
little bit. Again, please remember, this isn't a set of rules, this isn't a
set of official guidelines, this is just a bunch of *"if you want my advice,
and I know you didn't ask but you've read this far so you actually sort of
did don't say I didn't warn you!"* waffle.
This isn't going to be an exhaustive collection, far from it. But I feel
these are some important highlights.
### Do...
When looking for help, in any of the locations mentioned above, I'd totally
encourage:
#### Be clear and detailed
Too much detail is almost always way better than not enough. *"My program
didn't run"*, often even with some of the code supplied, is so much harder
to help than *"I ran this code I'm posting here, and I expected this
particular outcome, and I expected it because I'd read this particular thing
in the docs and had comprehended it to mean this, but instead the outcome
was this exception here, and I'm a bit stuck -- can someone offer some
pointers?"*
The former approach means there often ends up having to be a back and forth
which can last a long time, and which can sometimes be frustrating for the
person asking. Manage frustration: be clear, tell us everything you can.
#### Say what resources you've used already
If you've read the potions of the documentation that relate to what you're
trying to do, it's going to be really helpful if you say so. If you don't,
it might be assumed you haven't and you may end up being pointed at them.
So, please, if you've checked the documentation, looked in the FAQ, done a
search of past issues or discussions or perhaps even done a search on the
Discord server... please say so.
#### Be polite
This one can go a long way when looking for help. Look, I get it,
programming is bloody frustrating at times. We've all rage-quit some code at
some point, I'm sure. It's likely going to be your moment of greatest
frustration when you go looking for help. But if you turn up looking for
help acting all grumpy and stuff it's not going to come over well. Folk are
less likely to be motivated to lend a hand to someone who seems rather
annoyed.
If you throw in a please and thank-you here and there that makes it all the
better.
#### Fully consider the replies
You could find yourself getting a reply that you're sure won't help at all.
That's fair. But be sure to fully consider it first. Perhaps you missed the
obvious along the way and this is 100% the course correction you'd
unknowingly come looking for in the first place. Sure, the person replying
might have totally misunderstood what was being asked, or might be giving a
wrong answer (it me! I've totally done that and will again!), but even then
a reply along the lines of *"I'm not sure that's what I'm looking for,
because..."* gets everyone to the solution faster than *"lol nah"*.
#### Entertain what might seem like odd questions
Aye, I get it, being asked questions when you're looking for an *answer* can
be a bit frustrating. But if you find yourself on the receiving end of a
small series of questions about your question, keep this in mind: Textual is
still rather new and still developing and it's possible that what you're
trying to do isn't the correct way to do that thing. To the person looking
to help you it may seem to them you have an [XY
problem](https://en.wikipedia.org/wiki/XY_problem).
Entertaining those questions might just get you to the real solution to your
problem.
#### Allow for language differences
You don't need me to tell you that a project such as Textual has a global
audience. With that rather obvious fact comes the other fact that we don't
all share the same first language. So, please, as much as possible, try and
allow for that. If someone is trying to help you out, and they make it clear
they're struggling to follow you, keep this in mind.
#### Acknowledge the answer
I suppose this is a variation on "be polite" (really, a thanks can go a long
way), but there's more to this than a friendly acknowledgement. If someone
has gone to the trouble of offering some help, it's helpful to everyone who
comes after you to acknowledge if it worked or not. That way a future
help-seeker will know if the answer they're reading stands a chance of being
the right one.
#### Accept that Textual is zero-point software (right now)
Of course the aim is to have every release of Textual be stable and useful,
but things will break. So, please, do keep in mind things like:
- Textual likely doesn't have your feature of choice just yet.
- We might accidentally break something (perhaps pinning Textual and testing
each release is a good plan here?).
- We might deliberately break something because we've decided to take a
particular feature or way of doing things in a better direction.
Of course it can be a bit frustrating a times, but overall the aim is to
have the best framework possible in the long run.
### Don't...
Okay, now for a bit of old-hacker finger-wagging. Here's a few things I'd
personally discourage:
#### Lack patience
Sure, it can be annoying. You're in your flow, you've got a neat idea for a
thing you want to build, you're stuck on one particular thing and you really
need help right now! Thing is, that's unlikely to happen. Badgering
individuals, or a whole resource, to reply right now, or complaining that
it's been `$TIME_PERIOD` since you asked and nobody has replied... that's
just going to make people less likely to reply.
#### Unnecessarily tag individuals
This one often goes hand in hand with the "lack patience" thing: Be it
asking on Discord, or in GitHub issues, discussions or even PRs,
unnecessarily tagging individuals is a bit rude. Speaking for myself and
only myself: I *love* helping folk with Textual. If I could help everyone
all the time the moment they have a problem, I would. But it doesn't work
like that. There's any number of reasons I might not be responding to a
particular request, including but not limited to (here I'm talking
personally because I don't want to speak for anyone else, but I'm sure I'm
not alone here):
- I have a job. Sure, my job is (in part) Textual, but there's more to it
than that particular issue. I might be doing other stuff.
- I have my own projects to work on too. I like coding for fun as well (or
writing preaching old dude blog posts like this I guess, but you get the
idea).
- I actually have other interests outside of work hours so I might actually
be out doing a 10k in the local glen, or battling headcrabs in VR, or
something.
- Housework. :-/
You get the idea though. So while I'm off having a well-rounded life, it's
not good to get unnecessarily intrusive alerts to something that either a)
doesn't actually directly involve me or b) could wait.
#### Seek personal support
Again, I'm going to speak totally for myself here, but I also feel the
general case is polite for all: there's a lot of good support resources
available already; sending DMs on Discord or Twitter or in the Fediverse,
looking for direct personal support, isn't really the best way to get help.
Using the public/collective resources is absolutely the *best* way to get
that help. Why's it a bad idea to dive into DMs? Here's some reasons I think
it's not a good idea:
- It's a variation on "unnecessarily tagging individuals".
- You're short-changing yourself when it comes to getting help. If you ask
somewhere more public you're asking a much bigger audience, who
collectively have more time, more knowledge and more experience than a
single individual.
- Following on from that, any answers can be (politely) fact-checked or
enhanced by that audience, resulting in a better chance of getting the
best help possible.
- The next seeker-of-help gets to miss out on your question and the answer.
If asked and answered in public, it's a record that can help someone else
in the future.
#### Doubt your ability or skill level
I suppose this should really be phrased as a do rather than a don't, as here
I want to encourage something positive. A few times I've helped people out
who have been very apologetic about their questions being "noob" questions,
or about how they're fairly new to Python, or programming in general.
Really, please, don't feel the need to apologise and don't be ashamed of
where you're at.
If you've asked something that's obviously answered in the documentation,
that's not a problem; you'll likely get pointed at the docs and it's what
happens next that's the key bit. If the attitude is *"oh, cool, that's
exactly what I needed to be reading, thanks!"* that's a really positive
thing. The only time it's a problem is when there's a real reluctance to use
the available resources. We've all seen that person somewhere at some point,
right? ;-)
Not knowing things [is totally cool](https://xkcd.com/1053/).
## Conclusion
So, that's my waffle over. As I said at the start: this is my own personal
thoughts on how to get help with Textual, both as someone whose job it is to
work on Textual and help people with Textual, and also as a FOSS advocate
and supporter who can normally be found helping Textual users when he's not
"on the clock" too.
What I've written here isn't exhaustive. Neither is it novel. Plenty has
been written on the general subject in the past, and I'm sure more will be
written on the subject in the future. I do, however, feel that these are the
most common things I notice. I'd say those dos and don'ts cover 90% of *"can
I get some help?"* interactions; perhaps closer to 99%.
Finally, and I think this is the most important thing to remember, the next
time you are battling some issue while working with Textual: [don't lose
your head](https://www.youtube.com/watch?v=KdYvKF9O7Y8)!

View File

@@ -6,11 +6,10 @@ If you need help with any aspect of Textual, let us know! We would be happy to h
Report bugs via GitHub on the Textual [issues](https://github.com/Textualize/textual/issues) page. You can also post feature requests via GitHub issues, but see the [roadmap](./roadmap.md) first.
## Help with using Textual
You can seek help with using Textual [in the discussion area on GitHub](https://github.com/Textualize/textual/discussions).
## Discord Server
For more realtime feedback or chat, join our discord server to connect with the [Textual community](https://discord.gg/Enf6Z3qhVr).
## Forum
Visit the [Textual forum](https://community.textualize.io/) for Textual (and Rich) discussions.

128
poetry.lock generated
View File

@@ -182,7 +182,7 @@ test = ["flake8 (==3.7.8)", "hypothesis (==3.55.3)"]
[[package]]
name = "coverage"
version = "7.0.1"
version = "7.0.3"
description = "Code coverage measurement for Python"
category = "dev"
optional = false
@@ -269,7 +269,7 @@ typing-extensions = {version = ">=3.7.4.3", markers = "python_version < \"3.8\""
[[package]]
name = "griffe"
version = "0.25.2"
version = "0.25.3"
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
@@ -313,7 +313,7 @@ socks = ["socksio (>=1.0.0,<2.0.0)"]
[[package]]
name = "httpx"
version = "0.23.2"
version = "0.23.3"
description = "The next generation HTTP client."
category = "dev"
optional = false
@@ -333,7 +333,7 @@ socks = ["socksio (>=1.0.0,<2.0.0)"]
[[package]]
name = "identify"
version = "2.5.11"
version = "2.5.12"
description = "File identification library for Python"
category = "dev"
optional = false
@@ -524,7 +524,7 @@ python-legacy = ["mkdocstrings-python-legacy (>=0.2.1)"]
[[package]]
name = "mkdocstrings-python"
version = "0.8.2"
version = "0.8.3"
description = "A Python handler for mkdocstrings."
category = "dev"
optional = false
@@ -1178,57 +1178,57 @@ commonmark = [
{file = "commonmark-0.9.1.tar.gz", hash = "sha256:452f9dc859be7f06631ddcb328b6919c67984aca654e5fefb3914d54691aed60"},
]
coverage = [
{file = "coverage-7.0.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:b3695c4f4750bca943b3e1f74ad4be8d29e4aeab927d50772c41359107bd5d5c"},
{file = "coverage-7.0.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:fa6a5a224b7f4cfb226f4fc55a57e8537fcc096f42219128c2c74c0e7d0953e1"},
{file = "coverage-7.0.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:74f70cd92669394eaf8d7756d1b195c8032cf7bbbdfce3bc489d4e15b3b8cf73"},
{file = "coverage-7.0.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b66bb21a23680dee0be66557dc6b02a3152ddb55edf9f6723fa4a93368f7158d"},
{file = "coverage-7.0.1-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d87717959d4d0ee9db08a0f1d80d21eb585aafe30f9b0a54ecf779a69cb015f6"},
{file = "coverage-7.0.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:854f22fa361d1ff914c7efa347398374cc7d567bdafa48ac3aa22334650dfba2"},
{file = "coverage-7.0.1-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:1e414dc32ee5c3f36544ea466b6f52f28a7af788653744b8570d0bf12ff34bc0"},
{file = "coverage-7.0.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:6c5ad996c6fa4d8ed669cfa1e8551348729d008a2caf81489ab9ea67cfbc7498"},
{file = "coverage-7.0.1-cp310-cp310-win32.whl", hash = "sha256:691571f31ace1837838b7e421d3a09a8c00b4aac32efacb4fc9bd0a5c647d25a"},
{file = "coverage-7.0.1-cp310-cp310-win_amd64.whl", hash = "sha256:89caf4425fe88889e2973a8e9a3f6f5f9bbe5dd411d7d521e86428c08a873a4a"},
{file = "coverage-7.0.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:63d56165a7c76265468d7e0c5548215a5ba515fc2cba5232d17df97bffa10f6c"},
{file = "coverage-7.0.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4f943a3b2bc520102dd3e0bb465e1286e12c9a54f58accd71b9e65324d9c7c01"},
{file = "coverage-7.0.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:830525361249dc4cd013652b0efad645a385707a5ae49350c894b67d23fbb07c"},
{file = "coverage-7.0.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:fd1b9c5adc066db699ccf7fa839189a649afcdd9e02cb5dc9d24e67e7922737d"},
{file = "coverage-7.0.1-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e00c14720b8b3b6c23b487e70bd406abafc976ddc50490f645166f111c419c39"},
{file = "coverage-7.0.1-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:6d55d840e1b8c0002fce66443e124e8581f30f9ead2e54fbf6709fb593181f2c"},
{file = "coverage-7.0.1-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:66b18c3cf8bbab0cce0d7b9e4262dc830e93588986865a8c78ab2ae324b3ed56"},
{file = "coverage-7.0.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:12a5aa77783d49e05439fbe6e6b427484f8a0f9f456b46a51d8aac022cfd024d"},
{file = "coverage-7.0.1-cp311-cp311-win32.whl", hash = "sha256:b77015d1cb8fe941be1222a5a8b4e3fbca88180cfa7e2d4a4e58aeabadef0ab7"},
{file = "coverage-7.0.1-cp311-cp311-win_amd64.whl", hash = "sha256:fb992c47cb1e5bd6a01e97182400bcc2ba2077080a17fcd7be23aaa6e572e390"},
{file = "coverage-7.0.1-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:e78e9dcbf4f3853d3ae18a8f9272111242531535ec9e1009fa8ec4a2b74557dc"},
{file = "coverage-7.0.1-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e60bef2e2416f15fdc05772bf87db06c6a6f9870d1db08fdd019fbec98ae24a9"},
{file = "coverage-7.0.1-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9823e4789ab70f3ec88724bba1a203f2856331986cd893dedbe3e23a6cfc1e4e"},
{file = "coverage-7.0.1-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9158f8fb06747ac17bd237930c4372336edc85b6e13bdc778e60f9d685c3ca37"},
{file = "coverage-7.0.1-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:486ee81fa694b4b796fc5617e376326a088f7b9729c74d9defa211813f3861e4"},
{file = "coverage-7.0.1-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:1285648428a6101b5f41a18991c84f1c3959cee359e51b8375c5882fc364a13f"},
{file = "coverage-7.0.1-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:2c44fcfb3781b41409d0f060a4ed748537557de9362a8a9282182fafb7a76ab4"},
{file = "coverage-7.0.1-cp37-cp37m-win32.whl", hash = "sha256:d6814854c02cbcd9c873c0f3286a02e3ac1250625cca822ca6bc1018c5b19f1c"},
{file = "coverage-7.0.1-cp37-cp37m-win_amd64.whl", hash = "sha256:f66460f17c9319ea4f91c165d46840314f0a7c004720b20be58594d162a441d8"},
{file = "coverage-7.0.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:9b373c9345c584bb4b5f5b8840df7f4ab48c4cbb7934b58d52c57020d911b856"},
{file = "coverage-7.0.1-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:d3022c3007d3267a880b5adcf18c2a9bf1fc64469b394a804886b401959b8742"},
{file = "coverage-7.0.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:92651580bd46519067e36493acb394ea0607b55b45bd81dd4e26379ed1871f55"},
{file = "coverage-7.0.1-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3cfc595d2af13856505631be072835c59f1acf30028d1c860b435c5fc9c15b69"},
{file = "coverage-7.0.1-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0b4b3a4d9915b2be879aff6299c0a6129f3d08a775d5a061f503cf79571f73e4"},
{file = "coverage-7.0.1-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:b6f22bb64cc39bcb883e5910f99a27b200fdc14cdd79df8696fa96b0005c9444"},
{file = "coverage-7.0.1-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:72d1507f152abacea81f65fee38e4ef3ac3c02ff8bc16f21d935fd3a8a4ad910"},
{file = "coverage-7.0.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:0a79137fc99815fff6a852c233628e735ec15903cfd16da0f229d9c4d45926ab"},
{file = "coverage-7.0.1-cp38-cp38-win32.whl", hash = "sha256:b3763e7fcade2ff6c8e62340af9277f54336920489ceb6a8cd6cc96da52fcc62"},
{file = "coverage-7.0.1-cp38-cp38-win_amd64.whl", hash = "sha256:09f6b5a8415b6b3e136d5fec62b552972187265cb705097bf030eb9d4ffb9b60"},
{file = "coverage-7.0.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:978258fec36c154b5e250d356c59af7d4c3ba02bef4b99cda90b6029441d797d"},
{file = "coverage-7.0.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:19ec666533f0f70a0993f88b8273057b96c07b9d26457b41863ccd021a043b9a"},
{file = "coverage-7.0.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cfded268092a84605f1cc19e5c737f9ce630a8900a3589e9289622db161967e9"},
{file = "coverage-7.0.1-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:07bcfb1d8ac94af886b54e18a88b393f6a73d5959bb31e46644a02453c36e475"},
{file = "coverage-7.0.1-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:397b4a923cc7566bbc7ae2dfd0ba5a039b61d19c740f1373791f2ebd11caea59"},
{file = "coverage-7.0.1-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:aec2d1515d9d39ff270059fd3afbb3b44e6ec5758af73caf18991807138c7118"},
{file = "coverage-7.0.1-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:c20cfebcc149a4c212f6491a5f9ff56f41829cd4f607b5be71bb2d530ef243b1"},
{file = "coverage-7.0.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:fd556ff16a57a070ce4f31c635953cc44e25244f91a0378c6e9bdfd40fdb249f"},
{file = "coverage-7.0.1-cp39-cp39-win32.whl", hash = "sha256:b9ea158775c7c2d3e54530a92da79496fb3fb577c876eec761c23e028f1e216c"},
{file = "coverage-7.0.1-cp39-cp39-win_amd64.whl", hash = "sha256:d1991f1dd95eba69d2cd7708ff6c2bbd2426160ffc73c2b81f617a053ebcb1a8"},
{file = "coverage-7.0.1-pp37.pp38.pp39-none-any.whl", hash = "sha256:3dd4ee135e08037f458425b8842d24a95a0961831a33f89685ff86b77d378f89"},
{file = "coverage-7.0.1.tar.gz", hash = "sha256:a4a574a19eeb67575a5328a5760bbbb737faa685616586a9f9da4281f940109c"},
{file = "coverage-7.0.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:2f7c51b6074a8a3063c341953dffe48fd6674f8e4b1d3c8aa8a91f58d6e716a8"},
{file = "coverage-7.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:628f47eaf66727fc986d3b190d6fa32f5e6b7754a243919d28bc0fd7974c449f"},
{file = "coverage-7.0.3-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e89d5abf86c104de808108a25d171ad646c07eda96ca76c8b237b94b9c71e518"},
{file = "coverage-7.0.3-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:75e43c6f4ea4d122dac389aabdf9d4f0e160770a75e63372f88005d90f5bcc80"},
{file = "coverage-7.0.3-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:49da0ff241827ebb52d5d6d5a36d33b455fa5e721d44689c95df99fd8db82437"},
{file = "coverage-7.0.3-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:0bce4ad5bdd0b02e177a085d28d2cea5fc57bb4ba2cead395e763e34cf934eb1"},
{file = "coverage-7.0.3-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:f79691335257d60951638dd43576b9bcd6f52baa5c1c2cd07a509bb003238372"},
{file = "coverage-7.0.3-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:5722269ed05fbdb94eef431787c66b66260ff3125d1a9afcc00facff8c45adf9"},
{file = "coverage-7.0.3-cp310-cp310-win32.whl", hash = "sha256:bdbda870e0fda7dd0fe7db7135ca226ec4c1ade8aa76e96614829b56ca491012"},
{file = "coverage-7.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:e56fae4292e216b8deeee38ace84557b9fa85b52db005368a275427cdabb8192"},
{file = "coverage-7.0.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:b82343a5bc51627b9d606f0b6b6b9551db7b6311a5dd920fa52a94beae2e8959"},
{file = "coverage-7.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:fd0a8aa431f9b7ad9eb8264f55ef83cbb254962af3775092fb6e93890dea9ca2"},
{file = "coverage-7.0.3-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:112cfead1bd22eada8a8db9ed387bd3e8be5528debc42b5d3c1f7da4ffaf9fb5"},
{file = "coverage-7.0.3-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:af87e906355fa42447be5c08c5d44e6e1c005bf142f303f726ddf5ed6e0c8a4d"},
{file = "coverage-7.0.3-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f30090e22a301952c5abd0e493a1c8358b4f0b368b49fa3e4568ed3ed68b8d1f"},
{file = "coverage-7.0.3-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:ae871d09901911eedda1981ea6fd0f62a999107293cdc4c4fd612321c5b34745"},
{file = "coverage-7.0.3-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:ed7c9debf7bfc63c9b9f8b595409237774ff4b061bf29fba6f53b287a2fdeab9"},
{file = "coverage-7.0.3-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:13121fa22dcd2c7b19c5161e3fd725692448f05377b788da4502a383573227b3"},
{file = "coverage-7.0.3-cp311-cp311-win32.whl", hash = "sha256:037b51ee86bc600f99b3b957c20a172431c35c2ef9c1ca34bc813ab5b51fd9f5"},
{file = "coverage-7.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:25fde928306034e8deecd5fc91a07432dcc282c8acb76749581a28963c9f4f3f"},
{file = "coverage-7.0.3-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:7e8b0642c38b3d3b3c01417643ccc645345b03c32a2e84ef93cdd6844d6fe530"},
{file = "coverage-7.0.3-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:18b09811f849cc958d23f733a350a66b54a8de3fed1e6128ba55a5c97ffb6f65"},
{file = "coverage-7.0.3-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:349d0b545520e8516f7b4f12373afc705d17d901e1de6a37a20e4ec9332b61f7"},
{file = "coverage-7.0.3-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b5b38813eee5b4739f505d94247604c72eae626d5088a16dd77b08b8b1724ab3"},
{file = "coverage-7.0.3-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:ba9af1218fa01b1f11c72271bc7290b701d11ad4dbc2ae97c445ecacf6858dba"},
{file = "coverage-7.0.3-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:c5648c7eec5cf1ba5db1cf2d6c10036a582d7f09e172990474a122e30c841361"},
{file = "coverage-7.0.3-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:d0df04495b76a885bfef009f45eebe8fe2fbf815ad7a83dabcf5aced62f33162"},
{file = "coverage-7.0.3-cp37-cp37m-win32.whl", hash = "sha256:af6cef3796b8068713a48dd67d258dc9a6e2ebc3bd4645bfac03a09672fa5d20"},
{file = "coverage-7.0.3-cp37-cp37m-win_amd64.whl", hash = "sha256:62ef3800c4058844e2e3fa35faa9dd0ccde8a8aba6c763aae50342e00d4479d4"},
{file = "coverage-7.0.3-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:acef7f3a3825a2d218a03dd02f5f3cc7f27aa31d882dd780191d1ad101120d74"},
{file = "coverage-7.0.3-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:a530663a361eb27375cec28aea5cd282089b5e4b022ae451c4c3493b026a68a5"},
{file = "coverage-7.0.3-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c58cd6bb46dcb922e0d5792850aab5964433d511b3a020867650f8d930dde4f4"},
{file = "coverage-7.0.3-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f918e9ef4c98f477a5458238dde2a1643aed956c7213873ab6b6b82e32b8ef61"},
{file = "coverage-7.0.3-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2b865aa679bee7fbd1c55960940dbd3252621dd81468268786c67122bbd15343"},
{file = "coverage-7.0.3-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:c5d9b480ebae60fc2cbc8d6865194136bc690538fa542ba58726433bed6e04cc"},
{file = "coverage-7.0.3-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:985ad2af5ec3dbb4fd75d5b0735752c527ad183455520055a08cf8d6794cabfc"},
{file = "coverage-7.0.3-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:ca15308ef722f120967af7474ba6a453e0f5b6f331251e20b8145497cf1bc14a"},
{file = "coverage-7.0.3-cp38-cp38-win32.whl", hash = "sha256:c1cee10662c25c94415bbb987f2ec0e6ba9e8fce786334b10be7e6a7ab958f69"},
{file = "coverage-7.0.3-cp38-cp38-win_amd64.whl", hash = "sha256:44d6a556de4418f1f3bfd57094b8c49f0408df5a433cf0d253eeb3075261c762"},
{file = "coverage-7.0.3-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:e6dcc70a25cb95df0ae33dfc701de9b09c37f7dd9f00394d684a5b57257f8246"},
{file = "coverage-7.0.3-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:bf76d79dfaea802f0f28f50153ffbc1a74ae1ee73e480baeda410b4f3e7ab25f"},
{file = "coverage-7.0.3-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:88834e5d56d01c141c29deedacba5773fe0bed900b1edc957595a8a6c0da1c3c"},
{file = "coverage-7.0.3-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ef001a60e888f8741e42e5aa79ae55c91be73761e4df5e806efca1ddd62fd400"},
{file = "coverage-7.0.3-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4959dc506be74e4963bd2c42f7b87d8e4b289891201e19ec551e64c6aa5441f8"},
{file = "coverage-7.0.3-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:b791beb17b32ac019a78cfbe6184f992b6273fdca31145b928ad2099435e2fcb"},
{file = "coverage-7.0.3-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:b07651e3b9af8f1a092861d88b4c74d913634a7f1f2280fca0ad041ad84e9e96"},
{file = "coverage-7.0.3-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:55e46fa4168ccb7497c9be78627fcb147e06f474f846a10d55feeb5108a24ef0"},
{file = "coverage-7.0.3-cp39-cp39-win32.whl", hash = "sha256:e3f1cd1cd65695b1540b3cf7828d05b3515974a9d7c7530f762ac40f58a18161"},
{file = "coverage-7.0.3-cp39-cp39-win_amd64.whl", hash = "sha256:d8249666c23683f74f8f93aeaa8794ac87cc61c40ff70374a825f3352a4371dc"},
{file = "coverage-7.0.3-pp37.pp38.pp39-none-any.whl", hash = "sha256:b1ffc8f58b81baed3f8962e28c30d99442079b82ce1ec836a1f67c0accad91c1"},
{file = "coverage-7.0.3.tar.gz", hash = "sha256:d5be4e93acce64f516bf4fd239c0e6118fc913c93fa1a3f52d15bdcc60d97b2d"},
]
distlib = [
{file = "distlib-0.3.6-py2.py3-none-any.whl", hash = "sha256:f35c4b692542ca110de7ef0bea44d73981caeb34ca0b9b6b2e6d7790dda8f80e"},
@@ -1331,8 +1331,8 @@ gitpython = [
{file = "GitPython-3.1.30.tar.gz", hash = "sha256:769c2d83e13f5d938b7688479da374c4e3d49f71549aaf462b646db9602ea6f8"},
]
griffe = [
{file = "griffe-0.25.2-py3-none-any.whl", hash = "sha256:0868da415c5f43fe186705c041d98b69523c24a6504e841031373eacfdd7ec05"},
{file = "griffe-0.25.2.tar.gz", hash = "sha256:555707b3417355e015d837845522cb38ee4ffcec485427868648eafacabe142e"},
{file = "griffe-0.25.3-py3-none-any.whl", hash = "sha256:c98e8471a4fc7675a7989f45563a9f7ccbfdfb1713725526d69dec1bbdcda74a"},
{file = "griffe-0.25.3.tar.gz", hash = "sha256:a71f156851649b3f0bdad6eb6bf7d7ac70e720a30da9f2d5a60e042480e92c03"},
]
h11 = [
{file = "h11-0.14.0-py3-none-any.whl", hash = "sha256:e3fe4ac4b851c468cc8363d500db52c2ead036020723024a109d37346efaa761"},
@@ -1343,12 +1343,12 @@ httpcore = [
{file = "httpcore-0.16.3.tar.gz", hash = "sha256:c5d6f04e2fc530f39e0c077e6a30caa53f1451096120f1f38b954afd0b17c0cb"},
]
httpx = [
{file = "httpx-0.23.2-py3-none-any.whl", hash = "sha256:106cded342a44e443060fab70ef327139248c61939e77d73964560c8d8b57069"},
{file = "httpx-0.23.2.tar.gz", hash = "sha256:e824a6fa18ffaa6423c6f3a32d5096fc15bd8dff43663a223f06242fc69451a8"},
{file = "httpx-0.23.3-py3-none-any.whl", hash = "sha256:a211fcce9b1254ea24f0cd6af9869b3d29aba40154e947d2a07bb499b3e310d6"},
{file = "httpx-0.23.3.tar.gz", hash = "sha256:9818458eb565bb54898ccb9b8b251a28785dd4a55afbc23d0eb410754fe7d0f9"},
]
identify = [
{file = "identify-2.5.11-py2.py3-none-any.whl", hash = "sha256:e7db36b772b188099616aaf2accbee122949d1c6a1bac4f38196720d6f9f06db"},
{file = "identify-2.5.11.tar.gz", hash = "sha256:14b7076b29c99b1b0b8b08e96d448c7b877a9b07683cd8cfda2ea06af85ffa1c"},
{file = "identify-2.5.12-py2.py3-none-any.whl", hash = "sha256:e8a400c3062d980243d27ce10455a52832205649bbcaf27ffddb3dfaaf477bad"},
{file = "identify-2.5.12.tar.gz", hash = "sha256:0bc96b09c838310b6fcfcc61f78a981ea07f94836ef6ef553da5bb5d4745d662"},
]
idna = [
{file = "idna-3.4-py3-none-any.whl", hash = "sha256:90b77e79eaa3eba6de819a0c442c0b4ceefc341a7a2ab77d7562bf49f425c5c2"},
@@ -1441,8 +1441,8 @@ mkdocstrings = [
{file = "mkdocstrings-0.19.1.tar.gz", hash = "sha256:d1037cacb4b522c1e8c164ed5d00d724a82e49dcee0af80db8fb67b384faeef9"},
]
mkdocstrings-python = [
{file = "mkdocstrings-python-0.8.2.tar.gz", hash = "sha256:b22528b7a7a0589d007eced019d97ad14de4eba4b2b9ba6a013bb66edc74ab43"},
{file = "mkdocstrings_python-0.8.2-py3-none-any.whl", hash = "sha256:213d9592e66e084a9bd2fa4956d6294a3487c6dc9cc45164058d6317249b7b6e"},
{file = "mkdocstrings-python-0.8.3.tar.gz", hash = "sha256:9ae473f6dc599339b09eee17e4d2b05d6ac0ec29860f3fc9b7512d940fc61adf"},
{file = "mkdocstrings_python-0.8.3-py3-none-any.whl", hash = "sha256:4e6e1cd6f37a785de0946ced6eb846eb2f5d891ac1cc2c7b832943d3529087a7"},
]
msgpack = [
{file = "msgpack-1.0.4-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:4ab251d229d10498e9a2f3b1e68ef64cb393394ec477e3370c457f9430ce9250"},

View File

@@ -253,8 +253,8 @@ class Compositor:
# Keep a copy of the old map because we're going to compare it with the update
old_map = self.map.copy()
old_widgets = old_map.keys()
map, widgets = self._arrange_root(parent, size)
map, widgets = self._arrange_root(parent, size)
new_widgets = map.keys()
# Newly visible widgets
@@ -266,20 +266,21 @@ class Compositor:
self.map = map
self.widgets = widgets
screen = size.region
# Contains widgets + geometry for every widget that changed (added, removed, or updated)
changes = map.items() ^ old_map.items()
# Widgets in both new and old
common_widgets = old_widgets & new_widgets
# Widgets with changed size
resized_widgets = {
widget
for widget, (region, *_) in map.items()
if widget in old_widgets and old_map[widget].region.size != region.size
for widget, (region, *_) in changes
if (widget in common_widgets and old_map[widget].region[2:] != region[2:])
}
# Gets pairs of tuples of (Widget, MapGeometry) which have changed
# i.e. if something is moved / deleted / added
if screen not in self._dirty_regions:
changes = map.items() ^ old_map.items()
screen_region = size.region
if screen_region not in self._dirty_regions:
regions = {
region
for region in (
@@ -346,6 +347,7 @@ class Compositor:
layer_order: int,
clip: Region,
visible: bool,
_MapGeometry=MapGeometry,
) -> None:
"""Called recursively to place a widget and its children in the map.
@@ -392,50 +394,57 @@ class Compositor:
)
widgets.update(arranged_widgets)
# An offset added to all placements
placement_offset = container_region.offset
placement_scroll_offset = placement_offset - widget.scroll_offset
if placements:
# An offset added to all placements
placement_offset = container_region.offset
placement_scroll_offset = (
placement_offset - widget.scroll_offset
)
_layers = widget.layers
layers_to_index = {
layer_name: index for index, layer_name in enumerate(_layers)
}
get_layer_index = layers_to_index.get
_layers = widget.layers
layers_to_index = {
layer_name: index
for index, layer_name in enumerate(_layers)
}
get_layer_index = layers_to_index.get
# Add all the widgets
for sub_region, margin, sub_widget, z, fixed in reversed(
placements
):
# Combine regions with children to calculate the "virtual size"
if fixed:
widget_region = sub_region + placement_offset
else:
total_region = total_region.union(
sub_region.grow(spacing + margin)
# Add all the widgets
for sub_region, margin, sub_widget, z, fixed in reversed(
placements
):
# Combine regions with children to calculate the "virtual size"
if fixed:
widget_region = sub_region + placement_offset
else:
total_region = total_region.union(
sub_region.grow(spacing + margin)
)
widget_region = sub_region + placement_scroll_offset
widget_order = (
*order,
get_layer_index(sub_widget.layer, 0),
z,
layer_order,
)
widget_region = sub_region + placement_scroll_offset
widget_order = order + (
(get_layer_index(sub_widget.layer, 0), z, layer_order),
)
add_widget(
sub_widget,
sub_region,
widget_region,
widget_order,
layer_order,
sub_clip,
visible,
)
layer_order -= 1
add_widget(
sub_widget,
sub_region,
widget_region,
widget_order,
layer_order,
sub_clip,
visible,
)
layer_order -= 1
if visible:
# Add any scrollbars
for chrome_widget, chrome_region in widget._arrange_scrollbars(
container_region
):
map[chrome_widget] = MapGeometry(
map[chrome_widget] = _MapGeometry(
chrome_region + layout_offset,
order,
clip,
@@ -444,7 +453,7 @@ class Compositor:
chrome_region,
)
map[widget] = MapGeometry(
map[widget] = _MapGeometry(
region + layout_offset,
order,
clip,
@@ -455,7 +464,7 @@ class Compositor:
elif visible:
# Add the widget to the map
map[widget] = MapGeometry(
map[widget] = _MapGeometry(
region + layout_offset,
order,
clip,

View File

@@ -14,6 +14,56 @@ from .css.types import AlignHorizontal, AlignVertical
from .geometry import Size
class NoCellPositionForIndex(Exception):
pass
def index_to_cell_position(segments: Iterable[Segment], index: int) -> int:
"""Given a character index, return the cell position of that character within
an Iterable of Segments. This is the sum of the cell lengths of all the characters
*before* the character at `index`.
Args:
segments (Iterable[Segment]): The segments to find the cell position within.
index (int): The index to convert into a cell position.
Returns:
int: The cell position of the character at `index`.
Raises:
NoCellPositionForIndex: If the supplied index doesn't fall within the given segments.
"""
if not segments:
raise NoCellPositionForIndex
if index == 0:
return 0
cell_position_end = 0
segment_length = 0
segment_end_index = 0
segment_cell_length = 0
text = ""
iter_segments = iter(segments)
try:
while segment_end_index < index:
segment = next(iter_segments)
text = segment.text
segment_length = len(text)
segment_cell_length = cell_len(text)
cell_position_end += segment_cell_length
segment_end_index += segment_length
except StopIteration:
raise NoCellPositionForIndex
# Check how far into this segment the target index is
segment_index_start = segment_end_index - segment_length
index_within_segment = index - segment_index_start
segment_cell_start = cell_position_end - segment_cell_length
return segment_cell_start + cell_len(text[:index_within_segment])
def line_crop(
segments: list[Segment], start: int, end: int, total: int
) -> list[Segment]:

View File

@@ -1,6 +1,8 @@
from __future__ import annotations
import asyncio
from concurrent.futures import Future
from functools import partial
import inspect
import io
import os
@@ -18,6 +20,7 @@ from time import perf_counter
from typing import (
TYPE_CHECKING,
Any,
Awaitable,
Callable,
Generic,
Iterable,
@@ -206,6 +209,8 @@ class _WriterThread(threading.Thread):
CSSPathType = Union[str, PurePath, List[Union[str, PurePath]], None]
CallThreadReturnType = TypeVar("CallThreadReturnType")
@rich.repr.auto
class App(Generic[ReturnType], DOMNode):
@@ -353,6 +358,8 @@ class App(Generic[ReturnType], DOMNode):
else:
self.devtools = DevtoolsClient()
self._loop: asyncio.AbstractEventLoop | None = None
self._thread_id: int = 0
self._return_value: ReturnType | None = None
self._exit = False
@@ -604,6 +611,51 @@ class App(Generic[ReturnType], DOMNode):
except Exception as error:
self._handle_exception(error)
def call_from_thread(
self,
callback: Callable[..., CallThreadReturnType | Awaitable[CallThreadReturnType]],
*args,
**kwargs,
) -> CallThreadReturnType:
"""Run a callback from another thread.
Like asyncio apps in general, Textual apps are not thread-safe. If you call methods
or set attributes on Textual objects from a thread, you may get unpredictable results.
This method will ensure that your code is ran within the correct context.
Args:
callback (Callable): A callable to run.
*args: Arguments to the callback.
**kwargs: Keyword arguments for the callback.
Raises:
RuntimeError: If the app isn't running or if this method is called from the same
thread where the app is running.
"""
if self._loop is None:
raise RuntimeError("App is not running")
if self._thread_id == threading.get_ident():
raise RuntimeError(
"The `call_from_thread` method must run in a different thread from the app"
)
callback_with_args = partial(callback, *args, **kwargs)
async def run_callback() -> CallThreadReturnType:
"""Run the callback, set the result or error on the future."""
self._set_active()
return await invoke(callback_with_args)
# Post the message to the main loop
future: Future[CallThreadReturnType] = asyncio.run_coroutine_threadsafe(
run_callback(), loop=self._loop
)
result = future.result()
return result
def action_toggle_dark(self) -> None:
"""Action to toggle dark mode."""
self.dark = not self.dark
@@ -874,11 +926,17 @@ class App(Generic[ReturnType], DOMNode):
async def run_app() -> None:
"""Run the app."""
await self.run_async(
headless=headless,
size=size,
auto_pilot=auto_pilot,
)
self._loop = asyncio.get_running_loop()
self._thread_id = threading.get_ident()
try:
await self.run_async(
headless=headless,
size=size,
auto_pilot=auto_pilot,
)
finally:
self._loop = None
self._thread_id = 0
if _ASYNCIO_GET_EVENT_LOOP_IS_DEPRECATED:
# N.B. This doesn't work with Python<3.10, as we end up with 2 event loops:

View File

@@ -684,6 +684,7 @@ class Region(NamedTuple):
)
return new_region
@lru_cache(maxsize=4096)
def grow(self, margin: tuple[int, int, int, int]) -> Region:
"""Grow a region by adding spacing.
@@ -704,6 +705,7 @@ class Region(NamedTuple):
height=max(0, height + top + bottom),
)
@lru_cache(maxsize=4096)
def shrink(self, margin: tuple[int, int, int, int]) -> Region:
"""Shrink a region by subtracting spacing.
@@ -713,7 +715,8 @@ class Region(NamedTuple):
Returns:
Region: The new, smaller region.
"""
if not any(margin):
return self
top, right, bottom, left = margin
x, y, width, height = self
return Region(

View File

@@ -10,6 +10,7 @@ from rich.style import Style
from ._cache import FIFOCache
from ._filter import LineFilter
from ._segment_tools import index_to_cell_position
@rich.repr.auto
@@ -68,6 +69,19 @@ class Strip:
"""
return [cls(segments, cell_length) for segments in lines]
def index_to_cell_position(self, index: int) -> int:
"""Given a character index, return the cell position of that character.
This is the sum of the cell lengths of all the characters *before* the character
at `index`.
Args:
index (int): The index to convert.
Returns:
int: The cell position of the character at `index`.
"""
return index_to_cell_position(self._segments, index)
@property
def cell_length(self) -> int:
"""Get the number of cells required to render this object."""

View File

@@ -802,15 +802,17 @@ class Widget(DOMNode):
if self.auto_links:
self.highlight_link_id = hover_style.link_id
def watch_scroll_x(self, new_value: float) -> None:
def watch_scroll_x(self, old_value: float, new_value: float) -> None:
if self.show_horizontal_scrollbar:
self.horizontal_scrollbar.position = int(new_value)
self.refresh(layout=True, repaint=False)
self.horizontal_scrollbar.position = round(new_value)
if round(old_value) != round(new_value):
self._refresh_scroll()
def watch_scroll_y(self, new_value: float) -> None:
def watch_scroll_y(self, old_value: float, new_value: float) -> None:
if self.show_vertical_scrollbar:
self.vertical_scrollbar.position = int(new_value)
self.refresh(layout=True, repaint=False)
self.vertical_scrollbar.position = round(new_value)
if round(old_value) != round(new_value):
self._refresh_scroll()
def validate_scroll_x(self, value: float) -> float:
return clamp(value, 0, self.max_scroll_x)
@@ -1147,7 +1149,7 @@ class Widget(DOMNode):
Returns:
Offset: Offset a container has been scrolled by.
"""
return Offset(int(self.scroll_x), int(self.scroll_y))
return Offset(round(self.scroll_x), round(self.scroll_y))
@property
def is_transparent(self) -> bool:
@@ -2155,8 +2157,16 @@ class Widget(DOMNode):
event._set_forwarded()
await self.post_message(event)
def _refresh_scroll(self) -> None:
"""Refreshes the scroll position."""
self._layout_required = True
self.check_idle()
def refresh(
self, *regions: Region, repaint: bool = True, layout: bool = False
self,
*regions: Region,
repaint: bool = True,
layout: bool = False,
) -> None:
"""Initiate a refresh of the widget.

53
tests/test_concurrency.py Normal file
View File

@@ -0,0 +1,53 @@
import pytest
from threading import Thread
from textual.app import App, ComposeResult
from textual.widgets import TextLog
def test_call_from_thread_app_not_running():
app = App()
# Should fail if app is not running
with pytest.raises(RuntimeError):
app.call_from_thread(print)
def test_call_from_thread():
"""Test the call_from_thread method."""
class BackgroundThread(Thread):
"""A background thread which will modify app in some way."""
def __init__(self, app: App) -> None:
self.app = app
super().__init__()
def run(self) -> None:
def write_stuff(text: str) -> None:
"""Write stuff to a widget."""
self.app.query_one(TextLog).write(text)
self.app.call_from_thread(write_stuff, "Hello")
# Exit the app with a code we can assert
self.app.call_from_thread(self.app.exit, 123)
class ThreadTestApp(App):
"""Trivial app with a single widget."""
def compose(self) -> ComposeResult:
yield TextLog()
def on_ready(self) -> None:
"""Launch a thread which will modify the app."""
try:
self.call_from_thread(print)
except RuntimeError as error:
# Calling this from the same thread as the app is an error
self._runtime_error = error
BackgroundThread(self).start()
app = ThreadTestApp()
result = app.run(headless=True, size=(80, 24))
assert isinstance(app._runtime_error, RuntimeError)
assert result == 123

View File

@@ -24,7 +24,7 @@ def test_non_empty_immutable_sequence() -> None:
def test_no_assign_to_immutable_sequence() -> None:
"""It should not be possible to assign into an immutable sequence."""
tester = wrap([1,2,3,4,5])
tester = wrap([1, 2, 3, 4, 5])
with pytest.raises(TypeError):
tester[0] = 23
with pytest.raises(TypeError):
@@ -33,7 +33,7 @@ def test_no_assign_to_immutable_sequence() -> None:
def test_no_del_from_iummutable_sequence() -> None:
"""It should not be possible delete an item from an immutable sequence."""
tester = wrap([1,2,3,4,5])
tester = wrap([1, 2, 3, 4, 5])
with pytest.raises(TypeError):
del tester[0]
@@ -46,23 +46,23 @@ def test_get_item_from_immutable_sequence() -> None:
def test_get_slice_from_immutable_sequence() -> None:
"""It should be possible to get a slice from an immutable sequence."""
assert list(wrap(range(10))[0:2]) == [0,1]
assert list(wrap(range(10))[0:-1]) == [0,1,2,3,4,5,6,7,8]
assert list(wrap(range(10))[0:2]) == [0, 1]
assert list(wrap(range(10))[0:-1]) == [0, 1, 2, 3, 4, 5, 6, 7, 8]
def test_immutable_sequence_contains() -> None:
"""It should be possible to see if an immutable sequence contains a value."""
tester = wrap([1,2,3,4,5])
tester = wrap([1, 2, 3, 4, 5])
assert 1 in tester
assert 11 not in tester
def test_immutable_sequence_index() -> None:
tester = wrap([1,2,3,4,5])
tester = wrap([1, 2, 3, 4, 5])
assert tester.index(1) == 0
with pytest.raises(ValueError):
_ = tester.index(11)
def test_reverse_immutable_sequence() -> None:
assert list(reversed(wrap([1,2]))) == [2,1]
assert list(reversed(wrap([1, 2]))) == [2, 1]

View File

@@ -1,6 +1,8 @@
import pytest
from rich.segment import Segment
from rich.style import Style
from textual._segment_tools import NoCellPositionForIndex
from textual.strip import Strip
from textual._filter import Monochrome
@@ -62,9 +64,7 @@ def test_eq():
def test_adjust_cell_length():
for repeat in range(3):
assert Strip([]).adjust_cell_length(3) == Strip([Segment(" ")])
assert Strip([Segment("f")]).adjust_cell_length(3) == Strip(
[Segment("f"), Segment(" ")]
@@ -119,9 +119,7 @@ def test_style_links():
def test_crop():
for repeat in range(3):
assert Strip([Segment("foo")]).crop(0, 3) == Strip([Segment("foo")])
assert Strip([Segment("foo")]).crop(0, 2) == Strip([Segment("fo")])
assert Strip([Segment("foo")]).crop(0, 1) == Strip([Segment("f")])
@@ -136,10 +134,42 @@ def test_crop():
def test_divide():
for repeat in range(3):
assert Strip([Segment("foo")]).divide([1, 2]) == [
Strip([Segment("f")]),
Strip([Segment("o")]),
]
@pytest.mark.parametrize(
"index,cell_position",
[
(0, 0),
(1, 1),
(2, 2),
(3, 3),
(4, 4),
(5, 6),
(6, 8),
(7, 10),
(8, 11),
(9, 12),
(10, 13),
(11, 14),
],
)
def test_index_to_cell_position(index, cell_position):
strip = Strip([Segment("ab"), Segment("cd日本語ef"), Segment("gh")])
assert cell_position == strip.index_to_cell_position(index)
def test_index_cell_position_no_segments():
strip = Strip([])
with pytest.raises(NoCellPositionForIndex):
strip.index_to_cell_position(2)
def test_index_cell_position_index_too_large():
strip = Strip([Segment("abcdef"), Segment("ghi")])
with pytest.raises(NoCellPositionForIndex):
strip.index_to_cell_position(100)

View File

@@ -1,29 +1,29 @@
import pytest
from textual.widgets import Tree, TreeNode
def label_of(node: TreeNode[None]):
"""Get the label of a node.
TODO: This is just a helper function to reduce the number of type
errors, which can and will be remove once this code is merged with a
version of main that also has the TreeNode.label PR merged.
"""
return str(node._label)
def label_of(node: TreeNode[None]):
"""Get the label of a node as a string"""
return str(node.label)
def test_tree_node_children() -> None:
"""A node's children property should act like an immutable list."""
CHILDREN=23
CHILDREN = 23
tree = Tree[None]("Root")
for child in range(CHILDREN):
tree.root.add(str(child))
assert len(tree.root.children)==CHILDREN
assert len(tree.root.children) == CHILDREN
for child in range(CHILDREN):
assert label_of(tree.root.children[child]) == str(child)
assert label_of(tree.root.children[0]) == "0"
assert label_of(tree.root.children[-1]) == str(CHILDREN-1)
assert [label_of(node) for node in tree.root.children] == [str(n) for n in range(CHILDREN)]
assert [label_of(node) for node in tree.root.children[:2]] == [str(n) for n in range(2)]
assert label_of(tree.root.children[-1]) == str(CHILDREN - 1)
assert [label_of(node) for node in tree.root.children] == [
str(n) for n in range(CHILDREN)
]
assert [label_of(node) for node in tree.root.children[:2]] == [
str(n) for n in range(2)
]
with pytest.raises(TypeError):
tree.root.children[0] = tree.root.children[1]
with pytest.raises(TypeError):

View File

@@ -1,6 +1,7 @@
from textual.widgets import Tree, TreeNode
from rich.text import Text
def test_tree_node_label() -> None:
"""It should be possible to modify a TreeNode's label."""
node = TreeNode(Tree[None]("Xenomorph Lifecycle"), None, 0, "Facehugger")
@@ -8,6 +9,7 @@ def test_tree_node_label() -> None:
node.label = "Chestbuster"
assert node.label == Text("Chestbuster")
def test_tree_node_label_via_tree() -> None:
"""It should be possible to modify a TreeNode's label when created via a Tree."""
tree = Tree[None]("Xenomorph Lifecycle")

View File

@@ -1,5 +1,6 @@
from textual.widgets import TreeNode, Tree
def test_tree_node_parent() -> None:
"""It should be possible to access a TreeNode's parent."""
tree = Tree[None]("Anakin")