diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 0a091f72f..bee80413b 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -12,5 +12,4 @@ repos: rev: 22.10.0 hooks: - id: black - exclude: ^tests/ exclude: ^tests/snapshot_tests diff --git a/CHANGELOG.md b/CHANGELOG.md index 8da8e3b9e..90bb8ddc2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/docs/blog/posts/looking-for-help.md b/docs/blog/posts/looking-for-help.md new file mode 100644 index 000000000..fe142303d --- /dev/null +++ b/docs/blog/posts/looking-for-help.md @@ -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. + + Juan Sánchez Villalobos Ramírez, Chief metallurgist to King Charles V of Spain + +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. + + + +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)! diff --git a/docs/help.md b/docs/help.md index 488d0919b..a27dae4e0 100644 --- a/docs/help.md +++ b/docs/help.md @@ -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. - diff --git a/poetry.lock b/poetry.lock index 821e4f4f3..34c7f058e 100644 --- a/poetry.lock +++ b/poetry.lock @@ -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"}, diff --git a/src/textual/_compositor.py b/src/textual/_compositor.py index 1e7ab1603..ca92e525e 100644 --- a/src/textual/_compositor.py +++ b/src/textual/_compositor.py @@ -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, diff --git a/src/textual/_segment_tools.py b/src/textual/_segment_tools.py index c12b06cbc..3ccfcf97a 100644 --- a/src/textual/_segment_tools.py +++ b/src/textual/_segment_tools.py @@ -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]: diff --git a/src/textual/app.py b/src/textual/app.py index b207bbd02..89da6aaf6 100644 --- a/src/textual/app.py +++ b/src/textual/app.py @@ -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: diff --git a/src/textual/geometry.py b/src/textual/geometry.py index 9b5c09f03..17e938b1d 100644 --- a/src/textual/geometry.py +++ b/src/textual/geometry.py @@ -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( diff --git a/src/textual/strip.py b/src/textual/strip.py index 5a4c1404e..9f8575c6d 100644 --- a/src/textual/strip.py +++ b/src/textual/strip.py @@ -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.""" diff --git a/src/textual/widget.py b/src/textual/widget.py index 185d18a64..8c3f7951d 100644 --- a/src/textual/widget.py +++ b/src/textual/widget.py @@ -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. diff --git a/tests/test_concurrency.py b/tests/test_concurrency.py new file mode 100644 index 000000000..c73418f2f --- /dev/null +++ b/tests/test_concurrency.py @@ -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 diff --git a/tests/test_immutable_sequence_view.py b/tests/test_immutable_sequence_view.py index 5af7f5133..500eaa6d0 100644 --- a/tests/test_immutable_sequence_view.py +++ b/tests/test_immutable_sequence_view.py @@ -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] diff --git a/tests/test_strip.py b/tests/test_strip.py index 891af9845..7674c2fd8 100644 --- a/tests/test_strip.py +++ b/tests/test_strip.py @@ -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) diff --git a/tests/tree/test_tree_node_children.py b/tests/tree/test_tree_node_children.py index 22df664db..eb5c949c0 100644 --- a/tests/tree/test_tree_node_children.py +++ b/tests/tree/test_tree_node_children.py @@ -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): diff --git a/tests/tree/test_tree_node_label.py b/tests/tree/test_tree_node_label.py index 55af8088e..e64fcf24d 100644 --- a/tests/tree/test_tree_node_label.py +++ b/tests/tree/test_tree_node_label.py @@ -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") diff --git a/tests/tree/test_tree_node_parent.py b/tests/tree/test_tree_node_parent.py index 90353ea92..b9d85af43 100644 --- a/tests/tree/test_tree_node_parent.py +++ b/tests/tree/test_tree_node_parent.py @@ -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")