Merge branch 'main' into review-styles-reference
[skip ci]
20
.faq/FAQ.md
Normal file
@@ -0,0 +1,20 @@
|
||||
|
||||
# Frequently Asked Questions
|
||||
|
||||
{%- for question in questions %}
|
||||
- [{{ question.title }}](#{{ question.slug }})
|
||||
{%- endfor %}
|
||||
|
||||
|
||||
{%- for question in questions %}
|
||||
|
||||
<a name="{{ question.slug }}"></a>
|
||||
## {{ question.title }}
|
||||
|
||||
{{ question.body }}
|
||||
|
||||
{%- endfor %}
|
||||
|
||||
<hr>
|
||||
|
||||
Generated by [FAQtory](https://github.com/willmcgugan/faqtory)
|
||||
20
.faq/suggest.md
Normal file
@@ -0,0 +1,20 @@
|
||||
{%- if questions -%}
|
||||
{% if questions|length == 1 %}
|
||||
We found the following entry in the [FAQ]({{ faq_url }}) which you may find helpful:
|
||||
{%- else %}
|
||||
We found the following entries in the [FAQ]({{ faq_url }}) which you may find helpful:
|
||||
{%- endif %}
|
||||
|
||||
{% for question in questions %}
|
||||
- [{{ question.title }}]({{ faq_url }}#{{ question.slug }})
|
||||
{%- endfor %}
|
||||
|
||||
Feel free to close this issue if you found an answer in the FAQ. Otherwise, please give us a little time to review.
|
||||
|
||||
{%- else -%}
|
||||
Thank you for your issue. Give us a little time to review it.
|
||||
|
||||
PS. You might want to check the [FAQ]({{ faq_url }}) if you haven't done so already.
|
||||
{%- endif %}
|
||||
|
||||
This is an automated reply, generated by [FAQtory](https://github.com/willmcgugan/faqtory)
|
||||
8
.github/ISSUE_TEMPLATE/bug_report.md
vendored
@@ -7,8 +7,12 @@ assignees: ''
|
||||
|
||||
---
|
||||
|
||||
Please give a brief but clear explanation of what the issue is. Let us know what the behaviour you expect is, and what is actually happening. Let us know what operating system you are running on, and what terminal you are using.
|
||||
Have you checked closed issues? https://github.com/Textualize/textual/issues?q=is%3Aissue+is%3Aclosed
|
||||
|
||||
Please give a brief but clear explanation of the issue.
|
||||
|
||||
What Operating System are you running on?
|
||||
|
||||
Feel free to add screenshots and/or videos. These can be very helpful!
|
||||
|
||||
If you can, include a complete working example that demonstrates the bug. Please check it can run without modifications.
|
||||
If you can, include a complete working example that demonstrates the bug. Check it can run without modifications.
|
||||
|
||||
29
.github/workflows/new_issue.yml
vendored
Normal file
@@ -0,0 +1,29 @@
|
||||
name: issues
|
||||
on:
|
||||
issues:
|
||||
types: [opened]
|
||||
jobs:
|
||||
add-comment:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
issues: write
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
with:
|
||||
ref: main
|
||||
- name: Install FAQtory
|
||||
run: pip install FAQtory
|
||||
- name: Run Suggest
|
||||
env:
|
||||
TITLE: ${{ github.event.issue.title }}
|
||||
run: faqtory suggest "$TITLE" > suggest.md
|
||||
- name: Read suggest.md
|
||||
id: suggest
|
||||
uses: juliangruber/read-file-action@v1
|
||||
with:
|
||||
path: ./suggest.md
|
||||
- name: Suggest FAQ
|
||||
uses: peter-evans/create-or-update-comment@a35cf36e5301d70b76f316e867e7788a55a31dae
|
||||
with:
|
||||
issue-number: ${{ github.event.issue.number }}
|
||||
body: ${{ steps.suggest.outputs.content }}
|
||||
97
CHANGELOG.md
@@ -5,6 +5,95 @@ All notable changes to this project will be documented in this file.
|
||||
The format is based on [Keep a Changelog](http://keepachangelog.com/)
|
||||
and this project adheres to [Semantic Versioning](http://semver.org/).
|
||||
|
||||
## [0.10.0] - Unreleased
|
||||
|
||||
### Added
|
||||
|
||||
- Added public `TreeNode` label access via `TreeNode.label` https://github.com/Textualize/textual/issues/1396
|
||||
|
||||
### Changed
|
||||
|
||||
- `MouseScrollUp` and `MouseScrollDown` now inherit from `MouseEvent` and have attached modifier keys. https://github.com/Textualize/textual/pull/1458
|
||||
|
||||
### Fixed
|
||||
|
||||
- The styles `scrollbar-background-active` and `scrollbar-color-hover` are no longer ignored https://github.com/Textualize/textual/pull/1480
|
||||
|
||||
## [0.9.1] - 2022-12-30
|
||||
|
||||
### Added
|
||||
|
||||
- Added textual._win_sleep for Python on Windows < 3.11 https://github.com/Textualize/textual/pull/1457
|
||||
|
||||
## [0.9.0] - 2022-12-30
|
||||
|
||||
### Added
|
||||
|
||||
- Added textual.strip.Strip primitive
|
||||
- Added textual._cache.FIFOCache
|
||||
- Added an option to clear columns in DataTable.clear() https://github.com/Textualize/textual/pull/1427
|
||||
|
||||
### Changed
|
||||
|
||||
- Widget.render_line now returns a Strip
|
||||
- Fix for slow updates on Windows
|
||||
- Bumped Rich dependency
|
||||
|
||||
## [0.8.2] - 2022-12-28
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed issue with TextLog.clear() https://github.com/Textualize/textual/issues/1447
|
||||
|
||||
## [0.8.1] - 2022-12-25
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fix for overflowing tree issue https://github.com/Textualize/textual/issues/1425
|
||||
|
||||
## [0.8.0] - 2022-12-22
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed issues with nested auto dimensions https://github.com/Textualize/textual/issues/1402
|
||||
- Fixed watch method incorrectly running on first set when value hasn't changed and init=False https://github.com/Textualize/textual/pull/1367
|
||||
- `App.dark` can now be set from `App.on_load` without an error being raised https://github.com/Textualize/textual/issues/1369
|
||||
- Fixed setting `visibility` changes needing a `refresh` https://github.com/Textualize/textual/issues/1355
|
||||
|
||||
### Added
|
||||
|
||||
- Added `textual.actions.SkipAction` exception which can be raised from an action to allow parents to process bindings.
|
||||
- Added `textual keys` preview.
|
||||
- Added ability to bind to a character in addition to key name. i.e. you can bind to "." or "full_stop".
|
||||
- Added TextLog.shrink attribute to allow renderable to reduce in size to fit width.
|
||||
|
||||
### Changed
|
||||
|
||||
- Deprecated `PRIORITY_BINDINGS` class variable.
|
||||
- Renamed `char` to `character` on Key event.
|
||||
- Renamed `key_name` to `name` on Key event.
|
||||
- Queries/`walk_children` no longer includes self in results by default https://github.com/Textualize/textual/pull/1416
|
||||
|
||||
## [0.7.0] - 2022-12-17
|
||||
|
||||
### Added
|
||||
|
||||
- Added `PRIORITY_BINDINGS` class variable, which can be used to control if a widget's bindings have priority by default. https://github.com/Textualize/textual/issues/1343
|
||||
|
||||
### Changed
|
||||
|
||||
- Renamed the `Binding` argument `universal` to `priority`. https://github.com/Textualize/textual/issues/1343
|
||||
- When looking for bindings that have priority, they are now looked from `App` downwards. https://github.com/Textualize/textual/issues/1343
|
||||
- `BINDINGS` on an `App`-derived class have priority by default. https://github.com/Textualize/textual/issues/1343
|
||||
- `BINDINGS` on a `Screen`-derived class have priority by default. https://github.com/Textualize/textual/issues/1343
|
||||
- Added a message parameter to Widget.exit
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed validator not running on first reactive set https://github.com/Textualize/textual/pull/1359
|
||||
- Ensure only printable characters are used as key_display https://github.com/Textualize/textual/pull/1361
|
||||
|
||||
|
||||
## [0.6.0] - 2022-12-11
|
||||
|
||||
### Added
|
||||
@@ -247,7 +336,13 @@ https://textual.textualize.io/blog/2022/11/08/version-040/#version-040
|
||||
- New handler system for messages that doesn't require inheritance
|
||||
- Improved traceback handling
|
||||
|
||||
[0.6.0]: https://github.com/Textualize/textual/compare/v0.3.0...v0.6.0
|
||||
[0.9.1]: https://github.com/Textualize/textual/compare/v0.9.0...v0.9.1
|
||||
[0.9.0]: https://github.com/Textualize/textual/compare/v0.8.2...v0.9.0
|
||||
[0.8.2]: https://github.com/Textualize/textual/compare/v0.8.1...v0.8.2
|
||||
[0.8.1]: https://github.com/Textualize/textual/compare/v0.8.0...v0.8.1
|
||||
[0.8.0]: https://github.com/Textualize/textual/compare/v0.7.0...v0.8.0
|
||||
[0.7.0]: https://github.com/Textualize/textual/compare/v0.6.0...v0.7.0
|
||||
[0.6.0]: https://github.com/Textualize/textual/compare/v0.5.0...v0.6.0
|
||||
[0.5.0]: https://github.com/Textualize/textual/compare/v0.4.0...v0.5.0
|
||||
[0.4.0]: https://github.com/Textualize/textual/compare/v0.3.0...v0.4.0
|
||||
[0.3.0]: https://github.com/Textualize/textual/compare/v0.2.1...v0.3.0
|
||||
|
||||
92
FAQ.md
Normal file
@@ -0,0 +1,92 @@
|
||||
|
||||
# Frequently Asked Questions
|
||||
- [Does Textual support images?](#does-textual-support-images)
|
||||
- [How can I fix ImportError cannot import name ComposeResult from textual.app ?](#how-can-i-fix-importerror-cannot-import-name-composeresult-from-textualapp-)
|
||||
- [How do I center a widget in a screen?](#how-do-i-center-a-widget-in-a-screen)
|
||||
- [How do I pass arguments to an app?](#how-do-i-pass-arguments-to-an-app)
|
||||
|
||||
<a name="does-textual-support-images"></a>
|
||||
## Does Textual support images?
|
||||
|
||||
Textual doesn't have built in support for images yet, but it is on the [Roadmap](https://textual.textualize.io/roadmap/).
|
||||
|
||||
See also the [rich-pixels](https://github.com/darrenburns/rich-pixels) project for a Rich renderable for images that works with Textual.
|
||||
|
||||
<a name="how-can-i-fix-importerror-cannot-import-name-composeresult-from-textualapp-"></a>
|
||||
## How can I fix ImportError cannot import name ComposeResult from textual.app ?
|
||||
|
||||
You likely have an older version of Textual. You can install the latest version by adding the `-U` switch which will force pip to upgrade.
|
||||
|
||||
The following should do it:
|
||||
|
||||
```
|
||||
pip install "textual[dev]" -U
|
||||
```
|
||||
|
||||
<a name="how-do-i-center-a-widget-in-a-screen"></a>
|
||||
## How do I center a widget in a screen?
|
||||
|
||||
To center a widget within a container use
|
||||
[`align`](https://textual.textualize.io/styles/align/). But remember that
|
||||
`align` works on the *children* of a container, it isn't something you use
|
||||
on the child you want centered.
|
||||
|
||||
For example, here's an app that shows a `Button` in the middle of a
|
||||
`Screen`:
|
||||
|
||||
```python
|
||||
from textual.app import App, ComposeResult
|
||||
from textual.widgets import Button
|
||||
|
||||
class ButtonApp(App):
|
||||
|
||||
CSS = """
|
||||
Screen {
|
||||
align: center middle;
|
||||
}
|
||||
"""
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
yield Button("PUSH ME!")
|
||||
|
||||
if __name__ == "__main__":
|
||||
ButtonApp().run()
|
||||
```
|
||||
|
||||
<a name="how-do-i-pass-arguments-to-an-app"></a>
|
||||
## How do I pass arguments to an app?
|
||||
|
||||
When creating your `App` class, override `__init__` as you would when
|
||||
inheriting normally. For example:
|
||||
|
||||
```python
|
||||
from textual.app import App, ComposeResult
|
||||
from textual.widgets import Static
|
||||
|
||||
class Greetings(App[None]):
|
||||
|
||||
def __init__(self, greeting: str="Hello", to_greet: str="World") -> None:
|
||||
self.greeting = greeting
|
||||
self.to_greet = to_greet
|
||||
super().__init__()
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
yield Static(f"{self.greeting}, {self.to_greet}")
|
||||
```
|
||||
|
||||
Then the app can be run, passing in various arguments; for example:
|
||||
|
||||
```python
|
||||
# Running with default arguments.
|
||||
Greetings().run()
|
||||
|
||||
# Running with a keyword arguyment.
|
||||
Greetings(to_greet="davep").run()
|
||||
|
||||
# Running with both positional arguments.
|
||||
Greetings("Well hello", "there").run()
|
||||
```
|
||||
|
||||
<hr>
|
||||
|
||||
Generated by [FAQtory](https://github.com/willmcgugan/faqtory)
|
||||
|
After Width: | Height: | Size: 50 KiB |
BIN
docs/blog/images/darren-year-in-review/Untitled 1.png
Normal file
|
After Width: | Height: | Size: 615 KiB |
BIN
docs/blog/images/darren-year-in-review/Untitled 2.png
Normal file
|
After Width: | Height: | Size: 1.4 MiB |
BIN
docs/blog/images/darren-year-in-review/Untitled 3.png
Normal file
|
After Width: | Height: | Size: 854 KiB |
BIN
docs/blog/images/darren-year-in-review/Untitled 4.png
Normal file
|
After Width: | Height: | Size: 33 KiB |
BIN
docs/blog/images/darren-year-in-review/Untitled 5.png
Normal file
|
After Width: | Height: | Size: 87 KiB |
BIN
docs/blog/images/darren-year-in-review/Untitled.png
Normal file
|
After Width: | Height: | Size: 1.3 MiB |
186
docs/blog/images/darren-year-in-review/bulbasaur.svg
Normal file
|
After Width: | Height: | Size: 91 KiB |
BIN
docs/blog/images/darren-year-in-review/devtools.png
Normal file
|
After Width: | Height: | Size: 1.1 MiB |
BIN
docs/blog/images/darren-year-in-review/filemanager-trimmed.gif
Normal file
|
After Width: | Height: | Size: 1.5 MiB |
BIN
docs/blog/images/darren-year-in-review/floating-gutter.gif
Normal file
|
After Width: | Height: | Size: 1.9 MiB |
BIN
docs/blog/images/darren-year-in-review/pokedex-terminal.mov
Normal file
BIN
docs/blog/images/darren-year-in-review/shira-demo.gif
Normal file
|
After Width: | Height: | Size: 1.3 MiB |
55
docs/blog/posts/better-sleep-on-windows.md
Normal file
@@ -0,0 +1,55 @@
|
||||
---
|
||||
draft: false
|
||||
date: 2022-12-30
|
||||
categories:
|
||||
- DevLog
|
||||
authors:
|
||||
- willmcgugan
|
||||
---
|
||||
# A better asyncio sleep for Windows to fix animation
|
||||
|
||||
I spent some time optimizing Textual on Windows recently, and discovered something which may be of interest to anyone working with async code on that platform.
|
||||
|
||||
<!-- more -->
|
||||
|
||||
Animation, scrolling, and fading had always been unsatisfactory on Windows. Textual was usable, but the lag when scrolling made apps feel far less snappy that other platforms. On macOS and Linux, scrolling is fast enough that it feels close to a native app, not something running in a terminal. Yet the Windows experience never improved, even as Textual got faster with each release.
|
||||
|
||||
I had chalked this up to Windows Terminal being slow to render updates. After all, the classic Windows terminal was (and still is) glacially slow. Perhaps Microsoft just weren't focusing on performance.
|
||||
|
||||
In retrospect, that was highly improbable. Like all modern terminals, Windows Terminal uses the GPU to render updates. Even without focussing on performance, it should be fast.
|
||||
|
||||
I figured I'd give it one last attempt to speed up Textual on Windows. If I failed, Windows would forever be a third-class platform for Textual apps.
|
||||
|
||||
It turned out that it was nothing to do with performance, per se. The issue was with a single asyncio function: `asyncio.sleep`.
|
||||
|
||||
Textual has a `Timer` class which creates events at regular intervals. It powers the JS-like `set_interval` and `set_timer` functions. It is also used internally to do animation (such as smooth scrolling). This Timer class calls `asyncio.sleep` to wait the time between one event and the next.
|
||||
|
||||
On macOS and Linux, calling `asynco.sleep` is fairly accurate. If you call `sleep(3.14)`, it will return within 1% of 3.14 seconds. This is not the case for Windows, which for historical reasons uses a timer with a granularity of 15 milliseconds. The upshot is that sleep times will be rounded up to the nearest multiple of 15 milliseconds.
|
||||
|
||||
This limit appears to hold true for all async primitives on Windows. If you wait for something with a timeout, it will return on a multiple of 15 milliseconds. Fortunately there is work in the CPython pipeline to make this more accurate. Thanks to [Steve Dower](https://twitter.com/zooba) for pointing this out.
|
||||
|
||||
This lack of accuracy in the timer meant that timer events were created at a far slower rate than intended. Animation was slower because Textual was waiting too long between updates.
|
||||
|
||||
Once I had figured that out, I needed an alternative to `asyncio.sleep` for Textual's Timer class. And I found one. The following version of `sleep` is accurate to well within 1%:
|
||||
|
||||
```python
|
||||
from time import sleep as time_sleep
|
||||
from asyncio import get_running_loop
|
||||
|
||||
async def sleep(sleep_for: float) -> None:
|
||||
"""An asyncio sleep.
|
||||
|
||||
On Windows this achieves a better granularity than asyncio.sleep
|
||||
|
||||
Args:
|
||||
sleep_for (float): Seconds to sleep for.
|
||||
"""
|
||||
await get_running_loop().run_in_executor(None, time_sleep, sleep_for)
|
||||
|
||||
```
|
||||
|
||||
That is a drop-in replacement for sleep on Windows. With it, Textual runs a *lot* smoother. Easily on par with macOS and Linux.
|
||||
|
||||
It's not quite perfect. There is a little *tearing* during full "screen" updates, but performance is decent all round. I suspect when [this bug]( https://bugs.python.org/issue37871) is fixed (big thanks to [Paul Moore](https://twitter.com/pf_moore) for looking in to that), and Microsoft implements [this protocol](https://gist.github.com/christianparpart/d8a62cc1ab659194337d73e399004036) then Textual on Windows will be A+.
|
||||
|
||||
This Windows improvement will be in v0.9.0 of [Textual](https://github.com/Textualize/textual), which will be released in a few days.
|
||||
171
docs/blog/posts/darren-year-in-review.md
Normal file
@@ -0,0 +1,171 @@
|
||||
---
|
||||
draft: false
|
||||
date: 2022-12-20
|
||||
categories:
|
||||
- DevLog
|
||||
authors:
|
||||
- darrenburns
|
||||
---
|
||||
# A year of building for the terminal
|
||||
|
||||
I joined Textualize back in January 2022, and since then have been hard at work with the team on both [Rich](https://github.com/Textualize/rich) and [Textual](https://github.com/Textualize/textual).
|
||||
Over the course of the year, I’ve been able to work on a lot of really cool things.
|
||||
In this post, I’ll review a subset of the more interesting and visual stuff I’ve built. If you’re into terminals and command line tooling, you’ll hopefully see at least one thing of interest!
|
||||
|
||||
<!-- more -->
|
||||
|
||||
## A file manager powered by Textual
|
||||
|
||||
I’ve been slowly developing a file manager as a “dogfooding” project for Textual. It takes inspiration from tools such as Ranger and Midnight Commander.
|
||||
|
||||

|
||||
|
||||
As of December 2022, it lets you browse your file system, filtering, multi-selection, creating and deleting files/directories, opening files in your `$EDITOR` and more.
|
||||
|
||||
I’m happy with how far this project has come — I think it’s a good example of the type of powerful application that can be built with Textual with relatively little code. I’ve been able to focus on *features*, instead of worrying about terminal emulator implementation details.
|
||||
|
||||

|
||||
|
||||
The project is available [on GitHub](https://github.com/darrenburns/kupo).
|
||||
|
||||
## Better diffs in the terminal
|
||||
|
||||
Diffs in the terminal are often difficult to read at a glance. I wanted to see how close I could get to achieving a diff display of a quality similar to that found in the GitHub UI.
|
||||
|
||||
To attempt this, I built a tool called [Dunk](https://github.com/darrenburns/dunk). It’s a command line program which you can pipe your `git diff` output into, and it’ll convert it into something which I find much more readable.
|
||||
|
||||

|
||||
|
||||
Although I’m not particularly proud of the code - there are a lot of “hacks” going on, but I’m proud of the result. If anything, it shows what can be achieved for tools like this.
|
||||
|
||||
For many diffs, the difference between running `git diff` and `git diff | dunk | less -R` is night and day.
|
||||
|
||||

|
||||
|
||||
It’d be interesting to revisit this at some point.
|
||||
It has its issues, but I’d love to see how it can be used alongside Textual to build a terminal-based diff/merge tool. Perhaps it could be combined with…
|
||||
|
||||
## Code editor floating gutter
|
||||
|
||||
This is a common feature in text editors and IDEs: when you scroll to the right, you should still be able to see what line you’re on. Out of interest, I tried to recreate the effect in the terminal using Textual.
|
||||
|
||||

|
||||
|
||||
Textual CSS offers a `dock` property which allows you to attach a widget to an edge of its parent.
|
||||
By creating a widget that contains a vertical list of numbers and setting the `dock` property to `left`, we can create a floating gutter effect.
|
||||
Then, we just need to keep the `scroll_y` in sync between the gutter and the content to ensure the line numbers stay aligned.
|
||||
|
||||
## Dropdown autocompletion menu
|
||||
|
||||
While working on [Shira](https://github.com/darrenburns/shira) (a proof-of-concept, terminal-based Python object explorer), I wrote some autocompleting dropdown functionality.
|
||||
|
||||

|
||||
|
||||
Textual forgoes the z-index concept from browser CSS and instead uses a “named layer” system. Using the `layers` property you can defined an ordered list of named layers, and using the `layer` property, you can assign a descendant widget to one of those layers.
|
||||
|
||||
By creating a new layer above all others and assigning a widget to that layer, we can ensure that widget is painted above everything else.
|
||||
|
||||
In order to determine where to place the dropdown, we can track the current value in the dropdown by `watch`ing the reactive input “value” inside the Input widget. This method will be called every time the `value` of the Input changes, and we can use this hook to amend the position of our dropdown position to accommodate for the length of the input value.
|
||||
|
||||

|
||||
|
||||
I’ve now extracted this into a separate library called [textual-autocomplete](https://github.com/darrenburns/textual-autocomplete).
|
||||
|
||||
## Tabs with animated underline
|
||||
|
||||
The aim here was to create a tab widget with underlines that animates smoothly as another tab is selected.
|
||||
|
||||
<video style="position: relative; width: 100%;" controls autoplay loop><source src="../../../../images/darren-year-in-review/tabs-textual-video-demo.mp4" type="video/mp4"></video>
|
||||
|
||||
The difficulty with implementing something like this is that we don’t have pixel-perfect resolution when animating - a terminal window is just a big grid of fixed-width character cells.
|
||||
|
||||
{ align=right width=250 }
|
||||
However, when animating things in a terminal, we can often achieve better granularity using Unicode related tricks. In this case, instead of shifting the bar along one whole cell, we adjust the endings of the bar to be a character which takes up half of a cell.
|
||||
|
||||
The exact characters that form the bar are "╺", "━" and "╸". When the bar sits perfectly within cell boundaries, every character is “━”. As it travels over a cell boundary, the left and right ends of the bar are updated to "╺" and "╸" respectively.
|
||||
|
||||
## Snapshot testing for terminal apps
|
||||
|
||||
One of the great features we added to Rich this year was the ability to export console contents to an SVG. This feature was later exposed to Textual, allowing users to capture screenshots of their running Textual apps.
|
||||
Ultimately, I ended up creating a tool for snapshot testing in the Textual codebase.
|
||||
|
||||
Snapshot testing is used to ensure that Textual output doesn’t unexpectedly change. On disk, we store what we expect the output to look like. Then, when we run our unit tests, we get immediately alerted if the output has changed.
|
||||
|
||||
This essentially automates the process of manually spinning up several apps and inspecting them for unexpected visual changes. It’s great for catching subtle regressions!
|
||||
|
||||
In Textual, each CSS property has its own canonical example and an associated snapshot test.
|
||||
If we accidentally break a property in a way that affects the visual output, the chances of it sneaking into a release are greatly reduced, because the corresponding snapshot test will fail.
|
||||
|
||||
As part of this work, I built a web interface for comparing snapshots with test output.
|
||||
There’s even a little toggle which highlights the differences, since they’re sometimes rather subtle.
|
||||
|
||||
<video style="position: relative; width: 100%;" controls autoplay loop><source src="../../../../images/darren-year-in-review/Screen_Recording_2022-12-14_at_14.08.15.mov" type="video/mp4"></video>
|
||||
|
||||
Since the terminal output shown in the video above is just an SVG image, I was able to add the "Show difference" functionality
|
||||
by overlaying the two images and applying a single CSS property: `mix-blend-mode: difference;`.
|
||||
|
||||
The snapshot testing functionality itself is implemented as a pytest plugin, and it builds on top of a snapshot testing framework called [syrupy](https://github.com/tophat/syrupy).
|
||||
|
||||

|
||||
|
||||
It's quite likely that this will eventually be exposed to end-users of Textual.
|
||||
|
||||
## Demonstrating animation
|
||||
|
||||
I built an example app to demonstrate how to animate in Textual and the available easing functions.
|
||||
|
||||
<video style="position: relative; width: 100%;" controls loop><source src="../../../../images/darren-year-in-review/animation-easing-example.mov" type="video/mp4"></video>
|
||||
|
||||
The smoothness here is achieved using tricks similar to those used in the tabs I discussed earlier.
|
||||
In fact, the bar that animates in the video above is the same Rich renderable that is used by Textual's scrollbars.
|
||||
|
||||
You can play with this app by running `textual easing`. Please use animation sparingly.
|
||||
|
||||
## Developer console
|
||||
|
||||
When developing terminal based applications, performing simple debugging using `print` can be difficult, since the terminal is in application mode.
|
||||
|
||||
A project I worked on earlier in the year to improve the situation was the Textual developer console, which you can launch with `textual console`.
|
||||
|
||||
<div>
|
||||
<figure markdown>
|
||||
<img src="../../../../images/darren-year-in-review/devtools.png">
|
||||
<figcaption>On the right, <a href="https://twitter.com/davepdotorg">Dave's</a> 5x5 Textual app. On the left, the Textual console.</figcaption>
|
||||
</figure>
|
||||
</div>
|
||||
|
||||
Then, by running a Textual application with the `--dev` flag, all standard output will be redirected to it.
|
||||
This means you can use the builtin `print` function and still immediately see the output.
|
||||
Textual itself also writes information to this console, giving insight into the messages that are flowing through an application.
|
||||
|
||||
## Pixel art
|
||||
|
||||
Cells in the terminal are roughly two times taller than they are wide. This means, that two horizontally adjacent cells form an approximate square.
|
||||
|
||||
Using this fact, I wrote a simple library based on Rich and PIL which can convert an image file into terminal output.
|
||||
You can find the library, `rich-pixels`, [on GitHub](https://github.com/darrenburns/rich-pixels).
|
||||
|
||||
It’s particularly good for displaying simple pixel art images. The SVG image below is also a good example of the SVG export functionality I touched on earlier.
|
||||
|
||||
<div>
|
||||
--8<-- "docs/blog/images/darren-year-in-review/bulbasaur.svg"
|
||||
</div>
|
||||
|
||||
Since the library generates an object which is renderable using Rich, these can easily be embedded inside Textual applications.
|
||||
|
||||
Here's an example of that in a scrapped "Pokédex" app I threw together:
|
||||
|
||||
<video style="position: relative; width: 100%;" controls autoplay loop><source src="../../../../images/darren-year-in-review/pokedex-terminal.mov" type="video/mp4"></video>
|
||||
|
||||
This is a rather naive approach to the problem... but I did it for fun!
|
||||
|
||||
Other methods for displaying images in the terminal include:
|
||||
|
||||
- A more advanced library like [chafa](https://github.com/hpjansson/chafa), which uses a range of Unicode characters to achieve a more accurate representation of the image.
|
||||
- One of the available terminal image protocols, such as Sixel, Kitty’s Terminal Graphics Protocol, and iTerm Inline Images Protocol.
|
||||
|
||||
<hr>
|
||||
|
||||
That was a whirlwind tour of just some of the projects I tackled in 2022.
|
||||
If you found it interesting, be sure to [follow me on Twitter](https://twitter.com/_darrenburns).
|
||||
I don't post often, but when I do, it's usually about things similar to those I've discussed here.
|
||||
@@ -12,6 +12,7 @@ class FooterApp(App):
|
||||
description="Show help screen",
|
||||
key_display="?",
|
||||
),
|
||||
Binding(key="delete", action="delete", description="Delete the thing"),
|
||||
Binding(key="j", action="down", description="Scroll down", show=False),
|
||||
]
|
||||
|
||||
|
||||
@@ -71,7 +71,7 @@ The Textual repository comes with a number of example apps. To try out the examp
|
||||
```
|
||||
|
||||
|
||||
With the repository cloned, navigate to the `/examples/` directory where you fill find a number of Python files you can run from the command line:
|
||||
With the repository cloned, navigate to the `/examples/` directory where you will find a number of Python files you can run from the command line:
|
||||
|
||||
```bash
|
||||
cd textual/examples/
|
||||
|
||||
@@ -75,7 +75,7 @@ When you click any of the links, Textual runs the `"set_background"` action to c
|
||||
|
||||
## Bindings
|
||||
|
||||
Textual will also run actions bound to keys. The following example adds key [bindings](./input.md#bindings) for the ++r++, ++g++, and ++b++ keys which call the `"set_background"` action.
|
||||
Textual will run actions bound to keys. The following example adds key [bindings](./input.md#bindings) for the ++r++, ++g++, and ++b++ keys which call the `"set_background"` action.
|
||||
|
||||
=== "actions04.py"
|
||||
|
||||
@@ -90,9 +90,11 @@ Textual will also run actions bound to keys. The following example adds key [bin
|
||||
|
||||
If you run this example, you can change the background by pressing keys in addition to clicking links.
|
||||
|
||||
See the previous section on [input](./input.md#bindings) for more information on bindings.
|
||||
|
||||
## Namespaces
|
||||
|
||||
Textual will look for action methods on the widget or app where they are used. If we were to create a [custom widget](./widgets.md#custom-widgets) it can have its own set of actions.
|
||||
Textual will look for action methods in the class where they are defined (App, Screen, or Widget). If we were to create a [custom widget](./widgets.md#custom-widgets) it can have its own set of actions.
|
||||
|
||||
The following example defines a custom widget with its own `set_background` action.
|
||||
|
||||
@@ -124,37 +126,9 @@ In the previous example if you wanted a link to set the background on the app ra
|
||||
|
||||
Textual supports the following builtin actions which are defined on the app.
|
||||
|
||||
|
||||
### Bell
|
||||
|
||||
::: textual.app.App.action_bell
|
||||
options:
|
||||
show_root_heading: false
|
||||
|
||||
### Push screen
|
||||
|
||||
::: textual.app.App.action_push_screen
|
||||
|
||||
|
||||
### Pop screen
|
||||
|
||||
::: textual.app.App.action_pop_screen
|
||||
|
||||
|
||||
### Screenshot
|
||||
|
||||
::: textual.app.App.action_screenshot
|
||||
|
||||
|
||||
### Switch screen
|
||||
|
||||
::: textual.app.App.action_switch_screen
|
||||
|
||||
|
||||
### Toggle_dark
|
||||
|
||||
::: textual.app.App.action_toggle_dark
|
||||
|
||||
### Quit
|
||||
|
||||
::: textual.app.App.action_quit
|
||||
- [action_bell][textual.app.App.action_bell]
|
||||
- [action_push_screen][textual.app.App.action_push_screen]
|
||||
- [action_pop_screen][textual.app.App.action_pop_screen]
|
||||
- [action_switch_screen][textual.app.App.action_switch_screen]
|
||||
- [action_screenshot][textual.app.App.action_screenshot]
|
||||
- [action_toggle_dark][textual.app.App.action_toggle_dark]
|
||||
|
||||
@@ -10,7 +10,7 @@ This chapter will discuss how to make your app respond to input in the form of k
|
||||
|
||||
## Keyboard input
|
||||
|
||||
The most fundamental way to receive input is via [Key](./events/key) events. Let's write an app to show key events as you type.
|
||||
The most fundamental way to receive input is via [Key][textual.events.Key] events which are sent to your app when the user presses a key. Let's write an app to show key events as you type.
|
||||
|
||||
=== "key01.py"
|
||||
|
||||
@@ -23,25 +23,52 @@ The most fundamental way to receive input is via [Key](./events/key) events. Let
|
||||
```{.textual path="docs/examples/guide/input/key01.py", press="T,e,x,t,u,a,l,!,_"}
|
||||
```
|
||||
|
||||
Note the key event handler on the app which logs all key events. If you press any key it will show up on the screen.
|
||||
When you press a key, the app will receive the event and write it to a [TextLog](../widgets/text_log.md) widget. Try pressing a few keys to see what happens.
|
||||
|
||||
### Attributes
|
||||
!!! tip
|
||||
|
||||
There are two main attributes on a key event. The `key` attribute is the _name_ of the key which may be a single character, or a longer identifier. Textual ensures that the `key` attribute could always be used in a method name.
|
||||
For a more feature rich version of this example, run `textual keys` from the command line.
|
||||
|
||||
Key events also contain a `char` attribute which contains a single character if it is printable, or ``None`` if it is not printable (like a function key which has no corresponding character).
|
||||
### Key Event
|
||||
|
||||
To illustrate the difference between `key` and `char`, try `key01.py` with the space key. You should see something like the following:
|
||||
The key event contains the following attributes which your app can use to know how to respond.
|
||||
|
||||
```{.textual path="docs/examples/guide/input/key01.py", press="space,_"}
|
||||
#### key
|
||||
|
||||
```
|
||||
The `key` attribute is a string which identifies the key that was pressed. The value of `key` will be a single character for letters and numbers, or a longer identifier for other keys.
|
||||
|
||||
Some keys may be combined with the ++shift++ key. In the case of letters, this will result in a capital letter as you might expect. For non-printable keys, the `key` attribute will be prefixed with `shift+`. For example, ++shift+home++ will produce an event with `key="shift+home"`.
|
||||
|
||||
Many keys can also be combined with ++ctrl++ which will prefix the key with `ctrl+`. For instance, ++ctrl+p++ will produce an event with `key="ctrl+p"`.
|
||||
|
||||
!!! warning
|
||||
|
||||
Not all keys combinations are supported in terminals and some keys may be intercepted by your OS. If in doubt, run `textual keys` from the command line.
|
||||
|
||||
#### character
|
||||
|
||||
If the key has an associated printable character, then `character` will contain a string with a single Unicode character. If there is no printable character for the key (such as for function keys) then `character` will be `None`.
|
||||
|
||||
For example the ++p++ key will produce `character="p"` but ++f2++ will produce `character=None`.
|
||||
|
||||
#### name
|
||||
|
||||
The `name` attribute is similar to `key` but, unlike `key`, is guaranteed to be valid within a Python function name. Textual derives `name` from the `key` attribute by lower casing it and replacing `+` with `_`. Upper case letters are prefixed with `upper_` to distinguish them from lower case names.
|
||||
|
||||
For example, ++ctrl+p++ produces `name="ctrl_p"` and ++shift+p++ produces `name="upper_p"`.
|
||||
|
||||
#### is_printable
|
||||
|
||||
The `is_printable` attribute is a boolean which indicates if the key would typically result in something that could be used in an input widget. If `is_printable` is `False` then the key is a control code or function key that you wouldn't expect to produce anything in an input.
|
||||
|
||||
#### aliases
|
||||
|
||||
Some keys or combinations of keys can produce the same event. For instance, the ++tab++ key is indistinguishable from ++ctrl+i++ in the terminal. For such keys, Textual events will contain a list of the possible keys that may have produced this event. In the case of ++tab++, the `aliases` attribute will contain `["tab", "ctrl+i"]`
|
||||
|
||||
Note that the `key` attribute contains the word "space" while the `char` attribute contains a literal space.
|
||||
|
||||
### Key methods
|
||||
|
||||
Textual offers a convenient way of handling specific keys. If you create a method beginning with `key_` followed by the name of a key, then that method will be called in response to the key.
|
||||
Textual offers a convenient way of handling specific keys. If you create a method beginning with `key_` followed by the key name (the event's `name` attribute), then that method will be called in response to the key press.
|
||||
|
||||
Let's add a key method to the example code.
|
||||
|
||||
@@ -131,11 +158,24 @@ Note how the footer displays bindings and makes them clickable.
|
||||
|
||||
The tuple of three strings may be enough for simple bindings, but you can also replace the tuple with a [Binding][textual.binding.Binding] instance which exposes a few more options.
|
||||
|
||||
### Why use bindings?
|
||||
### Priority bindings
|
||||
|
||||
Bindings are particularly useful for configurable hot-keys. Bindings can also be inspected in widgets such as [Footer](../widgets/footer.md).
|
||||
Individual bindings may be marked as a *priority*, which means they will be checked prior to the bindings of the focused widget. This feature is often used to create hot-keys on the app or screen. Such bindings can not be disabled by binding the same key on a widget.
|
||||
|
||||
You can create priority key bindings by setting `priority=True` on the Binding object. Textual uses this feature to add a default binding for ++ctrl+c++ so there is always a way to exit the app. Here's the bindings from the App base class. Note the first binding is set as a priority:
|
||||
|
||||
```python
|
||||
BINDINGS = [
|
||||
Binding("ctrl+c", "quit", "Quit", show=False, priority=True),
|
||||
Binding("tab", "focus_next", "Focus Next", show=False),
|
||||
Binding("shift+tab", "focus_previous", "Focus Previous", show=False),
|
||||
]
|
||||
```
|
||||
|
||||
### Show bindings
|
||||
|
||||
The [footer](../widgets/footer.md) widget can inspect bindings to display available keys. If you don't want a binding to display in the footer you can set `show=False`. The default bindings on App do this so that the standard ++ctrl+c++, ++tab++ and ++shift+tab++ bindings don't typically appear in the footer.
|
||||
|
||||
In a future version of Textual it will also be possible to specify bindings in a configuration file, which will allow users to override app bindings.
|
||||
|
||||
## Mouse Input
|
||||
|
||||
|
||||
@@ -167,9 +167,9 @@ If you click the buttons in the above example it will show the current count. Wh
|
||||
|
||||
Watch methods are another superpower.
|
||||
Textual will call watch methods when reactive attributes are modified.
|
||||
Watch methods begin with `watch_` followed by the name of the attribute.
|
||||
If the watch method accepts a positional argument, it will be called with the new assigned value.
|
||||
If the watch method accepts *two* positional arguments, it will be called with both the *old* value and the *new* value.
|
||||
Watch method names begin with `watch_` followed by the name of the attribute, and should accept one or two arguments.
|
||||
If the method accepts a single argument, it will be called with the new assigned value.
|
||||
If the method accepts *two* positional arguments, it will be called with both the *old* value and the *new* value.
|
||||
|
||||
The following app will display any color you type in to the input. Try it with a valid color in Textual CSS. For example `"darkorchid"` or `"#52de44"`.
|
||||
|
||||
|
||||
@@ -95,7 +95,7 @@ If you run this code, you should see something like the following:
|
||||
|
||||
Hit the ++d++ key to toggle between light and dark mode.
|
||||
|
||||
```{.textual path="docs/examples/tutorial/stopwatch01.py" press="d" title="TimerApp + dark"}
|
||||
```{.textual path="docs/examples/tutorial/stopwatch01.py" press="d" title="StopwatchApp + dark"}
|
||||
```
|
||||
|
||||
Hit ++ctrl+c++ to exit the app and return to the command prompt.
|
||||
@@ -365,7 +365,7 @@ The first argument to `reactive` may be a default value or a callable that retur
|
||||
The `time` attribute has a simple float as the default value, so `self.time` will be `0` on start.
|
||||
|
||||
|
||||
The `on_mount` method is an event handler called then the widget is first added to the application (or _mounted_). In this method we call [set_interval()][textual.message_pump.MessagePump.set_interval] to create a timer which calls `self.update_time` sixty times a second. This `update_time` method calculates the time elapsed since the widget started and assigns it to `self.time`. Which brings us to one of Reactive's super-powers.
|
||||
The `on_mount` method is an event handler called when the widget is first added to the application (or _mounted_). In this method we call [set_interval()][textual.message_pump.MessagePump.set_interval] to create a timer which calls `self.update_time` sixty times a second. This `update_time` method calculates the time elapsed since the widget started and assigns it to `self.time`. Which brings us to one of Reactive's super-powers.
|
||||
|
||||
If you implement a method that begins with `watch_` followed by the name of a reactive attribute (making it a _watch method_), that method will be called when the attribute is modified.
|
||||
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
|
||||
`ListItem` is the type of the elements in a `ListView`.
|
||||
|
||||
- [] Focusable
|
||||
- [] Container
|
||||
- [ ] Focusable
|
||||
- [ ] Container
|
||||
|
||||
## Example
|
||||
|
||||
|
||||
@@ -41,4 +41,4 @@ This widget sends no messages.
|
||||
|
||||
## See Also
|
||||
|
||||
* [TextLog](../api/textlog.md) code reference
|
||||
* [TextLog](../api/text_log.md) code reference
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
from rich.text import Text
|
||||
|
||||
@@ -64,7 +65,8 @@ class TreeApp(App):
|
||||
|
||||
def on_mount(self) -> None:
|
||||
"""Load some JSON when the app starts."""
|
||||
with open("food.json") as data_file:
|
||||
file_path = Path(__file__).parent / "food.json"
|
||||
with open(file_path) as data_file:
|
||||
self.json_data = json.load(data_file)
|
||||
|
||||
def action_add(self) -> None:
|
||||
|
||||
7
faq.yml
Normal file
@@ -0,0 +1,7 @@
|
||||
# FAQtory settings
|
||||
|
||||
faq_url: "https://github.com/textualize/textual/blob/main/FAQ.md" # Replace this with the URL to your FAQ.md!
|
||||
|
||||
questions_path: "./questions" # Where questions should be stored
|
||||
output_path: "./FAQ.md" # Where FAQ.md should be generated
|
||||
templates_path: ".faq" # Path to templates
|
||||
@@ -148,7 +148,6 @@ nav:
|
||||
- "api/color.md"
|
||||
- "api/containers.md"
|
||||
- "api/data_table.md"
|
||||
- "api/text_log.md"
|
||||
- "api/directory_tree.md"
|
||||
- "api/dom_node.md"
|
||||
- "api/events.md"
|
||||
@@ -166,6 +165,7 @@ nav:
|
||||
- "api/reactive.md"
|
||||
- "api/screen.md"
|
||||
- "api/static.md"
|
||||
- "api/text_log.md"
|
||||
- "api/timer.md"
|
||||
- "api/walk.md"
|
||||
- "api/widget.md"
|
||||
|
||||
541
poetry.lock
generated
@@ -70,17 +70,18 @@ python-versions = ">=3.5"
|
||||
|
||||
[[package]]
|
||||
name = "attrs"
|
||||
version = "22.1.0"
|
||||
version = "22.2.0"
|
||||
description = "Classes Without Boilerplate"
|
||||
category = "main"
|
||||
optional = false
|
||||
python-versions = ">=3.5"
|
||||
python-versions = ">=3.6"
|
||||
|
||||
[package.extras]
|
||||
dev = ["cloudpickle", "coverage[toml] (>=5.0.2)", "furo", "hypothesis", "mypy (>=0.900,!=0.940)", "pre-commit", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins", "sphinx", "sphinx-notfound-page", "zope.interface"]
|
||||
docs = ["furo", "sphinx", "sphinx-notfound-page", "zope.interface"]
|
||||
tests = ["cloudpickle", "coverage[toml] (>=5.0.2)", "hypothesis", "mypy (>=0.900,!=0.940)", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins", "zope.interface"]
|
||||
tests-no-zope = ["cloudpickle", "coverage[toml] (>=5.0.2)", "hypothesis", "mypy (>=0.900,!=0.940)", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins"]
|
||||
cov = ["attrs[tests]", "coverage-enable-subprocess", "coverage[toml] (>=5.3)"]
|
||||
dev = ["attrs[docs,tests]"]
|
||||
docs = ["furo", "myst-parser", "sphinx", "sphinx-notfound-page", "sphinxcontrib-towncrier", "towncrier", "zope.interface"]
|
||||
tests = ["attrs[tests-no-zope]", "zope.interface"]
|
||||
tests-no-zope = ["cloudpickle", "cloudpickle", "hypothesis", "hypothesis", "mypy (>=0.971,<0.990)", "mypy (>=0.971,<0.990)", "pympler", "pympler", "pytest (>=4.3.0)", "pytest (>=4.3.0)", "pytest-mypy-plugins", "pytest-mypy-plugins", "pytest-xdist[psutil]", "pytest-xdist[psutil]"]
|
||||
|
||||
[[package]]
|
||||
name = "black"
|
||||
@@ -181,7 +182,7 @@ test = ["flake8 (==3.7.8)", "hypothesis (==3.55.3)"]
|
||||
|
||||
[[package]]
|
||||
name = "coverage"
|
||||
version = "6.5.0"
|
||||
version = "7.0.1"
|
||||
description = "Code coverage measurement for Python"
|
||||
category = "dev"
|
||||
optional = false
|
||||
@@ -200,7 +201,7 @@ python-versions = "*"
|
||||
|
||||
[[package]]
|
||||
name = "exceptiongroup"
|
||||
version = "1.0.4"
|
||||
version = "1.1.0"
|
||||
description = "Backport of PEP 654 (exception groups)"
|
||||
category = "dev"
|
||||
optional = false
|
||||
@@ -211,15 +212,15 @@ test = ["pytest (>=6)"]
|
||||
|
||||
[[package]]
|
||||
name = "filelock"
|
||||
version = "3.8.2"
|
||||
version = "3.9.0"
|
||||
description = "A platform independent file lock."
|
||||
category = "dev"
|
||||
optional = false
|
||||
python-versions = ">=3.7"
|
||||
|
||||
[package.extras]
|
||||
docs = ["furo (>=2022.9.29)", "sphinx (>=5.3)", "sphinx-autodoc-typehints (>=1.19.5)"]
|
||||
testing = ["covdefaults (>=2.2.2)", "coverage (>=6.5)", "pytest (>=7.2)", "pytest-cov (>=4)", "pytest-timeout (>=2.1)"]
|
||||
docs = ["furo (>=2022.12.7)", "sphinx (>=5.3)", "sphinx-autodoc-typehints (>=1.19.5)"]
|
||||
testing = ["covdefaults (>=2.2.2)", "coverage (>=7.0.1)", "pytest (>=7.2)", "pytest-cov (>=4)", "pytest-timeout (>=2.1)"]
|
||||
|
||||
[[package]]
|
||||
name = "frozenlist"
|
||||
@@ -256,7 +257,7 @@ smmap = ">=3.0.1,<6"
|
||||
|
||||
[[package]]
|
||||
name = "gitpython"
|
||||
version = "3.1.29"
|
||||
version = "3.1.30"
|
||||
description = "GitPython is a python library used to interact with Git repositories"
|
||||
category = "dev"
|
||||
optional = false
|
||||
@@ -268,7 +269,7 @@ typing-extensions = {version = ">=3.7.4.3", markers = "python_version < \"3.8\""
|
||||
|
||||
[[package]]
|
||||
name = "griffe"
|
||||
version = "0.24.1"
|
||||
version = "0.25.2"
|
||||
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
|
||||
@@ -294,7 +295,7 @@ typing-extensions = {version = "*", markers = "python_version < \"3.8\""}
|
||||
|
||||
[[package]]
|
||||
name = "httpcore"
|
||||
version = "0.16.2"
|
||||
version = "0.16.3"
|
||||
description = "A minimal low-level HTTP client."
|
||||
category = "dev"
|
||||
optional = false
|
||||
@@ -312,7 +313,7 @@ socks = ["socksio (>=1.0.0,<2.0.0)"]
|
||||
|
||||
[[package]]
|
||||
name = "httpx"
|
||||
version = "0.23.1"
|
||||
version = "0.23.2"
|
||||
description = "The next generation HTTP client."
|
||||
category = "dev"
|
||||
optional = false
|
||||
@@ -332,7 +333,7 @@ socks = ["socksio (>=1.0.0,<2.0.0)"]
|
||||
|
||||
[[package]]
|
||||
name = "identify"
|
||||
version = "2.5.9"
|
||||
version = "2.5.11"
|
||||
description = "File identification library for Python"
|
||||
category = "dev"
|
||||
optional = false
|
||||
@@ -501,7 +502,7 @@ doc = ["mkdocs-bootswatch (>=1,<2)", "mkdocs-minify-plugin (>=0.5.0,<0.6.0)", "p
|
||||
|
||||
[[package]]
|
||||
name = "mkdocstrings"
|
||||
version = "0.19.0"
|
||||
version = "0.19.1"
|
||||
description = "Automatic documentation from sources, for MkDocs."
|
||||
category = "dev"
|
||||
optional = false
|
||||
@@ -543,7 +544,7 @@ python-versions = "*"
|
||||
|
||||
[[package]]
|
||||
name = "multidict"
|
||||
version = "6.0.3"
|
||||
version = "6.0.4"
|
||||
description = "multidict implementation"
|
||||
category = "main"
|
||||
optional = false
|
||||
@@ -606,7 +607,7 @@ python-versions = ">=3.7"
|
||||
|
||||
[[package]]
|
||||
name = "pathspec"
|
||||
version = "0.10.2"
|
||||
version = "0.10.3"
|
||||
description = "Utility library for gitignore style pattern matching of file paths."
|
||||
category = "dev"
|
||||
optional = false
|
||||
@@ -614,15 +615,18 @@ python-versions = ">=3.7"
|
||||
|
||||
[[package]]
|
||||
name = "platformdirs"
|
||||
version = "2.6.0"
|
||||
version = "2.6.2"
|
||||
description = "A small Python package for determining appropriate platform-specific dirs, e.g. a \"user data dir\"."
|
||||
category = "dev"
|
||||
optional = false
|
||||
python-versions = ">=3.7"
|
||||
|
||||
[package.dependencies]
|
||||
typing-extensions = {version = ">=4.4", markers = "python_version < \"3.8\""}
|
||||
|
||||
[package.extras]
|
||||
docs = ["furo (>=2022.9.29)", "proselint (>=0.13)", "sphinx (>=5.3)", "sphinx-autodoc-typehints (>=1.19.4)"]
|
||||
test = ["appdirs (==1.4.4)", "pytest (>=7.2)", "pytest-cov (>=4)", "pytest-mock (>=3.10)"]
|
||||
docs = ["furo (>=2022.12.7)", "proselint (>=0.13)", "sphinx (>=5.3)", "sphinx-autodoc-typehints (>=1.19.5)"]
|
||||
test = ["appdirs (==1.4.4)", "covdefaults (>=2.2.2)", "pytest (>=7.2)", "pytest-cov (>=4)", "pytest-mock (>=3.10)"]
|
||||
|
||||
[[package]]
|
||||
name = "pluggy"
|
||||
@@ -641,7 +645,7 @@ testing = ["pytest", "pytest-benchmark"]
|
||||
|
||||
[[package]]
|
||||
name = "pre-commit"
|
||||
version = "2.20.0"
|
||||
version = "2.21.0"
|
||||
description = "A framework for managing and maintaining multi-language pre-commit hooks."
|
||||
category = "dev"
|
||||
optional = false
|
||||
@@ -653,12 +657,11 @@ identify = ">=1.0.0"
|
||||
importlib-metadata = {version = "*", markers = "python_version < \"3.8\""}
|
||||
nodeenv = ">=0.11.1"
|
||||
pyyaml = ">=5.1"
|
||||
toml = "*"
|
||||
virtualenv = ">=20.0.8"
|
||||
virtualenv = ">=20.10.0"
|
||||
|
||||
[[package]]
|
||||
name = "pygments"
|
||||
version = "2.13.0"
|
||||
version = "2.14.0"
|
||||
description = "Pygments is a syntax highlighting package written in Python."
|
||||
category = "main"
|
||||
optional = false
|
||||
@@ -760,7 +763,7 @@ six = ">=1.5"
|
||||
|
||||
[[package]]
|
||||
name = "pytz"
|
||||
version = "2022.6"
|
||||
version = "2022.7"
|
||||
description = "World timezone definitions, modern and historical"
|
||||
category = "dev"
|
||||
optional = false
|
||||
@@ -819,11 +822,11 @@ idna2008 = ["idna"]
|
||||
|
||||
[[package]]
|
||||
name = "rich"
|
||||
version = "12.6.0"
|
||||
version = "13.0.0"
|
||||
description = "Render rich text, tables, progress bars, syntax highlighting, markdown and more to the terminal"
|
||||
category = "main"
|
||||
optional = false
|
||||
python-versions = ">=3.6.3,<4.0.0"
|
||||
python-versions = ">=3.7.0"
|
||||
|
||||
[package.dependencies]
|
||||
commonmark = ">=0.9.0,<0.10.0"
|
||||
@@ -872,7 +875,7 @@ python-versions = ">=3.7"
|
||||
|
||||
[[package]]
|
||||
name = "syrupy"
|
||||
version = "3.0.5"
|
||||
version = "3.0.6"
|
||||
description = "Pytest Snapshot Test Utility"
|
||||
category = "dev"
|
||||
optional = false
|
||||
@@ -884,7 +887,7 @@ pytest = ">=5.1.0,<8.0.0"
|
||||
|
||||
[[package]]
|
||||
name = "time-machine"
|
||||
version = "2.8.2"
|
||||
version = "2.9.0"
|
||||
description = "Travel through time in your tests."
|
||||
category = "dev"
|
||||
optional = false
|
||||
@@ -966,7 +969,7 @@ testing = ["coverage (>=6.2)", "coverage-enable-subprocess (>=1)", "flaky (>=3.7
|
||||
|
||||
[[package]]
|
||||
name = "watchdog"
|
||||
version = "2.2.0"
|
||||
version = "2.2.1"
|
||||
description = "Filesystem events monitoring"
|
||||
category = "dev"
|
||||
optional = false
|
||||
@@ -1006,7 +1009,7 @@ dev = ["aiohttp", "click", "msgpack"]
|
||||
[metadata]
|
||||
lock-version = "1.1"
|
||||
python-versions = "^3.7"
|
||||
content-hash = "54991c99e5b0b3aeea2efe4f5aea432134bcc614328467554d6b01769a17fcff"
|
||||
content-hash = "0e3bcf48b37c16096a3c2b2f7d3f548494f9a22ebdee2e2c5d8ac74b80ab344e"
|
||||
|
||||
[metadata.files]
|
||||
aiohttp = [
|
||||
@@ -1115,8 +1118,8 @@ asynctest = [
|
||||
{file = "asynctest-0.13.0.tar.gz", hash = "sha256:c27862842d15d83e6a34eb0b2866c323880eb3a75e4485b079ea11748fd77fac"},
|
||||
]
|
||||
attrs = [
|
||||
{file = "attrs-22.1.0-py2.py3-none-any.whl", hash = "sha256:86efa402f67bf2df34f51a335487cf46b1ec130d02b8d39fd248abfd30da551c"},
|
||||
{file = "attrs-22.1.0.tar.gz", hash = "sha256:29adc2665447e5191d0e7c568fde78b21f9672d344281d0c6e1ab085429b22b6"},
|
||||
{file = "attrs-22.2.0-py3-none-any.whl", hash = "sha256:29e95c7f6778868dbd49170f98f8818f78f3dc5e0e37c0b1f474e3561b240836"},
|
||||
{file = "attrs-22.2.0.tar.gz", hash = "sha256:c9227bfc2f01993c03f68db37d1d15c9690188323c067c641f1a35ca58185f99"},
|
||||
]
|
||||
black = [
|
||||
{file = "black-22.8.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:ce957f1d6b78a8a231b18e0dd2d94a33d2ba738cd88a7fe64f53f659eea49fdd"},
|
||||
@@ -1175,68 +1178,69 @@ commonmark = [
|
||||
{file = "commonmark-0.9.1.tar.gz", hash = "sha256:452f9dc859be7f06631ddcb328b6919c67984aca654e5fefb3914d54691aed60"},
|
||||
]
|
||||
coverage = [
|
||||
{file = "coverage-6.5.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:ef8674b0ee8cc11e2d574e3e2998aea5df5ab242e012286824ea3c6970580e53"},
|
||||
{file = "coverage-6.5.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:784f53ebc9f3fd0e2a3f6a78b2be1bd1f5575d7863e10c6e12504f240fd06660"},
|
||||
{file = "coverage-6.5.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b4a5be1748d538a710f87542f22c2cad22f80545a847ad91ce45e77417293eb4"},
|
||||
{file = "coverage-6.5.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:83516205e254a0cb77d2d7bb3632ee019d93d9f4005de31dca0a8c3667d5bc04"},
|
||||
{file = "coverage-6.5.0-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:af4fffaffc4067232253715065e30c5a7ec6faac36f8fc8d6f64263b15f74db0"},
|
||||
{file = "coverage-6.5.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:97117225cdd992a9c2a5515db1f66b59db634f59d0679ca1fa3fe8da32749cae"},
|
||||
{file = "coverage-6.5.0-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:a1170fa54185845505fbfa672f1c1ab175446c887cce8212c44149581cf2d466"},
|
||||
{file = "coverage-6.5.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:11b990d520ea75e7ee8dcab5bc908072aaada194a794db9f6d7d5cfd19661e5a"},
|
||||
{file = "coverage-6.5.0-cp310-cp310-win32.whl", hash = "sha256:5dbec3b9095749390c09ab7c89d314727f18800060d8d24e87f01fb9cfb40b32"},
|
||||
{file = "coverage-6.5.0-cp310-cp310-win_amd64.whl", hash = "sha256:59f53f1dc5b656cafb1badd0feb428c1e7bc19b867479ff72f7a9dd9b479f10e"},
|
||||
{file = "coverage-6.5.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:4a5375e28c5191ac38cca59b38edd33ef4cc914732c916f2929029b4bfb50795"},
|
||||
{file = "coverage-6.5.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c4ed2820d919351f4167e52425e096af41bfabacb1857186c1ea32ff9983ed75"},
|
||||
{file = "coverage-6.5.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:33a7da4376d5977fbf0a8ed91c4dffaaa8dbf0ddbf4c8eea500a2486d8bc4d7b"},
|
||||
{file = "coverage-6.5.0-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a8fb6cf131ac4070c9c5a3e21de0f7dc5a0fbe8bc77c9456ced896c12fcdad91"},
|
||||
{file = "coverage-6.5.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:a6b7d95969b8845250586f269e81e5dfdd8ff828ddeb8567a4a2eaa7313460c4"},
|
||||
{file = "coverage-6.5.0-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:1ef221513e6f68b69ee9e159506d583d31aa3567e0ae84eaad9d6ec1107dddaa"},
|
||||
{file = "coverage-6.5.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:cca4435eebea7962a52bdb216dec27215d0df64cf27fc1dd538415f5d2b9da6b"},
|
||||
{file = "coverage-6.5.0-cp311-cp311-win32.whl", hash = "sha256:98e8a10b7a314f454d9eff4216a9a94d143a7ee65018dd12442e898ee2310578"},
|
||||
{file = "coverage-6.5.0-cp311-cp311-win_amd64.whl", hash = "sha256:bc8ef5e043a2af066fa8cbfc6e708d58017024dc4345a1f9757b329a249f041b"},
|
||||
{file = "coverage-6.5.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:4433b90fae13f86fafff0b326453dd42fc9a639a0d9e4eec4d366436d1a41b6d"},
|
||||
{file = "coverage-6.5.0-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f4f05d88d9a80ad3cac6244d36dd89a3c00abc16371769f1340101d3cb899fc3"},
|
||||
{file = "coverage-6.5.0-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:94e2565443291bd778421856bc975d351738963071e9b8839ca1fc08b42d4bef"},
|
||||
{file = "coverage-6.5.0-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:027018943386e7b942fa832372ebc120155fd970837489896099f5cfa2890f79"},
|
||||
{file = "coverage-6.5.0-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:255758a1e3b61db372ec2736c8e2a1fdfaf563977eedbdf131de003ca5779b7d"},
|
||||
{file = "coverage-6.5.0-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:851cf4ff24062c6aec510a454b2584f6e998cada52d4cb58c5e233d07172e50c"},
|
||||
{file = "coverage-6.5.0-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:12adf310e4aafddc58afdb04d686795f33f4d7a6fa67a7a9d4ce7d6ae24d949f"},
|
||||
{file = "coverage-6.5.0-cp37-cp37m-win32.whl", hash = "sha256:b5604380f3415ba69de87a289a2b56687faa4fe04dbee0754bfcae433489316b"},
|
||||
{file = "coverage-6.5.0-cp37-cp37m-win_amd64.whl", hash = "sha256:4a8dbc1f0fbb2ae3de73eb0bdbb914180c7abfbf258e90b311dcd4f585d44bd2"},
|
||||
{file = "coverage-6.5.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:d900bb429fdfd7f511f868cedd03a6bbb142f3f9118c09b99ef8dc9bf9643c3c"},
|
||||
{file = "coverage-6.5.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:2198ea6fc548de52adc826f62cb18554caedfb1d26548c1b7c88d8f7faa8f6ba"},
|
||||
{file = "coverage-6.5.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6c4459b3de97b75e3bd6b7d4b7f0db13f17f504f3d13e2a7c623786289dd670e"},
|
||||
{file = "coverage-6.5.0-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:20c8ac5386253717e5ccc827caad43ed66fea0efe255727b1053a8154d952398"},
|
||||
{file = "coverage-6.5.0-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6b07130585d54fe8dff3d97b93b0e20290de974dc8177c320aeaf23459219c0b"},
|
||||
{file = "coverage-6.5.0-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:dbdb91cd8c048c2b09eb17713b0c12a54fbd587d79adcebad543bc0cd9a3410b"},
|
||||
{file = "coverage-6.5.0-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:de3001a203182842a4630e7b8d1a2c7c07ec1b45d3084a83d5d227a3806f530f"},
|
||||
{file = "coverage-6.5.0-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:e07f4a4a9b41583d6eabec04f8b68076ab3cd44c20bd29332c6572dda36f372e"},
|
||||
{file = "coverage-6.5.0-cp38-cp38-win32.whl", hash = "sha256:6d4817234349a80dbf03640cec6109cd90cba068330703fa65ddf56b60223a6d"},
|
||||
{file = "coverage-6.5.0-cp38-cp38-win_amd64.whl", hash = "sha256:7ccf362abd726b0410bf8911c31fbf97f09f8f1061f8c1cf03dfc4b6372848f6"},
|
||||
{file = "coverage-6.5.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:633713d70ad6bfc49b34ead4060531658dc6dfc9b3eb7d8a716d5873377ab745"},
|
||||
{file = "coverage-6.5.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:95203854f974e07af96358c0b261f1048d8e1083f2de9b1c565e1be4a3a48cfc"},
|
||||
{file = "coverage-6.5.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b9023e237f4c02ff739581ef35969c3739445fb059b060ca51771e69101efffe"},
|
||||
{file = "coverage-6.5.0-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:265de0fa6778d07de30bcf4d9dc471c3dc4314a23a3c6603d356a3c9abc2dfcf"},
|
||||
{file = "coverage-6.5.0-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8f830ed581b45b82451a40faabb89c84e1a998124ee4212d440e9c6cf70083e5"},
|
||||
{file = "coverage-6.5.0-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:7b6be138d61e458e18d8e6ddcddd36dd96215edfe5f1168de0b1b32635839b62"},
|
||||
{file = "coverage-6.5.0-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:42eafe6778551cf006a7c43153af1211c3aaab658d4d66fa5fcc021613d02518"},
|
||||
{file = "coverage-6.5.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:723e8130d4ecc8f56e9a611e73b31219595baa3bb252d539206f7bbbab6ffc1f"},
|
||||
{file = "coverage-6.5.0-cp39-cp39-win32.whl", hash = "sha256:d9ecf0829c6a62b9b573c7bb6d4dcd6ba8b6f80be9ba4fc7ed50bf4ac9aecd72"},
|
||||
{file = "coverage-6.5.0-cp39-cp39-win_amd64.whl", hash = "sha256:fc2af30ed0d5ae0b1abdb4ebdce598eafd5b35397d4d75deb341a614d333d987"},
|
||||
{file = "coverage-6.5.0-pp36.pp37.pp38-none-any.whl", hash = "sha256:1431986dac3923c5945271f169f59c45b8802a114c8f548d611f2015133df77a"},
|
||||
{file = "coverage-6.5.0.tar.gz", hash = "sha256:f642e90754ee3e06b0e7e51bce3379590e76b7f76b708e1a71ff043f87025c84"},
|
||||
{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"},
|
||||
]
|
||||
distlib = [
|
||||
{file = "distlib-0.3.6-py2.py3-none-any.whl", hash = "sha256:f35c4b692542ca110de7ef0bea44d73981caeb34ca0b9b6b2e6d7790dda8f80e"},
|
||||
{file = "distlib-0.3.6.tar.gz", hash = "sha256:14bad2d9b04d3a36127ac97f30b12a19268f211063d8f8ee4f47108896e11b46"},
|
||||
]
|
||||
exceptiongroup = [
|
||||
{file = "exceptiongroup-1.0.4-py3-none-any.whl", hash = "sha256:542adf9dea4055530d6e1279602fa5cb11dab2395fa650b8674eaec35fc4a828"},
|
||||
{file = "exceptiongroup-1.0.4.tar.gz", hash = "sha256:bd14967b79cd9bdb54d97323216f8fdf533e278df937aa2a90089e7d6e06e5ec"},
|
||||
{file = "exceptiongroup-1.1.0-py3-none-any.whl", hash = "sha256:327cbda3da756e2de031a3107b81ab7b3770a602c4d16ca618298c526f4bec1e"},
|
||||
{file = "exceptiongroup-1.1.0.tar.gz", hash = "sha256:bcb67d800a4497e1b404c2dd44fca47d3b7a5e5433dbab67f96c1a685cdfdf23"},
|
||||
]
|
||||
filelock = [
|
||||
{file = "filelock-3.8.2-py3-none-any.whl", hash = "sha256:8df285554452285f79c035efb0c861eb33a4bcfa5b7a137016e32e6a90f9792c"},
|
||||
{file = "filelock-3.8.2.tar.gz", hash = "sha256:7565f628ea56bfcd8e54e42bdc55da899c85c1abfe1b5bcfd147e9188cebb3b2"},
|
||||
{file = "filelock-3.9.0-py3-none-any.whl", hash = "sha256:f58d535af89bb9ad5cd4df046f741f8553a418c01a7856bf0d173bbc9f6bd16d"},
|
||||
{file = "filelock-3.9.0.tar.gz", hash = "sha256:7b319f24340b51f55a2bf7a12ac0755a9b03e718311dac567a0f4f7fabd2f5de"},
|
||||
]
|
||||
frozenlist = [
|
||||
{file = "frozenlist-1.3.3-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:ff8bf625fe85e119553b5383ba0fb6aa3d0ec2ae980295aaefa552374926b3f4"},
|
||||
@@ -1323,28 +1327,28 @@ gitdb = [
|
||||
{file = "gitdb-4.0.10.tar.gz", hash = "sha256:6eb990b69df4e15bad899ea868dc46572c3f75339735663b81de79b06f17eb9a"},
|
||||
]
|
||||
gitpython = [
|
||||
{file = "GitPython-3.1.29-py3-none-any.whl", hash = "sha256:41eea0deec2deea139b459ac03656f0dd28fc4a3387240ec1d3c259a2c47850f"},
|
||||
{file = "GitPython-3.1.29.tar.gz", hash = "sha256:cc36bfc4a3f913e66805a28e84703e419d9c264c1077e537b54f0e1af85dbefd"},
|
||||
{file = "GitPython-3.1.30-py3-none-any.whl", hash = "sha256:cd455b0000615c60e286208ba540271af9fe531fa6a87cc590a7298785ab2882"},
|
||||
{file = "GitPython-3.1.30.tar.gz", hash = "sha256:769c2d83e13f5d938b7688479da374c4e3d49f71549aaf462b646db9602ea6f8"},
|
||||
]
|
||||
griffe = [
|
||||
{file = "griffe-0.24.1-py3-none-any.whl", hash = "sha256:cfd17f61f3815be5a83f27303cd3db6e9fd9328d4070e4824cd5573763a28961"},
|
||||
{file = "griffe-0.24.1.tar.gz", hash = "sha256:acc7e6aac2495ffbfd70b2cdd801fff1299ec3e5efaaad23ccd316b711f1d11d"},
|
||||
{file = "griffe-0.25.2-py3-none-any.whl", hash = "sha256:0868da415c5f43fe186705c041d98b69523c24a6504e841031373eacfdd7ec05"},
|
||||
{file = "griffe-0.25.2.tar.gz", hash = "sha256:555707b3417355e015d837845522cb38ee4ffcec485427868648eafacabe142e"},
|
||||
]
|
||||
h11 = [
|
||||
{file = "h11-0.14.0-py3-none-any.whl", hash = "sha256:e3fe4ac4b851c468cc8363d500db52c2ead036020723024a109d37346efaa761"},
|
||||
{file = "h11-0.14.0.tar.gz", hash = "sha256:8f19fbbe99e72420ff35c00b27a34cb9937e902a8b810e2c88300c6f0a3b699d"},
|
||||
]
|
||||
httpcore = [
|
||||
{file = "httpcore-0.16.2-py3-none-any.whl", hash = "sha256:52c79095197178856724541e845f2db86d5f1527640d9254b5b8f6f6cebfdee6"},
|
||||
{file = "httpcore-0.16.2.tar.gz", hash = "sha256:c35c5176dc82db732acfd90b581a3062c999a72305df30c0fc8fafd8e4aca068"},
|
||||
{file = "httpcore-0.16.3-py3-none-any.whl", hash = "sha256:da1fb708784a938aa084bde4feb8317056c55037247c787bd7e19eb2c2949dc0"},
|
||||
{file = "httpcore-0.16.3.tar.gz", hash = "sha256:c5d6f04e2fc530f39e0c077e6a30caa53f1451096120f1f38b954afd0b17c0cb"},
|
||||
]
|
||||
httpx = [
|
||||
{file = "httpx-0.23.1-py3-none-any.whl", hash = "sha256:0b9b1f0ee18b9978d637b0776bfd7f54e2ca278e063e3586d8f01cda89e042a8"},
|
||||
{file = "httpx-0.23.1.tar.gz", hash = "sha256:202ae15319be24efe9a8bd4ed4360e68fde7b38bcc2ce87088d416f026667d19"},
|
||||
{file = "httpx-0.23.2-py3-none-any.whl", hash = "sha256:106cded342a44e443060fab70ef327139248c61939e77d73964560c8d8b57069"},
|
||||
{file = "httpx-0.23.2.tar.gz", hash = "sha256:e824a6fa18ffaa6423c6f3a32d5096fc15bd8dff43663a223f06242fc69451a8"},
|
||||
]
|
||||
identify = [
|
||||
{file = "identify-2.5.9-py2.py3-none-any.whl", hash = "sha256:a390fb696e164dbddb047a0db26e57972ae52fbd037ae68797e5ae2f4492485d"},
|
||||
{file = "identify-2.5.9.tar.gz", hash = "sha256:906036344ca769539610436e40a684e170c3648b552194980bb7b617a8daeb9f"},
|
||||
{file = "identify-2.5.11-py2.py3-none-any.whl", hash = "sha256:e7db36b772b188099616aaf2accbee122949d1c6a1bac4f38196720d6f9f06db"},
|
||||
{file = "identify-2.5.11.tar.gz", hash = "sha256:14b7076b29c99b1b0b8b08e96d448c7b877a9b07683cd8cfda2ea06af85ffa1c"},
|
||||
]
|
||||
idna = [
|
||||
{file = "idna-3.4-py3-none-any.whl", hash = "sha256:90b77e79eaa3eba6de819a0c442c0b4ceefc341a7a2ab77d7562bf49f425c5c2"},
|
||||
@@ -1433,8 +1437,8 @@ mkdocs-rss-plugin = [
|
||||
{file = "mkdocs_rss_plugin-1.5.0-py2.py3-none-any.whl", hash = "sha256:2ab14c20bf6b7983acbe50181e7e4a0778731d9c2d5c38107ca7047a7abd2165"},
|
||||
]
|
||||
mkdocstrings = [
|
||||
{file = "mkdocstrings-0.19.0-py3-none-any.whl", hash = "sha256:3217d510d385c961f69385a670b2677e68e07b5fea4a504d86bf54c006c87c7d"},
|
||||
{file = "mkdocstrings-0.19.0.tar.gz", hash = "sha256:efa34a67bad11229d532d89f6836a8a215937548623b64f3698a1df62e01cc3e"},
|
||||
{file = "mkdocstrings-0.19.1-py3-none-any.whl", hash = "sha256:32a38d88f67f65b264184ea71290f9332db750d189dea4200cbbe408d304c261"},
|
||||
{file = "mkdocstrings-0.19.1.tar.gz", hash = "sha256:d1037cacb4b522c1e8c164ed5d00d724a82e49dcee0af80db8fb67b384faeef9"},
|
||||
]
|
||||
mkdocstrings-python = [
|
||||
{file = "mkdocstrings-python-0.8.2.tar.gz", hash = "sha256:b22528b7a7a0589d007eced019d97ad14de4eba4b2b9ba6a013bb66edc74ab43"},
|
||||
@@ -1495,80 +1499,80 @@ msgpack = [
|
||||
{file = "msgpack-1.0.4.tar.gz", hash = "sha256:f5d869c18f030202eb412f08b28d2afeea553d6613aee89e200d7aca7ef01f5f"},
|
||||
]
|
||||
multidict = [
|
||||
{file = "multidict-6.0.3-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:73009ea04205966d47e16d98686ac5c438af23a1bb30b48a2c5da3423ec9ce37"},
|
||||
{file = "multidict-6.0.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:8b92a9f3ab904397a33b193000dc4de7318ea175c4c460a1e154c415f9008e3d"},
|
||||
{file = "multidict-6.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:578bfcb16f4b8675ef71b960c00f174b0426e0eeb796bab6737389d8288eb827"},
|
||||
{file = "multidict-6.0.3-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f1650ea41c408755da5eed52ac6ccbc8938ccc3e698d81e6f6a1be02ff2a0945"},
|
||||
{file = "multidict-6.0.3-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d52442e7c951e4c9ee591d6047706e66923d248d83958bbf99b8b19515fffaef"},
|
||||
{file = "multidict-6.0.3-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ad7d66422b9cc51125509229693d27e18c08f2dea3ac9de408d821932b1b3759"},
|
||||
{file = "multidict-6.0.3-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6cd14e61f0da2a2cfb9fe05bfced2a1ed7063ce46a7a8cd473be4973de9a7f91"},
|
||||
{file = "multidict-6.0.3-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:190626ced82d4cc567a09e7346340d380154a493bac6905e0095d8158cdf1e38"},
|
||||
{file = "multidict-6.0.3-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:791458a1f7d1b4ab3bd9e93e0dcd1d59ef7ee9aa051dcd1ea030e62e49b923fd"},
|
||||
{file = "multidict-6.0.3-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:b46e79a9f4db53897d17bc64a39d1c7c2be3e3d4f8dba6d6730a2b13ddf0f986"},
|
||||
{file = "multidict-6.0.3-cp310-cp310-musllinux_1_1_ppc64le.whl", hash = "sha256:e4a095e18847c12ec20e55326ab8782d9c2d599400a3a2f174fab4796875d0e2"},
|
||||
{file = "multidict-6.0.3-cp310-cp310-musllinux_1_1_s390x.whl", hash = "sha256:fb6c3dc3d65014d2c782f5acf0b3ba14e639c6c33d3ed8932ead76b9080b3544"},
|
||||
{file = "multidict-6.0.3-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:3541882266247c7cd3dba78d6ef28dbe704774df60c9e4231edaa4493522e614"},
|
||||
{file = "multidict-6.0.3-cp310-cp310-win32.whl", hash = "sha256:67090b17a0a5be5704fd109f231ee73cefb1b3802d41288d6378b5df46ae89ba"},
|
||||
{file = "multidict-6.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:36df958b15639e40472adaa4f0c2c7828fe680f894a6b48c4ce229f59a6a798b"},
|
||||
{file = "multidict-6.0.3-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:5b51969503709415a35754954c2763f536a70b8bf7360322b2edb0c0a44391f6"},
|
||||
{file = "multidict-6.0.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:24e8d513bfcaadc1f8b0ebece3ff50961951c54b07d5a775008a882966102418"},
|
||||
{file = "multidict-6.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d325d61cac602976a5d47b19eaa7d04e3daf4efce2164c630219885087234102"},
|
||||
{file = "multidict-6.0.3-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:26fbbe17f8a7211b623502d2bf41022a51da3025142401417c765bf9a56fed4c"},
|
||||
{file = "multidict-6.0.3-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4fb3fe591956d8841882c463f934c9f7485cfd5f763a08c0d467b513dc18ef89"},
|
||||
{file = "multidict-6.0.3-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e1925f78a543b94c3d46274c66a366fee8a263747060220ed0188e5f3eeea1c0"},
|
||||
{file = "multidict-6.0.3-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:21e1ce0b187c4e93112304dcde2aa18922fdbe8fb4f13d8aa72a5657bce0563a"},
|
||||
{file = "multidict-6.0.3-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e07c24018986fb00d6e7eafca8fcd6e05095649e17fcf0e33a592caaa62a78b9"},
|
||||
{file = "multidict-6.0.3-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:114a4ab3e5cfbc56c4b6697686ecb92376c7e8c56893ef20547921552f8bdf57"},
|
||||
{file = "multidict-6.0.3-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:4ccf55f28066b4f08666764a957c2b7c241c7547b0921d69c7ceab5f74fe1a45"},
|
||||
{file = "multidict-6.0.3-cp311-cp311-musllinux_1_1_ppc64le.whl", hash = "sha256:9d359b0a962e052b713647ac1f13eabf2263167b149ed1e27d5c579f5c8c7d2c"},
|
||||
{file = "multidict-6.0.3-cp311-cp311-musllinux_1_1_s390x.whl", hash = "sha256:df7b4cee3ff31b3335aba602f8d70dbc641e5b7164b1e9565570c9d3c536a438"},
|
||||
{file = "multidict-6.0.3-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:ee9b1cae9a6c5d023e5a150f6f6b9dbb3c3bbc7887d6ee07d4c0ecb49a473734"},
|
||||
{file = "multidict-6.0.3-cp311-cp311-win32.whl", hash = "sha256:960ce1b790952916e682093788696ef7e33ac6a97482f9b983abdc293091b531"},
|
||||
{file = "multidict-6.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:2b66d61966b12e6bba500e5cbb2c721a35e119c30ee02495c5629bd0e91eea30"},
|
||||
{file = "multidict-6.0.3-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:526f8397fc124674b8f39748680a0ff673bd6a715fecb4866716d36e380f015f"},
|
||||
{file = "multidict-6.0.3-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1f5d5129a937af4e3c4a1d6c139f4051b7d17d43276cefdd8d442a7031f7eef2"},
|
||||
{file = "multidict-6.0.3-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:38d394814b39be1c36ac709006d39d50d72a884f9551acd9c8cc1ffae3fc8c4e"},
|
||||
{file = "multidict-6.0.3-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:99341ca1f1db9e7f47914cb2461305665a662383765ced6f843712564766956d"},
|
||||
{file = "multidict-6.0.3-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c5790cc603456b6dcf8a9a4765f666895a6afddc88b3d3ba7b53dea2b6e23116"},
|
||||
{file = "multidict-6.0.3-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ce8e51774eb03844588d3c279adb94efcd0edeccd2f97516623292445bcc01f9"},
|
||||
{file = "multidict-6.0.3-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:baa96a3418e27d723064854143b2f414a422c84cc87285a71558722049bebc5a"},
|
||||
{file = "multidict-6.0.3-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:cb4a08f0aaaa869f189ffea0e17b86ad0237b51116d494da15ef7991ee6ad2d7"},
|
||||
{file = "multidict-6.0.3-cp37-cp37m-musllinux_1_1_ppc64le.whl", hash = "sha256:62db44727d0befea68e8ad2881bb87a9cfb6b87d45dd78609009627167f37b69"},
|
||||
{file = "multidict-6.0.3-cp37-cp37m-musllinux_1_1_s390x.whl", hash = "sha256:4cc5c8cd205a9810d16a5cd428cd81bac554ad1477cb87f4ad722b10992e794d"},
|
||||
{file = "multidict-6.0.3-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:f76109387e1ec8d8e2137c94c437b89fe002f29e0881aae8ae45529bdff92000"},
|
||||
{file = "multidict-6.0.3-cp37-cp37m-win32.whl", hash = "sha256:f8a728511c977df6f3d8af388fcb157e49f11db4a6637dd60131b8b6e40b0253"},
|
||||
{file = "multidict-6.0.3-cp37-cp37m-win_amd64.whl", hash = "sha256:c2a1168e5aa7c72499fb03c850e0f03f624fa4a5c8d2e215c518d0a73872eb64"},
|
||||
{file = "multidict-6.0.3-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:eddf604a3de2ace3d9a4e4d491be7562a1ac095a0a1c95a9ec5781ef0273ef11"},
|
||||
{file = "multidict-6.0.3-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:d09daf5c6ce7fc6ed444c9339bbde5ea84e2534d1ca1cd37b60f365c77f00dea"},
|
||||
{file = "multidict-6.0.3-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:12e0d396faa6dc55ff5379eee54d1df3b508243ff15bfc8295a6ec7a4483a335"},
|
||||
{file = "multidict-6.0.3-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:70740c2bc9ab1c99f7cdcb104f27d16c63860c56d51c5bf0ef82fc1d892a2131"},
|
||||
{file = "multidict-6.0.3-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e322c94596054352f5a02771eec71563c018b15699b961aba14d6dd943367022"},
|
||||
{file = "multidict-6.0.3-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4159fc1ec9ede8ab93382e0d6ba9b1b3d23c72da39a834db7a116986605c7ab4"},
|
||||
{file = "multidict-6.0.3-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:47defc0218682281a52fb1f6346ebb8b68b17538163a89ea24dfe4da37a8a9a3"},
|
||||
{file = "multidict-6.0.3-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7f9511e48bde6b995825e8d35e434fc96296cf07a25f4aae24ff9162be7eaa46"},
|
||||
{file = "multidict-6.0.3-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:e0bce9f7c30e7e3a9e683f670314c0144e8d34be6b7019e40604763bd278d84f"},
|
||||
{file = "multidict-6.0.3-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:01b456046a05ff7cceefb0e1d2a9d32f05efcb1c7e0d152446304e11557639ce"},
|
||||
{file = "multidict-6.0.3-cp38-cp38-musllinux_1_1_ppc64le.whl", hash = "sha256:8230a39bae6c2e8a09e4da6bace5064693b00590a4a213e38f9a9366da10e7dd"},
|
||||
{file = "multidict-6.0.3-cp38-cp38-musllinux_1_1_s390x.whl", hash = "sha256:445c0851a1cbc1f2ec3b40bc22f9c4a235edb3c9a0906122a9df6ea8d51f886c"},
|
||||
{file = "multidict-6.0.3-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:9aac6881454a750554ed4b280a839dcf9e2133a9d12ab4d417d673fb102289b7"},
|
||||
{file = "multidict-6.0.3-cp38-cp38-win32.whl", hash = "sha256:81c3d597591b0940e04949e4e4f79359b2d2e542a686ba0da5e25de33fec13e0"},
|
||||
{file = "multidict-6.0.3-cp38-cp38-win_amd64.whl", hash = "sha256:dc4cfef5d899f5f1a15f3d2ac49f71107a01a5a2745b4dd53fa0cede1419385a"},
|
||||
{file = "multidict-6.0.3-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:d408172519049e36fb6d29672f060dc8461fc7174eba9883c7026041ef9bfb38"},
|
||||
{file = "multidict-6.0.3-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:e068dfeadbce63072b2d8096486713d04db4946aad0a0f849bd4fc300799d0d3"},
|
||||
{file = "multidict-6.0.3-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:a8b817d4ed68fd568ec5e45dd75ddf30cc72a47a6b41b74d5bb211374c296f5e"},
|
||||
{file = "multidict-6.0.3-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2cf5d19e12eff855aa198259c0b02fd3f5d07e1291fbd20279c37b3b0e6c9852"},
|
||||
{file = "multidict-6.0.3-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e5a811aab1b4aea0b4be669363c19847a8c547510f0e18fb632956369fdbdf67"},
|
||||
{file = "multidict-6.0.3-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2cfda34b7cb99eacada2072e0f69c0ad3285cb6f8e480b11f2b6d6c1c6f92718"},
|
||||
{file = "multidict-6.0.3-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:beeca903e4270b4afcd114f371a9602240dc143f9e944edfea00f8d4ad56c40d"},
|
||||
{file = "multidict-6.0.3-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:cd5771e8ea325f85cbb361ddbdeb9ae424a68e5dfb6eea786afdcd22e68a7d5d"},
|
||||
{file = "multidict-6.0.3-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:9dbab2a7e9c073bc9538824a01f5ed689194db7f55f2b8102766873e906a6c1a"},
|
||||
{file = "multidict-6.0.3-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:f2c0957b3e8c66c10d27272709a5299ab3670a0f187c9428f3b90d267119aedb"},
|
||||
{file = "multidict-6.0.3-cp39-cp39-musllinux_1_1_ppc64le.whl", hash = "sha256:94cbe5535ef150546b8321aebea22862a3284da51e7b55f6f95b7d73e96d90ee"},
|
||||
{file = "multidict-6.0.3-cp39-cp39-musllinux_1_1_s390x.whl", hash = "sha256:d0e798b072cf2aab9daceb43d97c9c527a0c7593e67a7846ad4cc6051de1e303"},
|
||||
{file = "multidict-6.0.3-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:a27b029caa3b555a4f3da54bc1e718eb55fcf1a11fda8bf0132147b476cf4c08"},
|
||||
{file = "multidict-6.0.3-cp39-cp39-win32.whl", hash = "sha256:018c8e3be7f161a12b3e41741b6721f9baeb2210f4ab25a6359b7d76c1017dce"},
|
||||
{file = "multidict-6.0.3-cp39-cp39-win_amd64.whl", hash = "sha256:5e58ec0375803526d395f6f7e730ecc45d06e15f68f7b9cdbf644a2918324e51"},
|
||||
{file = "multidict-6.0.3.tar.gz", hash = "sha256:2523a29006c034687eccd3ee70093a697129a3ffe8732535d3b2df6a4ecc279d"},
|
||||
{file = "multidict-6.0.4-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:0b1a97283e0c85772d613878028fec909f003993e1007eafa715b24b377cb9b8"},
|
||||
{file = "multidict-6.0.4-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:eeb6dcc05e911516ae3d1f207d4b0520d07f54484c49dfc294d6e7d63b734171"},
|
||||
{file = "multidict-6.0.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:d6d635d5209b82a3492508cf5b365f3446afb65ae7ebd755e70e18f287b0adf7"},
|
||||
{file = "multidict-6.0.4-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c048099e4c9e9d615545e2001d3d8a4380bd403e1a0578734e0d31703d1b0c0b"},
|
||||
{file = "multidict-6.0.4-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ea20853c6dbbb53ed34cb4d080382169b6f4554d394015f1bef35e881bf83547"},
|
||||
{file = "multidict-6.0.4-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:16d232d4e5396c2efbbf4f6d4df89bfa905eb0d4dc5b3549d872ab898451f569"},
|
||||
{file = "multidict-6.0.4-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:36c63aaa167f6c6b04ef2c85704e93af16c11d20de1d133e39de6a0e84582a93"},
|
||||
{file = "multidict-6.0.4-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:64bdf1086b6043bf519869678f5f2757f473dee970d7abf6da91ec00acb9cb98"},
|
||||
{file = "multidict-6.0.4-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:43644e38f42e3af682690876cff722d301ac585c5b9e1eacc013b7a3f7b696a0"},
|
||||
{file = "multidict-6.0.4-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:7582a1d1030e15422262de9f58711774e02fa80df0d1578995c76214f6954988"},
|
||||
{file = "multidict-6.0.4-cp310-cp310-musllinux_1_1_ppc64le.whl", hash = "sha256:ddff9c4e225a63a5afab9dd15590432c22e8057e1a9a13d28ed128ecf047bbdc"},
|
||||
{file = "multidict-6.0.4-cp310-cp310-musllinux_1_1_s390x.whl", hash = "sha256:ee2a1ece51b9b9e7752e742cfb661d2a29e7bcdba2d27e66e28a99f1890e4fa0"},
|
||||
{file = "multidict-6.0.4-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:a2e4369eb3d47d2034032a26c7a80fcb21a2cb22e1173d761a162f11e562caa5"},
|
||||
{file = "multidict-6.0.4-cp310-cp310-win32.whl", hash = "sha256:574b7eae1ab267e5f8285f0fe881f17efe4b98c39a40858247720935b893bba8"},
|
||||
{file = "multidict-6.0.4-cp310-cp310-win_amd64.whl", hash = "sha256:4dcbb0906e38440fa3e325df2359ac6cb043df8e58c965bb45f4e406ecb162cc"},
|
||||
{file = "multidict-6.0.4-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:0dfad7a5a1e39c53ed00d2dd0c2e36aed4650936dc18fd9a1826a5ae1cad6f03"},
|
||||
{file = "multidict-6.0.4-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:64da238a09d6039e3bd39bb3aee9c21a5e34f28bfa5aa22518581f910ff94af3"},
|
||||
{file = "multidict-6.0.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:ff959bee35038c4624250473988b24f846cbeb2c6639de3602c073f10410ceba"},
|
||||
{file = "multidict-6.0.4-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:01a3a55bd90018c9c080fbb0b9f4891db37d148a0a18722b42f94694f8b6d4c9"},
|
||||
{file = "multidict-6.0.4-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c5cb09abb18c1ea940fb99360ea0396f34d46566f157122c92dfa069d3e0e982"},
|
||||
{file = "multidict-6.0.4-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:666daae833559deb2d609afa4490b85830ab0dfca811a98b70a205621a6109fe"},
|
||||
{file = "multidict-6.0.4-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:11bdf3f5e1518b24530b8241529d2050014c884cf18b6fc69c0c2b30ca248710"},
|
||||
{file = "multidict-6.0.4-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7d18748f2d30f94f498e852c67d61261c643b349b9d2a581131725595c45ec6c"},
|
||||
{file = "multidict-6.0.4-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:458f37be2d9e4c95e2d8866a851663cbc76e865b78395090786f6cd9b3bbf4f4"},
|
||||
{file = "multidict-6.0.4-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:b1a2eeedcead3a41694130495593a559a668f382eee0727352b9a41e1c45759a"},
|
||||
{file = "multidict-6.0.4-cp311-cp311-musllinux_1_1_ppc64le.whl", hash = "sha256:7d6ae9d593ef8641544d6263c7fa6408cc90370c8cb2bbb65f8d43e5b0351d9c"},
|
||||
{file = "multidict-6.0.4-cp311-cp311-musllinux_1_1_s390x.whl", hash = "sha256:5979b5632c3e3534e42ca6ff856bb24b2e3071b37861c2c727ce220d80eee9ed"},
|
||||
{file = "multidict-6.0.4-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:dcfe792765fab89c365123c81046ad4103fcabbc4f56d1c1997e6715e8015461"},
|
||||
{file = "multidict-6.0.4-cp311-cp311-win32.whl", hash = "sha256:3601a3cece3819534b11d4efc1eb76047488fddd0c85a3948099d5da4d504636"},
|
||||
{file = "multidict-6.0.4-cp311-cp311-win_amd64.whl", hash = "sha256:81a4f0b34bd92df3da93315c6a59034df95866014ac08535fc819f043bfd51f0"},
|
||||
{file = "multidict-6.0.4-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:67040058f37a2a51ed8ea8f6b0e6ee5bd78ca67f169ce6122f3e2ec80dfe9b78"},
|
||||
{file = "multidict-6.0.4-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:853888594621e6604c978ce2a0444a1e6e70c8d253ab65ba11657659dcc9100f"},
|
||||
{file = "multidict-6.0.4-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:39ff62e7d0f26c248b15e364517a72932a611a9b75f35b45be078d81bdb86603"},
|
||||
{file = "multidict-6.0.4-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:af048912e045a2dc732847d33821a9d84ba553f5c5f028adbd364dd4765092ac"},
|
||||
{file = "multidict-6.0.4-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b1e8b901e607795ec06c9e42530788c45ac21ef3aaa11dbd0c69de543bfb79a9"},
|
||||
{file = "multidict-6.0.4-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:62501642008a8b9871ddfccbf83e4222cf8ac0d5aeedf73da36153ef2ec222d2"},
|
||||
{file = "multidict-6.0.4-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:99b76c052e9f1bc0721f7541e5e8c05db3941eb9ebe7b8553c625ef88d6eefde"},
|
||||
{file = "multidict-6.0.4-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:509eac6cf09c794aa27bcacfd4d62c885cce62bef7b2c3e8b2e49d365b5003fe"},
|
||||
{file = "multidict-6.0.4-cp37-cp37m-musllinux_1_1_ppc64le.whl", hash = "sha256:21a12c4eb6ddc9952c415f24eef97e3e55ba3af61f67c7bc388dcdec1404a067"},
|
||||
{file = "multidict-6.0.4-cp37-cp37m-musllinux_1_1_s390x.whl", hash = "sha256:5cad9430ab3e2e4fa4a2ef4450f548768400a2ac635841bc2a56a2052cdbeb87"},
|
||||
{file = "multidict-6.0.4-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:ab55edc2e84460694295f401215f4a58597f8f7c9466faec545093045476327d"},
|
||||
{file = "multidict-6.0.4-cp37-cp37m-win32.whl", hash = "sha256:5a4dcf02b908c3b8b17a45fb0f15b695bf117a67b76b7ad18b73cf8e92608775"},
|
||||
{file = "multidict-6.0.4-cp37-cp37m-win_amd64.whl", hash = "sha256:6ed5f161328b7df384d71b07317f4d8656434e34591f20552c7bcef27b0ab88e"},
|
||||
{file = "multidict-6.0.4-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:5fc1b16f586f049820c5c5b17bb4ee7583092fa0d1c4e28b5239181ff9532e0c"},
|
||||
{file = "multidict-6.0.4-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:1502e24330eb681bdaa3eb70d6358e818e8e8f908a22a1851dfd4e15bc2f8161"},
|
||||
{file = "multidict-6.0.4-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:b692f419760c0e65d060959df05f2a531945af31fda0c8a3b3195d4efd06de11"},
|
||||
{file = "multidict-6.0.4-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:45e1ecb0379bfaab5eef059f50115b54571acfbe422a14f668fc8c27ba410e7e"},
|
||||
{file = "multidict-6.0.4-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ddd3915998d93fbcd2566ddf9cf62cdb35c9e093075f862935573d265cf8f65d"},
|
||||
{file = "multidict-6.0.4-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:59d43b61c59d82f2effb39a93c48b845efe23a3852d201ed2d24ba830d0b4cf2"},
|
||||
{file = "multidict-6.0.4-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cc8e1d0c705233c5dd0c5e6460fbad7827d5d36f310a0fadfd45cc3029762258"},
|
||||
{file = "multidict-6.0.4-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d6aa0418fcc838522256761b3415822626f866758ee0bc6632c9486b179d0b52"},
|
||||
{file = "multidict-6.0.4-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:6748717bb10339c4760c1e63da040f5f29f5ed6e59d76daee30305894069a660"},
|
||||
{file = "multidict-6.0.4-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:4d1a3d7ef5e96b1c9e92f973e43aa5e5b96c659c9bc3124acbbd81b0b9c8a951"},
|
||||
{file = "multidict-6.0.4-cp38-cp38-musllinux_1_1_ppc64le.whl", hash = "sha256:4372381634485bec7e46718edc71528024fcdc6f835baefe517b34a33c731d60"},
|
||||
{file = "multidict-6.0.4-cp38-cp38-musllinux_1_1_s390x.whl", hash = "sha256:fc35cb4676846ef752816d5be2193a1e8367b4c1397b74a565a9d0389c433a1d"},
|
||||
{file = "multidict-6.0.4-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:4b9d9e4e2b37daddb5c23ea33a3417901fa7c7b3dee2d855f63ee67a0b21e5b1"},
|
||||
{file = "multidict-6.0.4-cp38-cp38-win32.whl", hash = "sha256:e41b7e2b59679edfa309e8db64fdf22399eec4b0b24694e1b2104fb789207779"},
|
||||
{file = "multidict-6.0.4-cp38-cp38-win_amd64.whl", hash = "sha256:d6c254ba6e45d8e72739281ebc46ea5eb5f101234f3ce171f0e9f5cc86991480"},
|
||||
{file = "multidict-6.0.4-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:16ab77bbeb596e14212e7bab8429f24c1579234a3a462105cda4a66904998664"},
|
||||
{file = "multidict-6.0.4-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:bc779e9e6f7fda81b3f9aa58e3a6091d49ad528b11ed19f6621408806204ad35"},
|
||||
{file = "multidict-6.0.4-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:4ceef517eca3e03c1cceb22030a3e39cb399ac86bff4e426d4fc6ae49052cc60"},
|
||||
{file = "multidict-6.0.4-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:281af09f488903fde97923c7744bb001a9b23b039a909460d0f14edc7bf59706"},
|
||||
{file = "multidict-6.0.4-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:52f2dffc8acaba9a2f27174c41c9e57f60b907bb9f096b36b1a1f3be71c6284d"},
|
||||
{file = "multidict-6.0.4-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b41156839806aecb3641f3208c0dafd3ac7775b9c4c422d82ee2a45c34ba81ca"},
|
||||
{file = "multidict-6.0.4-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d5e3fc56f88cc98ef8139255cf8cd63eb2c586531e43310ff859d6bb3a6b51f1"},
|
||||
{file = "multidict-6.0.4-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8316a77808c501004802f9beebde51c9f857054a0c871bd6da8280e718444449"},
|
||||
{file = "multidict-6.0.4-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:f70b98cd94886b49d91170ef23ec5c0e8ebb6f242d734ed7ed677b24d50c82cf"},
|
||||
{file = "multidict-6.0.4-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:bf6774e60d67a9efe02b3616fee22441d86fab4c6d335f9d2051d19d90a40063"},
|
||||
{file = "multidict-6.0.4-cp39-cp39-musllinux_1_1_ppc64le.whl", hash = "sha256:e69924bfcdda39b722ef4d9aa762b2dd38e4632b3641b1d9a57ca9cd18f2f83a"},
|
||||
{file = "multidict-6.0.4-cp39-cp39-musllinux_1_1_s390x.whl", hash = "sha256:6b181d8c23da913d4ff585afd1155a0e1194c0b50c54fcfe286f70cdaf2b7176"},
|
||||
{file = "multidict-6.0.4-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:52509b5be062d9eafc8170e53026fbc54cf3b32759a23d07fd935fb04fc22d95"},
|
||||
{file = "multidict-6.0.4-cp39-cp39-win32.whl", hash = "sha256:27c523fbfbdfd19c6867af7346332b62b586eed663887392cff78d614f9ec313"},
|
||||
{file = "multidict-6.0.4-cp39-cp39-win_amd64.whl", hash = "sha256:33029f5734336aa0d4c0384525da0387ef89148dc7191aae00ca5fb23d7aafc2"},
|
||||
{file = "multidict-6.0.4.tar.gz", hash = "sha256:3666906492efb76453c0e7b97f2cf459b0682e7402c0489a95484965dbc1da49"},
|
||||
]
|
||||
mypy = [
|
||||
{file = "mypy-0.990-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:aaf1be63e0207d7d17be942dcf9a6b641745581fe6c64df9a38deb562a7dbafa"},
|
||||
@@ -1619,24 +1623,24 @@ packaging = [
|
||||
{file = "packaging-22.0.tar.gz", hash = "sha256:2198ec20bd4c017b8f9717e00f0c8714076fc2fd93816750ab48e2c41de2cfd3"},
|
||||
]
|
||||
pathspec = [
|
||||
{file = "pathspec-0.10.2-py3-none-any.whl", hash = "sha256:88c2606f2c1e818b978540f73ecc908e13999c6c3a383daf3705652ae79807a5"},
|
||||
{file = "pathspec-0.10.2.tar.gz", hash = "sha256:8f6bf73e5758fd365ef5d58ce09ac7c27d2833a8d7da51712eac6e27e35141b0"},
|
||||
{file = "pathspec-0.10.3-py3-none-any.whl", hash = "sha256:3c95343af8b756205e2aba76e843ba9520a24dd84f68c22b9f93251507509dd6"},
|
||||
{file = "pathspec-0.10.3.tar.gz", hash = "sha256:56200de4077d9d0791465aa9095a01d421861e405b5096955051deefd697d6f6"},
|
||||
]
|
||||
platformdirs = [
|
||||
{file = "platformdirs-2.6.0-py3-none-any.whl", hash = "sha256:1a89a12377800c81983db6be069ec068eee989748799b946cce2a6e80dcc54ca"},
|
||||
{file = "platformdirs-2.6.0.tar.gz", hash = "sha256:b46ffafa316e6b83b47489d240ce17173f123a9b9c83282141c3daf26ad9ac2e"},
|
||||
{file = "platformdirs-2.6.2-py3-none-any.whl", hash = "sha256:83c8f6d04389165de7c9b6f0c682439697887bca0aa2f1c87ef1826be3584490"},
|
||||
{file = "platformdirs-2.6.2.tar.gz", hash = "sha256:e1fea1fe471b9ff8332e229df3cb7de4f53eeea4998d3b6bfff542115e998bd2"},
|
||||
]
|
||||
pluggy = [
|
||||
{file = "pluggy-1.0.0-py2.py3-none-any.whl", hash = "sha256:74134bbf457f031a36d68416e1509f34bd5ccc019f0bcc952c7b909d06b37bd3"},
|
||||
{file = "pluggy-1.0.0.tar.gz", hash = "sha256:4224373bacce55f955a878bf9cfa763c1e360858e330072059e10bad68531159"},
|
||||
]
|
||||
pre-commit = [
|
||||
{file = "pre_commit-2.20.0-py2.py3-none-any.whl", hash = "sha256:51a5ba7c480ae8072ecdb6933df22d2f812dc897d5fe848778116129a681aac7"},
|
||||
{file = "pre_commit-2.20.0.tar.gz", hash = "sha256:a978dac7bc9ec0bcee55c18a277d553b0f419d259dadb4b9418ff2d00eb43959"},
|
||||
{file = "pre_commit-2.21.0-py2.py3-none-any.whl", hash = "sha256:e2f91727039fc39a92f58a588a25b87f936de6567eed4f0e673e0507edc75bad"},
|
||||
{file = "pre_commit-2.21.0.tar.gz", hash = "sha256:31ef31af7e474a8d8995027fefdfcf509b5c913ff31f2015b4ec4beb26a6f658"},
|
||||
]
|
||||
pygments = [
|
||||
{file = "Pygments-2.13.0-py3-none-any.whl", hash = "sha256:f643f331ab57ba3c9d89212ee4a2dabc6e94f117cf4eefde99a0574720d14c42"},
|
||||
{file = "Pygments-2.13.0.tar.gz", hash = "sha256:56a8508ae95f98e2b9bdf93a6be5ae3f7d8af858b43e02c5a2ff083726be40c1"},
|
||||
{file = "Pygments-2.14.0-py3-none-any.whl", hash = "sha256:fa7bd7bd2771287c0de303af8bfdfc731f51bd2c6a47ab69d117138893b82717"},
|
||||
{file = "Pygments-2.14.0.tar.gz", hash = "sha256:b3ed06a9e8ac9a9aae5a6f5dbe78a8a58655d17b43b93c078f094ddc476ae297"},
|
||||
]
|
||||
pymdown-extensions = [
|
||||
{file = "pymdown_extensions-9.9-py3-none-any.whl", hash = "sha256:ac698c15265680db5eb13cd4342abfcde2079ac01e5486028f47a1b41547b859"},
|
||||
@@ -1663,8 +1667,8 @@ python-dateutil = [
|
||||
{file = "python_dateutil-2.8.2-py2.py3-none-any.whl", hash = "sha256:961d03dc3453ebbc59dbdea9e4e11c5651520a876d0f4db161e8674aae935da9"},
|
||||
]
|
||||
pytz = [
|
||||
{file = "pytz-2022.6-py2.py3-none-any.whl", hash = "sha256:222439474e9c98fced559f1709d89e6c9cbf8d79c794ff3eb9f8800064291427"},
|
||||
{file = "pytz-2022.6.tar.gz", hash = "sha256:e89512406b793ca39f5971bc999cc538ce125c0e51c27941bef4568b460095e2"},
|
||||
{file = "pytz-2022.7-py2.py3-none-any.whl", hash = "sha256:93007def75ae22f7cd991c84e02d434876818661f8df9ad5df9e950ff4e52cfd"},
|
||||
{file = "pytz-2022.7.tar.gz", hash = "sha256:7ccfae7b4b2c067464a6733c6261673fdb8fd1be905460396b97a073e9fa683a"},
|
||||
]
|
||||
pyyaml = [
|
||||
{file = "PyYAML-6.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:d4db7c7aef085872ef65a8fd7d6d09a14ae91f691dec3e87ee5ee0539d516f53"},
|
||||
@@ -1721,8 +1725,8 @@ rfc3986 = [
|
||||
{file = "rfc3986-1.5.0.tar.gz", hash = "sha256:270aaf10d87d0d4e095063c65bf3ddbc6ee3d0b226328ce21e036f946e421835"},
|
||||
]
|
||||
rich = [
|
||||
{file = "rich-12.6.0-py3-none-any.whl", hash = "sha256:a4eb26484f2c82589bd9a17c73d32a010b1e29d89f1604cd9bf3a2097b81bb5e"},
|
||||
{file = "rich-12.6.0.tar.gz", hash = "sha256:ba3a3775974105c221d31141f2c116f4fd65c5ceb0698657a11e9f295ec93fd0"},
|
||||
{file = "rich-13.0.0-py3-none-any.whl", hash = "sha256:12b1d77ee7edf251b741531323f0d990f5f570a4e7c054d0bfb59fb7981ad977"},
|
||||
{file = "rich-13.0.0.tar.gz", hash = "sha256:3aa9eba7219b8c575c6494446a59f702552efe1aa261e7eeb95548fa586e1950"},
|
||||
]
|
||||
setuptools = [
|
||||
{file = "setuptools-65.6.3-py3-none-any.whl", hash = "sha256:57f6f22bde4e042978bcd50176fdb381d7c21a9efa4041202288d3737a0c6a54"},
|
||||
@@ -1741,60 +1745,63 @@ sniffio = [
|
||||
{file = "sniffio-1.3.0.tar.gz", hash = "sha256:e60305c5e5d314f5389259b7f22aaa33d8f7dee49763119234af3755c55b9101"},
|
||||
]
|
||||
syrupy = [
|
||||
{file = "syrupy-3.0.5-py3-none-any.whl", hash = "sha256:6dc0472cc690782b517d509a2854b5ca552a9851acf43b8a847cfa1aed6f6b01"},
|
||||
{file = "syrupy-3.0.5.tar.gz", hash = "sha256:928962a6c14abb2695be9a49b42a016a4e4abdb017a69104cde958f2faf01f98"},
|
||||
{file = "syrupy-3.0.6-py3-none-any.whl", hash = "sha256:9c18e22264026b34239bcc87ab7cc8d893eb17236ea7dae634217ea4f22a848d"},
|
||||
{file = "syrupy-3.0.6.tar.gz", hash = "sha256:583aa5ca691305c27902c3e29a1ce9da50ff9ab5f184c54b1dc124a16e4a6cf4"},
|
||||
]
|
||||
time-machine = [
|
||||
{file = "time-machine-2.8.2.tar.gz", hash = "sha256:2ff3cd145c381ac87b1c35400475a8f019b15dc2267861aad0466f55b49e7813"},
|
||||
{file = "time_machine-2.8.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:931f762053031ec76e81d5b97b276d6cbc3c9958fd281a3661a4e4dcd434ae4d"},
|
||||
{file = "time_machine-2.8.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:2bec6756c46d9e7ccfaeb177fde46da01af74ac9e5862dd9528e501d367f451e"},
|
||||
{file = "time_machine-2.8.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:959e63ad6980df1c36aefd19ae746e9b01c2be2f009199ec996fde0443b84de0"},
|
||||
{file = "time_machine-2.8.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:62db94b5ebe246949e6cedc57e7b96028f18ab9fb63b391d0e94d2e963702e30"},
|
||||
{file = "time_machine-2.8.2-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ad4b40d872fd025c9ee6924372d345b2788aac9df89eba5562e6464dde04cf99"},
|
||||
{file = "time_machine-2.8.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:b68259837b59c3bef30c5cff24d73228c5a5821342af624c78707fe297153221"},
|
||||
{file = "time_machine-2.8.2-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:46b4d2763c514d0036f7f46b23836d8fba0240ac1c50df588ca43193a59ee184"},
|
||||
{file = "time_machine-2.8.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:f416489bc8d0adb4bd63edcce5ba743b408f3c161ab0e1a65f9f904a6f9a06c0"},
|
||||
{file = "time_machine-2.8.2-cp310-cp310-win32.whl", hash = "sha256:94ab54c2062a362059a02e6df624151bfdcda79dab704ffee220bb31f8153e24"},
|
||||
{file = "time_machine-2.8.2-cp310-cp310-win_amd64.whl", hash = "sha256:f227819cfa27793e759811dabe6187e8f36dba6ac3a404516e17a81bb0216763"},
|
||||
{file = "time_machine-2.8.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:875eedfdf9cc59a9d119420b35c43a6d7ec08951a86581b4a4dbde47e6327256"},
|
||||
{file = "time_machine-2.8.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:01ee31fca1414d1198feff9eb7d062ca42aea9d1c01f63cdd6b2e0bb4f7479a9"},
|
||||
{file = "time_machine-2.8.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4387678c392cfb40c038016b04f5becb022bdc371ecabded751c2a116d2c0b5a"},
|
||||
{file = "time_machine-2.8.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2a42739702fd8ccbf4295aa6a0e5089f0ce125974e06ab157c6e4f4eadbc167c"},
|
||||
{file = "time_machine-2.8.2-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1963e1b9ea4891cbdd8a8f12cfb273dc7d3b0771ffe61238d688a7c2499445ef"},
|
||||
{file = "time_machine-2.8.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:7c0234c2fae05b4945b711d655af3487df34c466e184fbce7253dfc28c9980d1"},
|
||||
{file = "time_machine-2.8.2-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:19d01c6b6791c3ff45f8c82d149ac28292cf67242b1ace3dc1fdc0494edc111e"},
|
||||
{file = "time_machine-2.8.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:b05a2ca1045edd343fa07d2c55d57695c40b7af1e4c7df480d8e1976eb48a22f"},
|
||||
{file = "time_machine-2.8.2-cp311-cp311-win32.whl", hash = "sha256:71607d92fd23cd5fc5bcddb3ec6b91a6a1b07f7277e7e58dce0a5c1f67d229cd"},
|
||||
{file = "time_machine-2.8.2-cp311-cp311-win_amd64.whl", hash = "sha256:b9e4c58915b2136041027fb4d795e8844112683e550a9aed24ecde1de8a5a8f2"},
|
||||
{file = "time_machine-2.8.2-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:4b20f55d76cacb8b6f99c4161d8bfd6fc3be8d8ae003df2a79dbda9015d6ab85"},
|
||||
{file = "time_machine-2.8.2-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bb64b249df5c2958484706bdc095b326baf0f9c4a96c990d63a6e290680a8933"},
|
||||
{file = "time_machine-2.8.2-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:460f3d7344b64c906030013f6ca314017d7cbeb211e6c8c0efbdb3a2f5b168e3"},
|
||||
{file = "time_machine-2.8.2-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4ccd0e73e75f9cc624be08a2ae0305617ce7890d5b55f938ba336f086001ac66"},
|
||||
{file = "time_machine-2.8.2-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:8856b03574bc88f506534489562dfeb9c057485052817895413d8f33e7d03d28"},
|
||||
{file = "time_machine-2.8.2-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:3be539125dc815ff1f1ff05cd00f8839132a4b3a729809fa4a7de405f47cbd0b"},
|
||||
{file = "time_machine-2.8.2-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:8c3b356e9038abb78618169b86a2bc3488aa2faee27fa97c9cd8638972d60dfe"},
|
||||
{file = "time_machine-2.8.2-cp37-cp37m-win32.whl", hash = "sha256:bfbe53b80402ab3c93f112374d8624eb5e7f26395f01aea341bf91b4a512e36e"},
|
||||
{file = "time_machine-2.8.2-cp37-cp37m-win_amd64.whl", hash = "sha256:71917d38d2c34039a31ac0d63970f6009072a14c3a89169d165ca81130daf308"},
|
||||
{file = "time_machine-2.8.2-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:a3384f03776ffed86afdc2a807aa80fc656fbce6605e9b89261fc17302759290"},
|
||||
{file = "time_machine-2.8.2-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:6d084ccfbf30c658c23b1340583aa64afe4c6421b4d2ab3a84769915630e0d68"},
|
||||
{file = "time_machine-2.8.2-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4ed6c02afa3fc48af1fa256d5a3a18b63c3e36e7759fec8184e340e1b2f38f77"},
|
||||
{file = "time_machine-2.8.2-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c783769cc7b722e4b9df6015919a65952e58eb6fe884c198c1f56d58d883d0bc"},
|
||||
{file = "time_machine-2.8.2-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5da17b12c20d96b69bbe71d1e260e76c81072cded63539050d0f8aa26e9701dc"},
|
||||
{file = "time_machine-2.8.2-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:0213c32498190d7701cf90dc8a4f87d6d8571b856a16b474072e37f9e4daf896"},
|
||||
{file = "time_machine-2.8.2-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:c47caacc5a00656ee9e4ad4600ed46e036f233bbd93ed99c0da5f3dcec6a1a64"},
|
||||
{file = "time_machine-2.8.2-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:0e7950776b9087ba8e44f3602e5d695eaba853518c9963f41f3cba094000d87f"},
|
||||
{file = "time_machine-2.8.2-cp38-cp38-win32.whl", hash = "sha256:8bb1e68434a6c45bf2ef5d738420399803e7aa8211d77353e416d5043f82053e"},
|
||||
{file = "time_machine-2.8.2-cp38-cp38-win_amd64.whl", hash = "sha256:f67957dac20cca1171a7b63a8343c86f4f589e42f3c61bce687e77dd475e4d88"},
|
||||
{file = "time_machine-2.8.2-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:18d60cb6eb2bb896ef442628be783d2ddf374873caefb083cbc2b2ed19361157"},
|
||||
{file = "time_machine-2.8.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:82055dc781c4c9f6c97f3a349473ab44f1096da61a8cf1e72c105d12a39344ea"},
|
||||
{file = "time_machine-2.8.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bfaa1018ea5695a47f9536e1c7f7a112d55741162d8cdaa49801b3977f710666"},
|
||||
{file = "time_machine-2.8.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8f9c6bdead992708d3f88e9e337f08f9067e259eb6a7df23f94652cee7f08459"},
|
||||
{file = "time_machine-2.8.2-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0e6ba08062248fd9ba750ca997ed8699176d71b0d3aa525333efbd10e644f574"},
|
||||
{file = "time_machine-2.8.2-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:7d7233bb7a01d27e93fd8f687227fb93d314fb5048127844c248d76067b36e84"},
|
||||
{file = "time_machine-2.8.2-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:0cb22588e0c88239bad7ac5d593dc1119aacb7ac074e7aa2badc53583b92febf"},
|
||||
{file = "time_machine-2.8.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:ba71634179448df5dc6fb85d61e3956c8e33755ad3f76549dacb9c4854e88046"},
|
||||
{file = "time_machine-2.8.2-cp39-cp39-win32.whl", hash = "sha256:70ccbd8c5c4396fe4d60b0ceacef47f95e44f84a4d1d8cd5acdf9f81880e863a"},
|
||||
{file = "time_machine-2.8.2-cp39-cp39-win_amd64.whl", hash = "sha256:32f77a14ffbaeef8ae5e5bb86eb0e76057b56cb94f1f4990756c66047f8cac91"},
|
||||
{file = "time-machine-2.9.0.tar.gz", hash = "sha256:60222d43f6e93a926adc36ed37a54bc8e4d0d8d1c4d449096afcfe85086129c2"},
|
||||
{file = "time_machine-2.9.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:fd72c0b2e7443fff6e4481991742b72c17f73735e5fdd176406ca48df187a5c9"},
|
||||
{file = "time_machine-2.9.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:5657e0e6077cf15b37f0d8cf78e868113bbb3ecccc60064c40fe52d8166ca8b1"},
|
||||
{file = "time_machine-2.9.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bfa82614a98ecee70272bb6038d210b2ad7b2a6b8a678b400c34bdaf776802a7"},
|
||||
{file = "time_machine-2.9.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d4380bd6697cc7db3c9e6843f24779ac0550affa9d9a8e5f9e5d5cc139cb6583"},
|
||||
{file = "time_machine-2.9.0-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6211beee9f5dace08b1bbbb1fb09e34a69c52d87eea676729f14c8660481dff6"},
|
||||
{file = "time_machine-2.9.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:68ec8b83197db32c7a12da5f6b83c91271af3ed7f5dc122d2900a8de01dff9f0"},
|
||||
{file = "time_machine-2.9.0-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:c5dbc8b87cdc7be070a499f2bd1cd405c7f647abeb3447dfd397639df040bc64"},
|
||||
{file = "time_machine-2.9.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:948ca690f9770ad4a93fa183061c11346505598f5f0b721965bc85ec83bb103d"},
|
||||
{file = "time_machine-2.9.0-cp310-cp310-win32.whl", hash = "sha256:f92d5d2eb119a6518755c4c9170112094c706d1c604460f50afc1308eeb97f0e"},
|
||||
{file = "time_machine-2.9.0-cp310-cp310-win_amd64.whl", hash = "sha256:cb51432652ad663b4cbd631c73c90f9e94f463382b86c0b6b854173700512a70"},
|
||||
{file = "time_machine-2.9.0-cp310-cp310-win_arm64.whl", hash = "sha256:8976b7b1f7de13598b655d459f5640f90f3cd587283e1b914a22e45946c5485b"},
|
||||
{file = "time_machine-2.9.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:6463e302c96eb8c691c4340e281bd54327a213b924fa189aea81accf7e7f78df"},
|
||||
{file = "time_machine-2.9.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:6b632d60aa0883dc7292ac3d32050604d26ec2bbd5c4d42fb0de3b4ef17343e2"},
|
||||
{file = "time_machine-2.9.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d329578abe47ce95baa015ef3825acebb1b73b5fa6f818fdf2d4685a00ca457f"},
|
||||
{file = "time_machine-2.9.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9ba5fc2655749066d68986de8368984dad4082db2fbeade78f40506dc5b65672"},
|
||||
{file = "time_machine-2.9.0-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:49df5eea2160068e5b2bf28c22fc4c5aea00862ad88ddc3b62fc0f0683e97538"},
|
||||
{file = "time_machine-2.9.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:8830510adbf0a231184da277db9de1d55ef93ed228a575d217aaee295505abf1"},
|
||||
{file = "time_machine-2.9.0-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:b16a2129f9146faa080bfd1b53447761f7386ec5c72890c827a65f33ab200336"},
|
||||
{file = "time_machine-2.9.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:a2cf80e5deaaa68c6cefb25303a4c870490b4e7591ed8e2435a65728920bc097"},
|
||||
{file = "time_machine-2.9.0-cp311-cp311-win32.whl", hash = "sha256:fe013942ab7f3241fcbe66ee43222d47f499d1e0cb69e913791c52e638ddd7f0"},
|
||||
{file = "time_machine-2.9.0-cp311-cp311-win_amd64.whl", hash = "sha256:1d0ab46ce8a60baf9d86525694bf698fed9efefd22b8cbe1ca3e74abbb3239e1"},
|
||||
{file = "time_machine-2.9.0-cp311-cp311-win_arm64.whl", hash = "sha256:4f3755d9342ca1f1019418db52072272dfd75eb818fa4726fa8aabe208b38c26"},
|
||||
{file = "time_machine-2.9.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:9ee553f7732fa51e019e3329a6984593184c4e0410af1e73d91ce38a5d4b34ab"},
|
||||
{file = "time_machine-2.9.0-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:359c806e5b9a7a3c73dbb808d19dca297f5504a5eefdc5d031db8d918f43e364"},
|
||||
{file = "time_machine-2.9.0-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8e2a90b8300812d8d774f2d2fc216fec3c7d94132ac589e062489c395061f16c"},
|
||||
{file = "time_machine-2.9.0-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:36dde844d28549929fab171d683c28a8db1c206547bcf6b7aca77319847d2046"},
|
||||
{file = "time_machine-2.9.0-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:728263611d7940fda34d21573bd2b3f1491bdb52dbf75c5fe6c226dfe4655201"},
|
||||
{file = "time_machine-2.9.0-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:8bcc86b5a07ea9745f26dfad958dde0a4f56748c2ae0c9a96200a334d1b55055"},
|
||||
{file = "time_machine-2.9.0-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:0b9c36240876622b7f2f9e11bf72f100857c0a1e1a59af2da3d5067efea62c37"},
|
||||
{file = "time_machine-2.9.0-cp37-cp37m-win32.whl", hash = "sha256:eaf334477bc0a9283d5150a56be8670a07295ef676e5b5a7f086952929d1a56b"},
|
||||
{file = "time_machine-2.9.0-cp37-cp37m-win_amd64.whl", hash = "sha256:8e797e5a2a99d1b237183e52251abfc1ad85c376278b39d1aca76a451a97861a"},
|
||||
{file = "time_machine-2.9.0-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:69898aed9b2315a90f5855343d9aa34d05fa06032e2e3bb14f2528941ec89dc1"},
|
||||
{file = "time_machine-2.9.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:c01dbc3671d0649023daf623e952f9f0b4d904d57ab546d6d35a4aeb14915e8d"},
|
||||
{file = "time_machine-2.9.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2f080f6f7ca8cfca43bc5639288aebd0a273b4b5bd0acff609c2318728b13a18"},
|
||||
{file = "time_machine-2.9.0-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8670cb5cfda99f483d60de6ce56ceb0ec5d359193e79e4688e1c3c9db3937383"},
|
||||
{file = "time_machine-2.9.0-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f97ed8bc5b517844a71030f74e9561de92f4902c306e6ccc8331a5b0c8dd0e00"},
|
||||
{file = "time_machine-2.9.0-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:bdbe785e046d124f73cca603ee37d5fae0b15dc4c13702488ad19de56aae08ba"},
|
||||
{file = "time_machine-2.9.0-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:fcdef7687aed5c4331c9808f4a414a41987441c3e7a2ba554e4dccfa4218e788"},
|
||||
{file = "time_machine-2.9.0-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:f6e79643368828d4651146a486be5a662846ac223ab5e2c73ddd519acfcc243c"},
|
||||
{file = "time_machine-2.9.0-cp38-cp38-win32.whl", hash = "sha256:bb15b2b79b00d3f6cf7d62096f5e782fa740ecedfe0540c09f1d1e4d3d7b81ba"},
|
||||
{file = "time_machine-2.9.0-cp38-cp38-win_amd64.whl", hash = "sha256:3ff5148e2e73392db8418a1fe2f0b06f4a0e76772933502fb61e4c3000b5324e"},
|
||||
{file = "time_machine-2.9.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:8367fd03f2d7349c7fc20f14de186974eaca2502c64b948212de663742c8fd11"},
|
||||
{file = "time_machine-2.9.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:4b55654aaeaba380fcd6c004b8ada2978fdd4ece1e61e6b9717c6d4cc7fbbcd9"},
|
||||
{file = "time_machine-2.9.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ae4e3f02ab5dabb35adca606237c7e1a515c86d69c0b7092bbe0e1cfe5cffc61"},
|
||||
{file = "time_machine-2.9.0-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:010a58a8de1120308befae19e6c9de2ef5ca5206635cea33cb264998725cc027"},
|
||||
{file = "time_machine-2.9.0-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b32addbf56639a9a8261fb62f8ea83473447671c83ca2c017ab1eabf4841157f"},
|
||||
{file = "time_machine-2.9.0-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:372a97da01db89533d2f4ce50bbd908e5c56df7b8cfd6a005b177d0b14dc2938"},
|
||||
{file = "time_machine-2.9.0-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:b8faff03231ee55d5a216ce3e9171c5205459f866f54d4b5ee8aa1d860e4ce11"},
|
||||
{file = "time_machine-2.9.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:748d701228e646c224f2adfa6a11b986cd4aa90f1b8c13ef4534a3919c796bc0"},
|
||||
{file = "time_machine-2.9.0-cp39-cp39-win32.whl", hash = "sha256:d79d374e32488c76cdb06fbdd4464083aeaa715ddca3e864bac7c7760eb03729"},
|
||||
{file = "time_machine-2.9.0-cp39-cp39-win_amd64.whl", hash = "sha256:cc6bf01211b5ea40f633d5502c5aa495b415ebaff66e041820997dae70a508e1"},
|
||||
{file = "time_machine-2.9.0-cp39-cp39-win_arm64.whl", hash = "sha256:3ce445775fcf7cb4040cfdba4b7c4888e7fd98bbcccfe1dc3fa8a798ed1f1d24"},
|
||||
]
|
||||
toml = [
|
||||
{file = "toml-0.10.2-py2.py3-none-any.whl", hash = "sha256:806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b"},
|
||||
@@ -1847,34 +1854,34 @@ virtualenv = [
|
||||
{file = "virtualenv-20.17.1.tar.gz", hash = "sha256:f8b927684efc6f1cc206c9db297a570ab9ad0e51c16fa9e45487d36d1905c058"},
|
||||
]
|
||||
watchdog = [
|
||||
{file = "watchdog-2.2.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:ed91c3ccfc23398e7aa9715abf679d5c163394b8cad994f34f156d57a7c163dc"},
|
||||
{file = "watchdog-2.2.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:76a2743402b794629a955d96ea2e240bd0e903aa26e02e93cd2d57b33900962b"},
|
||||
{file = "watchdog-2.2.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:920a4bda7daa47545c3201a3292e99300ba81ca26b7569575bd086c865889090"},
|
||||
{file = "watchdog-2.2.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:ceaa9268d81205876bedb1069f9feab3eccddd4b90d9a45d06a0df592a04cae9"},
|
||||
{file = "watchdog-2.2.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1893d425ef4fb4f129ee8ef72226836619c2950dd0559bba022b0818c63a7b60"},
|
||||
{file = "watchdog-2.2.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:9e99c1713e4436d2563f5828c8910e5ff25abd6ce999e75f15c15d81d41980b6"},
|
||||
{file = "watchdog-2.2.0-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:a5bd9e8656d07cae89ac464ee4bcb6f1b9cecbedc3bf1334683bed3d5afd39ba"},
|
||||
{file = "watchdog-2.2.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:3a048865c828389cb06c0bebf8a883cec3ae58ad3e366bcc38c61d8455a3138f"},
|
||||
{file = "watchdog-2.2.0-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:e722755d995035dd32177a9c633d158f2ec604f2a358b545bba5bed53ab25bca"},
|
||||
{file = "watchdog-2.2.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:af4b5c7ba60206759a1d99811b5938ca666ea9562a1052b410637bb96ff97512"},
|
||||
{file = "watchdog-2.2.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:619d63fa5be69f89ff3a93e165e602c08ed8da402ca42b99cd59a8ec115673e1"},
|
||||
{file = "watchdog-2.2.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:1f2b0665c57358ce9786f06f5475bc083fea9d81ecc0efa4733fd0c320940a37"},
|
||||
{file = "watchdog-2.2.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:441024df19253bb108d3a8a5de7a186003d68564084576fecf7333a441271ef7"},
|
||||
{file = "watchdog-2.2.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:1a410dd4d0adcc86b4c71d1317ba2ea2c92babaf5b83321e4bde2514525544d5"},
|
||||
{file = "watchdog-2.2.0-pp37-pypy37_pp73-macosx_10_9_x86_64.whl", hash = "sha256:28704c71afdb79c3f215c90231e41c52b056ea880b6be6cee035c6149d658ed1"},
|
||||
{file = "watchdog-2.2.0-pp38-pypy38_pp73-macosx_10_9_x86_64.whl", hash = "sha256:2ac0bd7c206bb6df78ef9e8ad27cc1346f2b41b1fef610395607319cdab89bc1"},
|
||||
{file = "watchdog-2.2.0-pp39-pypy39_pp73-macosx_10_9_x86_64.whl", hash = "sha256:27e49268735b3c27310883012ab3bd86ea0a96dcab90fe3feb682472e30c90f3"},
|
||||
{file = "watchdog-2.2.0-py3-none-manylinux2014_aarch64.whl", hash = "sha256:2af1a29fd14fc0a87fb6ed762d3e1ae5694dcde22372eebba50e9e5be47af03c"},
|
||||
{file = "watchdog-2.2.0-py3-none-manylinux2014_armv7l.whl", hash = "sha256:c7bd98813d34bfa9b464cf8122e7d4bec0a5a427399094d2c17dd5f70d59bc61"},
|
||||
{file = "watchdog-2.2.0-py3-none-manylinux2014_i686.whl", hash = "sha256:56fb3f40fc3deecf6e518303c7533f5e2a722e377b12507f6de891583f1b48aa"},
|
||||
{file = "watchdog-2.2.0-py3-none-manylinux2014_ppc64.whl", hash = "sha256:74535e955359d79d126885e642d3683616e6d9ab3aae0e7dcccd043bd5a3ff4f"},
|
||||
{file = "watchdog-2.2.0-py3-none-manylinux2014_ppc64le.whl", hash = "sha256:cf05e6ff677b9655c6e9511d02e9cc55e730c4e430b7a54af9c28912294605a4"},
|
||||
{file = "watchdog-2.2.0-py3-none-manylinux2014_s390x.whl", hash = "sha256:d6ae890798a3560688b441ef086bb66e87af6b400a92749a18b856a134fc0318"},
|
||||
{file = "watchdog-2.2.0-py3-none-manylinux2014_x86_64.whl", hash = "sha256:e5aed2a700a18c194c39c266900d41f3db0c1ebe6b8a0834b9995c835d2ca66e"},
|
||||
{file = "watchdog-2.2.0-py3-none-win32.whl", hash = "sha256:d0fb5f2b513556c2abb578c1066f5f467d729f2eb689bc2db0739daf81c6bb7e"},
|
||||
{file = "watchdog-2.2.0-py3-none-win_amd64.whl", hash = "sha256:1f8eca9d294a4f194ce9df0d97d19b5598f310950d3ac3dd6e8d25ae456d4c8a"},
|
||||
{file = "watchdog-2.2.0-py3-none-win_ia64.whl", hash = "sha256:ad0150536469fa4b693531e497ffe220d5b6cd76ad2eda474a5e641ee204bbb6"},
|
||||
{file = "watchdog-2.2.0.tar.gz", hash = "sha256:83cf8bc60d9c613b66a4c018051873d6273d9e45d040eed06d6a96241bd8ec01"},
|
||||
{file = "watchdog-2.2.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:a09483249d25cbdb4c268e020cb861c51baab2d1affd9a6affc68ffe6a231260"},
|
||||
{file = "watchdog-2.2.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:5100eae58133355d3ca6c1083a33b81355c4f452afa474c2633bd2fbbba398b3"},
|
||||
{file = "watchdog-2.2.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e618a4863726bc7a3c64f95c218437f3349fb9d909eb9ea3a1ed3b567417c661"},
|
||||
{file = "watchdog-2.2.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:102a60093090fc3ff76c983367b19849b7cc24ec414a43c0333680106e62aae1"},
|
||||
{file = "watchdog-2.2.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:748ca797ff59962e83cc8e4b233f87113f3cf247c23e6be58b8a2885c7337aa3"},
|
||||
{file = "watchdog-2.2.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:6ccd8d84b9490a82b51b230740468116b8205822ea5fdc700a553d92661253a3"},
|
||||
{file = "watchdog-2.2.1-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:6e01d699cd260d59b84da6bda019dce0a3353e3fcc774408ae767fe88ee096b7"},
|
||||
{file = "watchdog-2.2.1-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:8586d98c494690482c963ffb24c49bf9c8c2fe0589cec4dc2f753b78d1ec301d"},
|
||||
{file = "watchdog-2.2.1-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:adaf2ece15f3afa33a6b45f76b333a7da9256e1360003032524d61bdb4c422ae"},
|
||||
{file = "watchdog-2.2.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:83a7cead445008e880dbde833cb9e5cc7b9a0958edb697a96b936621975f15b9"},
|
||||
{file = "watchdog-2.2.1-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:f8ac23ff2c2df4471a61af6490f847633024e5aa120567e08d07af5718c9d092"},
|
||||
{file = "watchdog-2.2.1-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:d0f29fd9f3f149a5277929de33b4f121a04cf84bb494634707cfa8ea8ae106a8"},
|
||||
{file = "watchdog-2.2.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:967636031fa4c4955f0f3f22da3c5c418aa65d50908d31b73b3b3ffd66d60640"},
|
||||
{file = "watchdog-2.2.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:96cbeb494e6cbe3ae6aacc430e678ce4b4dd3ae5125035f72b6eb4e5e9eb4f4e"},
|
||||
{file = "watchdog-2.2.1-pp37-pypy37_pp73-macosx_10_9_x86_64.whl", hash = "sha256:61fdb8e9c57baf625e27e1420e7ca17f7d2023929cd0065eb79c83da1dfbeacd"},
|
||||
{file = "watchdog-2.2.1-pp38-pypy38_pp73-macosx_10_9_x86_64.whl", hash = "sha256:4cb5ecc332112017fbdb19ede78d92e29a8165c46b68a0b8ccbd0a154f196d5e"},
|
||||
{file = "watchdog-2.2.1-pp39-pypy39_pp73-macosx_10_9_x86_64.whl", hash = "sha256:a480d122740debf0afac4ddd583c6c0bb519c24f817b42ed6f850e2f6f9d64a8"},
|
||||
{file = "watchdog-2.2.1-py3-none-manylinux2014_aarch64.whl", hash = "sha256:978a1aed55de0b807913b7482d09943b23a2d634040b112bdf31811a422f6344"},
|
||||
{file = "watchdog-2.2.1-py3-none-manylinux2014_armv7l.whl", hash = "sha256:8c28c23972ec9c524967895ccb1954bc6f6d4a557d36e681a36e84368660c4ce"},
|
||||
{file = "watchdog-2.2.1-py3-none-manylinux2014_i686.whl", hash = "sha256:c27d8c1535fd4474e40a4b5e01f4ba6720bac58e6751c667895cbc5c8a7af33c"},
|
||||
{file = "watchdog-2.2.1-py3-none-manylinux2014_ppc64.whl", hash = "sha256:d6b87477752bd86ac5392ecb9eeed92b416898c30bd40c7e2dd03c3146105646"},
|
||||
{file = "watchdog-2.2.1-py3-none-manylinux2014_ppc64le.whl", hash = "sha256:cece1aa596027ff56369f0b50a9de209920e1df9ac6d02c7f9e5d8162eb4f02b"},
|
||||
{file = "watchdog-2.2.1-py3-none-manylinux2014_s390x.whl", hash = "sha256:8b5cde14e5c72b2df5d074774bdff69e9b55da77e102a91f36ef26ca35f9819c"},
|
||||
{file = "watchdog-2.2.1-py3-none-manylinux2014_x86_64.whl", hash = "sha256:e038be858425c4f621900b8ff1a3a1330d9edcfeaa1c0468aeb7e330fb87693e"},
|
||||
{file = "watchdog-2.2.1-py3-none-win32.whl", hash = "sha256:bc43c1b24d2f86b6e1cc15f68635a959388219426109233e606517ff7d0a5a73"},
|
||||
{file = "watchdog-2.2.1-py3-none-win_amd64.whl", hash = "sha256:17f1708f7410af92ddf591e94ae71a27a13974559e72f7e9fde3ec174b26ba2e"},
|
||||
{file = "watchdog-2.2.1-py3-none-win_ia64.whl", hash = "sha256:195ab1d9d611a4c1e5311cbf42273bc541e18ea8c32712f2fb703cfc6ff006f9"},
|
||||
{file = "watchdog-2.2.1.tar.gz", hash = "sha256:cdcc23c9528601a8a293eb4369cbd14f6b4f34f07ae8769421252e9c22718b6f"},
|
||||
]
|
||||
yarl = [
|
||||
{file = "yarl-1.8.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:bb81f753c815f6b8e2ddd2eef3c855cf7da193b82396ac013c661aaa6cc6b0a5"},
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[tool.poetry]
|
||||
name = "textual"
|
||||
version = "0.6.0"
|
||||
version = "0.9.1"
|
||||
homepage = "https://github.com/Textualize/textual"
|
||||
description = "Modern Text User Interface framework"
|
||||
authors = ["Will McGugan <will@textualize.io>"]
|
||||
@@ -30,7 +30,7 @@ textual = "textual.cli.cli:run"
|
||||
|
||||
[tool.poetry.dependencies]
|
||||
python = "^3.7"
|
||||
rich = "^12.6.0"
|
||||
rich = ">12.6.0"
|
||||
#rich = {path="../rich", develop=true}
|
||||
importlib-metadata = "^4.11.3"
|
||||
typing-extensions = { version = "^4.0.0", python = "<3.10" }
|
||||
|
||||
18
questions/README.md
Normal file
@@ -0,0 +1,18 @@
|
||||
|
||||
# Questions
|
||||
|
||||
Your questions should go in this directory.
|
||||
|
||||
Question files should be named with the extension ".question.md".
|
||||
|
||||
To build the faq, install [faqtory](https://github.com/willmcgugan/faqtory) if you haven't already:
|
||||
|
||||
```
|
||||
pip install faqtory
|
||||
```
|
||||
|
||||
The run the following from the top of the repository:
|
||||
|
||||
```
|
||||
faqtory build
|
||||
```
|
||||
34
questions/align-center-middle.question.md
Normal file
@@ -0,0 +1,34 @@
|
||||
---
|
||||
title: "How do I center a widget in a screen?"
|
||||
alt_titles:
|
||||
- "centre a widget"
|
||||
- "center a control"
|
||||
- "centre a control"
|
||||
---
|
||||
|
||||
To center a widget within a container use
|
||||
[`align`](https://textual.textualize.io/styles/align/). But remember that
|
||||
`align` works on the *children* of a container, it isn't something you use
|
||||
on the child you want centered.
|
||||
|
||||
For example, here's an app that shows a `Button` in the middle of a
|
||||
`Screen`:
|
||||
|
||||
```python
|
||||
from textual.app import App, ComposeResult
|
||||
from textual.widgets import Button
|
||||
|
||||
class ButtonApp(App):
|
||||
|
||||
CSS = """
|
||||
Screen {
|
||||
align: center middle;
|
||||
}
|
||||
"""
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
yield Button("PUSH ME!")
|
||||
|
||||
if __name__ == "__main__":
|
||||
ButtonApp().run()
|
||||
```
|
||||
14
questions/compose-result.question.md
Normal file
@@ -0,0 +1,14 @@
|
||||
---
|
||||
title: "How can I fix ImportError cannot import name ComposeResult from textual.app ?"
|
||||
alt_titles:
|
||||
- "Can't import ComposeResult"
|
||||
- "Error about missing ComposeResult from textual.app"
|
||||
---
|
||||
|
||||
You likely have an older version of Textual. You can install the latest version by adding the `-U` switch which will force pip to upgrade.
|
||||
|
||||
The following should do it:
|
||||
|
||||
```
|
||||
pip install "textual[dev]" -U
|
||||
```
|
||||
11
questions/images.question.md
Normal file
@@ -0,0 +1,11 @@
|
||||
---
|
||||
title: "Does Textual support images?"
|
||||
alt_titles:
|
||||
- "Can Textual display PNG / SVG files?"
|
||||
- "Render images"
|
||||
|
||||
---
|
||||
|
||||
Textual doesn't have built in support for images yet, but it is on the [Roadmap](https://textual.textualize.io/roadmap/).
|
||||
|
||||
See also the [rich-pixels](https://github.com/darrenburns/rich-pixels) project for a Rich renderable for images that works with Textual.
|
||||
38
questions/pass-args-to-app.question.md
Normal file
@@ -0,0 +1,38 @@
|
||||
---
|
||||
title: "How do I pass arguments to an app?"
|
||||
alt_titles:
|
||||
- "pass arguments to an application"
|
||||
- "pass parameters to an app"
|
||||
- "pass parameters to an application"
|
||||
---
|
||||
|
||||
When creating your `App` class, override `__init__` as you would when
|
||||
inheriting normally. For example:
|
||||
|
||||
```python
|
||||
from textual.app import App, ComposeResult
|
||||
from textual.widgets import Static
|
||||
|
||||
class Greetings(App[None]):
|
||||
|
||||
def __init__(self, greeting: str="Hello", to_greet: str="World") -> None:
|
||||
self.greeting = greeting
|
||||
self.to_greet = to_greet
|
||||
super().__init__()
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
yield Static(f"{self.greeting}, {self.to_greet}")
|
||||
```
|
||||
|
||||
Then the app can be run, passing in various arguments; for example:
|
||||
|
||||
```python
|
||||
# Running with default arguments.
|
||||
Greetings().run()
|
||||
|
||||
# Running with a keyword arguyment.
|
||||
Greetings(to_greet="davep").run()
|
||||
|
||||
# Running with both positional arguments.
|
||||
Greetings("Well hello", "there").run()
|
||||
```
|
||||
@@ -14,13 +14,14 @@ where the overhead of the cache is a small fraction of the total processing time
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from threading import Lock
|
||||
from typing import Dict, Generic, KeysView, TypeVar, overload
|
||||
|
||||
CacheKey = TypeVar("CacheKey")
|
||||
CacheValue = TypeVar("CacheValue")
|
||||
DefaultValue = TypeVar("DefaultValue")
|
||||
|
||||
__all__ = ["LRUCache", "FIFOCache"]
|
||||
|
||||
|
||||
class LRUCache(Generic[CacheKey, CacheValue]):
|
||||
"""
|
||||
@@ -37,12 +38,22 @@ class LRUCache(Generic[CacheKey, CacheValue]):
|
||||
|
||||
"""
|
||||
|
||||
__slots__ = [
|
||||
"_maxsize",
|
||||
"_cache",
|
||||
"_full",
|
||||
"_head",
|
||||
"hits",
|
||||
"misses",
|
||||
]
|
||||
|
||||
def __init__(self, maxsize: int) -> None:
|
||||
self._maxsize = maxsize
|
||||
self._cache: Dict[CacheKey, list[object]] = {}
|
||||
self._full = False
|
||||
self._head: list[object] = []
|
||||
self._lock = Lock()
|
||||
self.hits = 0
|
||||
self.misses = 0
|
||||
super().__init__()
|
||||
|
||||
@property
|
||||
@@ -60,6 +71,11 @@ class LRUCache(Generic[CacheKey, CacheValue]):
|
||||
def __len__(self) -> int:
|
||||
return len(self._cache)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return (
|
||||
f"<LRUCache maxsize={self._maxsize} hits={self.hits} misses={self.misses}>"
|
||||
)
|
||||
|
||||
def grow(self, maxsize: int) -> None:
|
||||
"""Grow the maximum size to at least `maxsize` elements.
|
||||
|
||||
@@ -70,10 +86,9 @@ class LRUCache(Generic[CacheKey, CacheValue]):
|
||||
|
||||
def clear(self) -> None:
|
||||
"""Clear the cache."""
|
||||
with self._lock:
|
||||
self._cache.clear()
|
||||
self._full = False
|
||||
self._head = []
|
||||
self._cache.clear()
|
||||
self._full = False
|
||||
self._head = []
|
||||
|
||||
def keys(self) -> KeysView[CacheKey]:
|
||||
"""Get cache keys."""
|
||||
@@ -87,29 +102,28 @@ class LRUCache(Generic[CacheKey, CacheValue]):
|
||||
key (CacheKey): Key.
|
||||
value (CacheValue): Value.
|
||||
"""
|
||||
with self._lock:
|
||||
link = self._cache.get(key)
|
||||
if link is None:
|
||||
head = self._head
|
||||
if not head:
|
||||
# First link references itself
|
||||
self._head[:] = [head, head, key, value]
|
||||
else:
|
||||
# Add a new root to the beginning
|
||||
self._head = [head[0], head, key, value]
|
||||
# Updated references on previous root
|
||||
head[0][1] = self._head # type: ignore[index]
|
||||
head[0] = self._head
|
||||
self._cache[key] = self._head
|
||||
link = self._cache.get(key)
|
||||
if link is None:
|
||||
head = self._head
|
||||
if not head:
|
||||
# First link references itself
|
||||
self._head[:] = [head, head, key, value]
|
||||
else:
|
||||
# Add a new root to the beginning
|
||||
self._head = [head[0], head, key, value]
|
||||
# Updated references on previous root
|
||||
head[0][1] = self._head # type: ignore[index]
|
||||
head[0] = self._head
|
||||
self._cache[key] = self._head
|
||||
|
||||
if self._full or len(self._cache) > self._maxsize:
|
||||
# Cache is full, we need to evict the oldest one
|
||||
self._full = True
|
||||
head = self._head
|
||||
last = head[0]
|
||||
last[0][1] = head # type: ignore[index]
|
||||
head[0] = last[0] # type: ignore[index]
|
||||
del self._cache[last[2]] # type: ignore[index]
|
||||
if self._full or len(self._cache) > self._maxsize:
|
||||
# Cache is full, we need to evict the oldest one
|
||||
self._full = True
|
||||
head = self._head
|
||||
last = head[0]
|
||||
last[0][1] = head # type: ignore[index]
|
||||
head[0] = last[0] # type: ignore[index]
|
||||
del self._cache[last[2]] # type: ignore[index]
|
||||
|
||||
__setitem__ = set
|
||||
|
||||
@@ -135,31 +149,136 @@ class LRUCache(Generic[CacheKey, CacheValue]):
|
||||
"""
|
||||
link = self._cache.get(key)
|
||||
if link is None:
|
||||
self.misses += 1
|
||||
return default
|
||||
with self._lock:
|
||||
if link is not self._head:
|
||||
# Remove link from list
|
||||
link[0][1] = link[1] # type: ignore[index]
|
||||
link[1][0] = link[0] # type: ignore[index]
|
||||
head = self._head
|
||||
# Move link to head of list
|
||||
link[0] = head[0]
|
||||
link[1] = head
|
||||
self._head = head[0][1] = head[0] = link # type: ignore[index]
|
||||
|
||||
return link[3] # type: ignore[return-value]
|
||||
if link is not self._head:
|
||||
# Remove link from list
|
||||
link[0][1] = link[1] # type: ignore[index]
|
||||
link[1][0] = link[0] # type: ignore[index]
|
||||
head = self._head
|
||||
# Move link to head of list
|
||||
link[0] = head[0]
|
||||
link[1] = head
|
||||
self._head = head[0][1] = head[0] = link # type: ignore[index]
|
||||
self.hits += 1
|
||||
return link[3] # type: ignore[return-value]
|
||||
|
||||
def __getitem__(self, key: CacheKey) -> CacheValue:
|
||||
link = self._cache[key]
|
||||
with self._lock:
|
||||
if link is not self._head:
|
||||
link[0][1] = link[1] # type: ignore[index]
|
||||
link[1][0] = link[0] # type: ignore[index]
|
||||
head = self._head
|
||||
link[0] = head[0]
|
||||
link[1] = head
|
||||
self._head = head[0][1] = head[0] = link # type: ignore[index]
|
||||
return link[3] # type: ignore[return-value]
|
||||
link = self._cache.get(key)
|
||||
if link is None:
|
||||
self.misses += 1
|
||||
raise KeyError(key)
|
||||
if link is not self._head:
|
||||
link[0][1] = link[1] # type: ignore[index]
|
||||
link[1][0] = link[0] # type: ignore[index]
|
||||
head = self._head
|
||||
link[0] = head[0]
|
||||
link[1] = head
|
||||
self._head = head[0][1] = head[0] = link # type: ignore[index]
|
||||
self.hits += 1
|
||||
return link[3] # type: ignore[return-value]
|
||||
|
||||
def __contains__(self, key: CacheKey) -> bool:
|
||||
return key in self._cache
|
||||
|
||||
|
||||
class FIFOCache(Generic[CacheKey, CacheValue]):
|
||||
"""A simple cache that discards the first added key when full (First In First Out).
|
||||
|
||||
This has a lower overhead than LRUCache, but won't manage a working set as efficiently.
|
||||
It is most suitable for a cache with a relatively low maximum size that is not expected to
|
||||
do many lookups.
|
||||
|
||||
Args:
|
||||
maxsize (int): Maximum size of the cache.
|
||||
"""
|
||||
|
||||
__slots__ = [
|
||||
"_maxsize",
|
||||
"_cache",
|
||||
"hits",
|
||||
"misses",
|
||||
]
|
||||
|
||||
def __init__(self, maxsize: int) -> None:
|
||||
self._maxsize = maxsize
|
||||
self._cache: dict[CacheKey, CacheValue] = {}
|
||||
self.hits = 0
|
||||
self.misses = 0
|
||||
|
||||
def __bool__(self) -> bool:
|
||||
return bool(self._cache)
|
||||
|
||||
def __len__(self) -> int:
|
||||
return len(self._cache)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return (
|
||||
f"<FIFOCache maxsize={self._maxsize} hits={self.hits} misses={self.misses}>"
|
||||
)
|
||||
|
||||
def clear(self) -> None:
|
||||
"""Clear the cache."""
|
||||
self._cache.clear()
|
||||
|
||||
def keys(self) -> KeysView[CacheKey]:
|
||||
"""Get cache keys."""
|
||||
# Mostly for tests
|
||||
return self._cache.keys()
|
||||
|
||||
def set(self, key: CacheKey, value: CacheValue) -> None:
|
||||
"""Set a value.
|
||||
|
||||
Args:
|
||||
key (CacheKey): Key.
|
||||
value (CacheValue): Value.
|
||||
"""
|
||||
if key not in self._cache and len(self._cache) >= self._maxsize:
|
||||
for first_key in self._cache:
|
||||
self._cache.pop(first_key)
|
||||
break
|
||||
self._cache[key] = value
|
||||
|
||||
__setitem__ = set
|
||||
|
||||
@overload
|
||||
def get(self, key: CacheKey) -> CacheValue | None:
|
||||
...
|
||||
|
||||
@overload
|
||||
def get(self, key: CacheKey, default: DefaultValue) -> CacheValue | DefaultValue:
|
||||
...
|
||||
|
||||
def get(
|
||||
self, key: CacheKey, default: DefaultValue | None = None
|
||||
) -> CacheValue | DefaultValue | None:
|
||||
"""Get a value from the cache, or return a default if the key is not present.
|
||||
|
||||
Args:
|
||||
key (CacheKey): Key
|
||||
default (Optional[DefaultValue], optional): Default to return if key is not present. Defaults to None.
|
||||
|
||||
Returns:
|
||||
Union[CacheValue, Optional[DefaultValue]]: Either the value or a default.
|
||||
"""
|
||||
try:
|
||||
result = self._cache[key]
|
||||
except KeyError:
|
||||
self.misses += 1
|
||||
return default
|
||||
else:
|
||||
self.hits += 1
|
||||
return result
|
||||
|
||||
def __getitem__(self, key: CacheKey) -> CacheValue:
|
||||
try:
|
||||
result = self._cache[key]
|
||||
except KeyError:
|
||||
self.misses += 1
|
||||
raise KeyError(key) from None
|
||||
else:
|
||||
self.hits += 1
|
||||
return result
|
||||
|
||||
def __contains__(self, key: CacheKey) -> bool:
|
||||
return key in self._cache
|
||||
|
||||
@@ -13,7 +13,6 @@ without having to render the entire screen.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from itertools import chain
|
||||
from operator import itemgetter
|
||||
from typing import TYPE_CHECKING, Iterable, NamedTuple, cast
|
||||
|
||||
@@ -26,10 +25,11 @@ from rich.style import Style
|
||||
from . import errors
|
||||
from ._cells import cell_len
|
||||
from ._loop import loop_last
|
||||
from ._types import Lines
|
||||
from .strip import Strip
|
||||
from ._typing import TypeAlias
|
||||
from .geometry import NULL_OFFSET, Offset, Region, Size
|
||||
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .widget import Widget
|
||||
|
||||
@@ -66,8 +66,8 @@ CompositorMap: TypeAlias = "dict[Widget, MapGeometry]"
|
||||
class LayoutUpdate:
|
||||
"""A renderable containing the result of a render for a given region."""
|
||||
|
||||
def __init__(self, lines: Lines, region: Region) -> None:
|
||||
self.lines = lines
|
||||
def __init__(self, strips: list[Strip], region: Region) -> None:
|
||||
self.strips = strips
|
||||
self.region = region
|
||||
|
||||
def __rich_console__(
|
||||
@@ -76,7 +76,7 @@ class LayoutUpdate:
|
||||
x = self.region.x
|
||||
new_line = Segment.line()
|
||||
move_to = Control.move_to
|
||||
for last, (y, line) in loop_last(enumerate(self.lines, self.region.y)):
|
||||
for last, (y, line) in loop_last(enumerate(self.strips, self.region.y)):
|
||||
yield move_to(x, y)
|
||||
yield from line
|
||||
if not last:
|
||||
@@ -92,7 +92,7 @@ class ChopsUpdate:
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
chops: list[dict[int, list[Segment] | None]],
|
||||
chops: list[dict[int, Strip | None]],
|
||||
spans: list[tuple[int, int, int]],
|
||||
chop_ends: list[list[int]],
|
||||
) -> None:
|
||||
@@ -117,13 +117,12 @@ class ChopsUpdate:
|
||||
last_y = self.spans[-1][0]
|
||||
|
||||
_cell_len = cell_len
|
||||
|
||||
for y, x1, x2 in self.spans:
|
||||
line = chops[y]
|
||||
ends = chop_ends[y]
|
||||
for end, (x, segments) in zip(ends, line.items()):
|
||||
for end, (x, strip) in zip(ends, line.items()):
|
||||
# TODO: crop to x extents
|
||||
if segments is None:
|
||||
if strip is None:
|
||||
continue
|
||||
|
||||
if x > x2 or end <= x1:
|
||||
@@ -131,10 +130,10 @@ class ChopsUpdate:
|
||||
|
||||
if x2 > x >= x1 and end <= x2:
|
||||
yield move_to(x, y)
|
||||
yield from segments
|
||||
yield from strip
|
||||
continue
|
||||
|
||||
iter_segments = iter(segments)
|
||||
iter_segments = iter(strip)
|
||||
if x < x1:
|
||||
for segment in iter_segments:
|
||||
next_x = x + _cell_len(segment.text)
|
||||
@@ -280,12 +279,11 @@ class Compositor:
|
||||
# i.e. if something is moved / deleted / added
|
||||
|
||||
if screen not in self._dirty_regions:
|
||||
crop_screen = screen.intersection
|
||||
changes = map.items() ^ old_map.items()
|
||||
regions = {
|
||||
region
|
||||
for region in (
|
||||
crop_screen(map_geometry.visible_region)
|
||||
map_geometry.clip.intersection(map_geometry.region)
|
||||
for _, map_geometry in changes
|
||||
)
|
||||
if region
|
||||
@@ -635,11 +633,11 @@ class Compositor:
|
||||
|
||||
def _get_renders(
|
||||
self, crop: Region | None = None
|
||||
) -> Iterable[tuple[Region, Region, Lines]]:
|
||||
) -> Iterable[tuple[Region, Region, list[Strip]]]:
|
||||
"""Get rendered widgets (lists of segments) in the composition.
|
||||
|
||||
Returns:
|
||||
Iterable[tuple[Region, Region, Lines]]: An iterable of <region>, <clip region>, and <lines>
|
||||
Iterable[tuple[Region, Region, Strips]]: An iterable of <region>, <clip region>, and <strips>
|
||||
"""
|
||||
# If a renderable throws an error while rendering, the user likely doesn't care about the traceback
|
||||
# up to this point.
|
||||
@@ -685,18 +683,6 @@ class Compositor:
|
||||
_Region(delta_x, delta_y, new_width, new_height)
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def _assemble_chops(
|
||||
cls, chops: list[dict[int, list[Segment] | None]]
|
||||
) -> list[list[Segment]]:
|
||||
"""Combine chops in to lines."""
|
||||
from_iterable = chain.from_iterable
|
||||
segment_lines: list[list[Segment]] = [
|
||||
list(from_iterable(line for line in bucket.values() if line is not None))
|
||||
for bucket in chops
|
||||
]
|
||||
return segment_lines
|
||||
|
||||
def render(self, full: bool = False) -> RenderableType | None:
|
||||
"""Render a layout.
|
||||
|
||||
@@ -728,8 +714,6 @@ class Compositor:
|
||||
else:
|
||||
return None
|
||||
|
||||
divide = Segment.divide
|
||||
|
||||
# Maps each cut on to a list of segments
|
||||
cuts = self.cuts
|
||||
|
||||
@@ -738,19 +722,19 @@ class Compositor:
|
||||
"Callable[[list[int]], dict[int, list[Segment] | None]]", dict.fromkeys
|
||||
)
|
||||
# A mapping of cut index to a list of segments for each line
|
||||
chops: list[dict[int, list[Segment] | None]]
|
||||
chops: list[dict[int, Strip | None]]
|
||||
chops = [fromkeys(cut_set[:-1]) for cut_set in cuts]
|
||||
|
||||
cut_segments: Iterable[list[Segment]]
|
||||
cut_strips: Iterable[Strip]
|
||||
|
||||
# Go through all the renders in reverse order and fill buckets with no render
|
||||
renders = self._get_renders(crop)
|
||||
intersection = Region.intersection
|
||||
|
||||
for region, clip, lines in renders:
|
||||
for region, clip, strips in renders:
|
||||
render_region = intersection(region, clip)
|
||||
|
||||
for y, line in zip(render_region.line_range, lines):
|
||||
for y, strip in zip(render_region.line_range, strips):
|
||||
if not is_rendered_line(y):
|
||||
continue
|
||||
|
||||
@@ -763,20 +747,20 @@ class Compositor:
|
||||
]
|
||||
if len(final_cuts) <= 2:
|
||||
# Two cuts, which means the entire line
|
||||
cut_segments = [line]
|
||||
cut_strips = [strip]
|
||||
else:
|
||||
render_x = render_region.x
|
||||
relative_cuts = [cut - render_x for cut in final_cuts[1:]]
|
||||
cut_segments = divide(line, relative_cuts)
|
||||
cut_strips = strip.divide(relative_cuts)
|
||||
|
||||
# Since we are painting front to back, the first segments for a cut "wins"
|
||||
for cut, segments in zip(final_cuts, cut_segments):
|
||||
for cut, strip in zip(final_cuts, cut_strips):
|
||||
if chops_line[cut] is None:
|
||||
chops_line[cut] = segments
|
||||
chops_line[cut] = strip
|
||||
|
||||
if full:
|
||||
render_lines = self._assemble_chops(chops)
|
||||
return LayoutUpdate(render_lines, screen_region)
|
||||
render_strips = [Strip.join(chop.values()) for chop in chops]
|
||||
return LayoutUpdate(render_strips, screen_region)
|
||||
else:
|
||||
chop_ends = [cut_set[1:] for cut_set in cuts]
|
||||
return ChopsUpdate(chops, spans, chop_ends)
|
||||
@@ -785,7 +769,7 @@ class Compositor:
|
||||
self, console: Console, options: ConsoleOptions
|
||||
) -> RenderResult:
|
||||
if self._dirty_regions:
|
||||
yield self.render()
|
||||
yield self.render() or ""
|
||||
|
||||
def update_widgets(self, widgets: set[Widget]) -> None:
|
||||
"""Update a given widget in the composition.
|
||||
|
||||
@@ -13,14 +13,14 @@ class LineFilter(ABC):
|
||||
"""Base class for a line filter."""
|
||||
|
||||
@abstractmethod
|
||||
def filter(self, segments: list[Segment]) -> list[Segment]:
|
||||
def apply(self, segments: list[Segment]) -> list[Segment]:
|
||||
"""Transform a list of segments."""
|
||||
|
||||
|
||||
class Monochrome(LineFilter):
|
||||
"""Convert all colors to monochrome."""
|
||||
|
||||
def filter(self, segments: list[Segment]) -> list[Segment]:
|
||||
def apply(self, segments: list[Segment]) -> list[Segment]:
|
||||
to_monochrome = self.to_monochrome
|
||||
_Segment = Segment
|
||||
return [
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
from __future__ import annotations
|
||||
import sys
|
||||
|
||||
from typing import TYPE_CHECKING, Iterator, Sequence, overload
|
||||
from typing import TYPE_CHECKING, Any, Iterator, Sequence, overload
|
||||
|
||||
import rich.repr
|
||||
|
||||
@@ -13,7 +14,7 @@ class DuplicateIds(Exception):
|
||||
|
||||
|
||||
@rich.repr.auto(angular=True)
|
||||
class NodeList(Sequence):
|
||||
class NodeList(Sequence["Widget"]):
|
||||
"""
|
||||
A container for widgets that forms one level of hierarchy.
|
||||
|
||||
@@ -46,10 +47,10 @@ class NodeList(Sequence):
|
||||
def __len__(self) -> int:
|
||||
return len(self._nodes)
|
||||
|
||||
def __contains__(self, widget: Widget) -> bool:
|
||||
def __contains__(self, widget: object) -> bool:
|
||||
return widget in self._nodes
|
||||
|
||||
def index(self, widget: Widget) -> int:
|
||||
def index(self, widget: Any, start: int = 0, stop: int = sys.maxsize) -> int:
|
||||
"""Return the index of the given widget.
|
||||
|
||||
Args:
|
||||
@@ -61,7 +62,7 @@ class NodeList(Sequence):
|
||||
Raises:
|
||||
ValueError: If the widget is not in the node list.
|
||||
"""
|
||||
return self._nodes.index(widget)
|
||||
return self._nodes.index(widget, start, stop)
|
||||
|
||||
def _get_by_id(self, widget_id: str) -> Widget | None:
|
||||
"""Get the widget for the given widget_id, or None if there's no matches in this list"""
|
||||
|
||||
@@ -10,7 +10,6 @@ from rich.segment import Segment
|
||||
from rich.style import Style
|
||||
|
||||
from ._cells import cell_len
|
||||
from ._types import Lines
|
||||
from .css.types import AlignHorizontal, AlignVertical
|
||||
from .geometry import Size
|
||||
|
||||
@@ -22,8 +21,8 @@ def line_crop(
|
||||
|
||||
Args:
|
||||
segments (list[Segment]): A list of Segments for a line.
|
||||
start (int): Start offset
|
||||
end (int): End offset (exclusive)
|
||||
start (int): Start offset (cells)
|
||||
end (int): End offset (cells, exclusive)
|
||||
total (int): Total cell length of segments.
|
||||
Returns:
|
||||
list[Segment]: A new shorter list of segments
|
||||
@@ -130,7 +129,7 @@ def line_pad(
|
||||
|
||||
|
||||
def align_lines(
|
||||
lines: Lines,
|
||||
lines: list[list[Segment]],
|
||||
style: Style,
|
||||
size: Size,
|
||||
horizontal: AlignHorizontal,
|
||||
@@ -153,7 +152,7 @@ def align_lines(
|
||||
width, height = size
|
||||
shape_width, shape_height = Segment.get_shape(lines)
|
||||
|
||||
def blank_lines(count: int) -> Lines:
|
||||
def blank_lines(count: int) -> list[list[Segment]]:
|
||||
return [[Segment(" " * width, style)]] * count
|
||||
|
||||
top_blank_lines = bottom_blank_lines = 0
|
||||
|
||||
65
src/textual/_sleep.py
Normal file
@@ -0,0 +1,65 @@
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
from time import sleep, perf_counter
|
||||
from asyncio import get_running_loop
|
||||
from threading import Thread, Event
|
||||
|
||||
|
||||
class Sleeper(Thread):
|
||||
def __init__(
|
||||
self,
|
||||
) -> None:
|
||||
self._exit = False
|
||||
self._sleep_time = 0.0
|
||||
self._event = Event()
|
||||
self.future = None
|
||||
self._loop = get_running_loop()
|
||||
super().__init__(daemon=True)
|
||||
|
||||
def run(self):
|
||||
while True:
|
||||
self._event.wait()
|
||||
if self._exit:
|
||||
break
|
||||
sleep(self._sleep_time)
|
||||
self._event.clear()
|
||||
# self.future.set_result(None)
|
||||
self._loop.call_soon_threadsafe(self.future.set_result, None)
|
||||
|
||||
async def sleep(self, sleep_time: float) -> None:
|
||||
future = self.future = self._loop.create_future()
|
||||
self._sleep_time = sleep_time
|
||||
self._event.set()
|
||||
await future
|
||||
|
||||
# await self._async_event.wait()
|
||||
# self._async_event.clear()
|
||||
|
||||
|
||||
async def check_sleeps() -> None:
|
||||
|
||||
sleeper = Sleeper()
|
||||
sleeper.start()
|
||||
|
||||
async def profile_sleep(sleep_for: float) -> float:
|
||||
start = perf_counter()
|
||||
|
||||
while perf_counter() - start < sleep_for:
|
||||
sleep(0)
|
||||
# await sleeper.sleep(sleep_for)
|
||||
elapsed = perf_counter() - start
|
||||
return elapsed
|
||||
|
||||
for t in range(15, 120, 5):
|
||||
sleep_time = 1 / t
|
||||
elapsed = await profile_sleep(sleep_time)
|
||||
difference = (elapsed / sleep_time * 100) - 100
|
||||
print(
|
||||
f"sleep={sleep_time*1000:.01f}ms clock={elapsed*1000:.01f}ms diff={difference:.02f}%"
|
||||
)
|
||||
|
||||
|
||||
from asyncio import run
|
||||
|
||||
run(check_sleeps())
|
||||
@@ -11,12 +11,12 @@ from ._border import get_box, render_row
|
||||
from ._filter import LineFilter
|
||||
from ._opacity import _apply_opacity
|
||||
from ._segment_tools import line_crop, line_pad, line_trim
|
||||
from ._types import Lines
|
||||
from ._typing import TypeAlias
|
||||
from .color import Color
|
||||
from .geometry import Region, Size, Spacing
|
||||
from .renderables.text_opacity import TextOpacity
|
||||
from .renderables.tint import Tint
|
||||
from .strip import Strip
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .css.styles import StylesBase
|
||||
@@ -25,35 +25,6 @@ if TYPE_CHECKING:
|
||||
RenderLineCallback: TypeAlias = Callable[[int], List[Segment]]
|
||||
|
||||
|
||||
def style_links(
|
||||
segments: Iterable[Segment], link_id: str, link_style: Style
|
||||
) -> list[Segment]:
|
||||
"""Apply a style to the given link id.
|
||||
|
||||
Args:
|
||||
segments (Iterable[Segment]): Segments.
|
||||
link_id (str): A link id.
|
||||
link_style (Style): Style to apply.
|
||||
|
||||
Returns:
|
||||
list[Segment]: A list of new segments.
|
||||
"""
|
||||
|
||||
_Segment = Segment
|
||||
|
||||
segments = [
|
||||
_Segment(
|
||||
text,
|
||||
(style + link_style if style is not None else None)
|
||||
if (style and not style._null and style._link_id == link_id)
|
||||
else style,
|
||||
control,
|
||||
)
|
||||
for text, style, control in segments
|
||||
]
|
||||
return segments
|
||||
|
||||
|
||||
@lru_cache(1024 * 8)
|
||||
def make_blank(width, style: Style) -> Segment:
|
||||
"""Make a blank segment.
|
||||
@@ -95,7 +66,7 @@ class StylesCache:
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._cache: dict[int, list[Segment]] = {}
|
||||
self._cache: dict[int, Strip] = {}
|
||||
self._dirty_lines: set[int] = set()
|
||||
self._width = 1
|
||||
|
||||
@@ -123,7 +94,7 @@ class StylesCache:
|
||||
self._cache.clear()
|
||||
self._dirty_lines.clear()
|
||||
|
||||
def render_widget(self, widget: Widget, crop: Region) -> Lines:
|
||||
def render_widget(self, widget: Widget, crop: Region) -> list[Strip]:
|
||||
"""Render the content for a widget.
|
||||
|
||||
Args:
|
||||
@@ -135,7 +106,7 @@ class StylesCache:
|
||||
"""
|
||||
base_background, background = widget.background_colors
|
||||
styles = widget.styles
|
||||
lines = self.render(
|
||||
strips = self.render(
|
||||
styles,
|
||||
widget.region.size,
|
||||
base_background,
|
||||
@@ -147,7 +118,6 @@ class StylesCache:
|
||||
filter=widget.app._filter,
|
||||
)
|
||||
if widget.auto_links:
|
||||
_style_links = style_links
|
||||
hover_style = widget.hover_style
|
||||
link_hover_style = widget.link_hover_style
|
||||
if (
|
||||
@@ -157,12 +127,12 @@ class StylesCache:
|
||||
and "@click" in hover_style.meta
|
||||
):
|
||||
if link_hover_style:
|
||||
lines = [
|
||||
_style_links(line, hover_style.link_id, link_hover_style)
|
||||
for line in lines
|
||||
strips = [
|
||||
strip.style_links(hover_style.link_id, link_hover_style)
|
||||
for strip in strips
|
||||
]
|
||||
|
||||
return lines
|
||||
return strips
|
||||
|
||||
def render(
|
||||
self,
|
||||
@@ -175,7 +145,7 @@ class StylesCache:
|
||||
padding: Spacing | None = None,
|
||||
crop: Region | None = None,
|
||||
filter: LineFilter | None = None,
|
||||
) -> Lines:
|
||||
) -> list[Strip]:
|
||||
"""Render a widget content plus CSS styles.
|
||||
|
||||
Args:
|
||||
@@ -202,15 +172,14 @@ class StylesCache:
|
||||
if width != self._width:
|
||||
self.clear()
|
||||
self._width = width
|
||||
lines: Lines = []
|
||||
add_line = lines.append
|
||||
simplify = Segment.simplify
|
||||
strips: list[Strip] = []
|
||||
add_strip = strips.append
|
||||
|
||||
is_dirty = self._dirty_lines.__contains__
|
||||
render_line = self.render_line
|
||||
for y in crop.line_range:
|
||||
if is_dirty(y) or y not in self._cache:
|
||||
line = render_line(
|
||||
strip = render_line(
|
||||
styles,
|
||||
y,
|
||||
size,
|
||||
@@ -220,21 +189,19 @@ class StylesCache:
|
||||
background,
|
||||
render_content_line,
|
||||
)
|
||||
line = list(simplify(line))
|
||||
self._cache[y] = line
|
||||
self._cache[y] = strip
|
||||
else:
|
||||
line = self._cache[y]
|
||||
strip = self._cache[y]
|
||||
if filter:
|
||||
line = filter.filter(line)
|
||||
add_line(line)
|
||||
strip = strip.apply_filter(filter)
|
||||
add_strip(strip)
|
||||
self._dirty_lines.difference_update(crop.line_range)
|
||||
|
||||
if crop.column_span != (0, width):
|
||||
_line_crop = line_crop
|
||||
x1, x2 = crop.column_span
|
||||
lines = [_line_crop(line, x1, x2, width) for line in lines]
|
||||
strips = [strip.crop(x1, x2) for strip in strips]
|
||||
|
||||
return lines
|
||||
return strips
|
||||
|
||||
def render_line(
|
||||
self,
|
||||
@@ -246,7 +213,7 @@ class StylesCache:
|
||||
base_background: Color,
|
||||
background: Color,
|
||||
render_content_line: RenderLineCallback,
|
||||
) -> list[Segment]:
|
||||
) -> Strip:
|
||||
"""Render a styled line.
|
||||
|
||||
Args:
|
||||
@@ -288,7 +255,7 @@ class StylesCache:
|
||||
inner = from_color(bgcolor=(base_background + background).rich_color)
|
||||
outer = from_color(bgcolor=base_background.rich_color)
|
||||
|
||||
def post(segments: Iterable[Segment]) -> list[Segment]:
|
||||
def post(segments: Iterable[Segment]) -> Iterable[Segment]:
|
||||
"""Post process segments to apply opacity and tint.
|
||||
|
||||
Args:
|
||||
@@ -303,8 +270,7 @@ class StylesCache:
|
||||
segments = Tint.process_segments(segments, styles.tint)
|
||||
if styles.opacity != 1.0:
|
||||
segments = _apply_opacity(segments, base_background, styles.opacity)
|
||||
segments = list(segments)
|
||||
return segments if isinstance(segments, list) else list(segments)
|
||||
return segments
|
||||
|
||||
line: Iterable[Segment]
|
||||
# Draw top or bottom borders (A)
|
||||
@@ -402,4 +368,5 @@ class StylesCache:
|
||||
else:
|
||||
line = [*line, right]
|
||||
|
||||
return post(line)
|
||||
strip = Strip(post(line), width)
|
||||
return strip
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import platform
|
||||
|
||||
from asyncio import get_running_loop
|
||||
from asyncio import sleep as asyncio_sleep
|
||||
from time import monotonic, perf_counter
|
||||
|
||||
PLATFORM = platform.system()
|
||||
@@ -10,3 +11,34 @@ if WINDOWS:
|
||||
time = perf_counter
|
||||
else:
|
||||
time = monotonic
|
||||
|
||||
|
||||
if WINDOWS:
|
||||
# sleep on windows as a resolution of 15ms
|
||||
# Python3.11 is somewhat better, but this home-grown version beats it
|
||||
# Deduced from practical experiments
|
||||
|
||||
from ._win_sleep import sleep as win_sleep
|
||||
|
||||
async def sleep(secs: float) -> None:
|
||||
"""Sleep for a given number of seconds.
|
||||
|
||||
Args:
|
||||
secs (float): Number of seconds to sleep for.
|
||||
"""
|
||||
await get_running_loop().run_in_executor(None, win_sleep, secs)
|
||||
|
||||
else:
|
||||
|
||||
async def sleep(secs: float) -> None:
|
||||
"""Sleep for a given number of seconds.
|
||||
|
||||
Args:
|
||||
secs (float): Number of seconds to sleep for.
|
||||
"""
|
||||
# From practical experiments, asyncio.sleep sleeps for at least half a millisecond too much
|
||||
# Presumably there is overhead asyncio itself which accounts for this
|
||||
# We will reduce the sleep to compensate, and also don't sleep at all for less than half a millisecond
|
||||
sleep_for = secs - 0.0005
|
||||
if sleep_for > 0:
|
||||
await asyncio_sleep(sleep_for)
|
||||
|
||||
@@ -2,10 +2,12 @@ from typing import Awaitable, Callable, List, TYPE_CHECKING, Union
|
||||
|
||||
from rich.segment import Segment
|
||||
|
||||
from textual._typing import Protocol
|
||||
from ._typing import Protocol
|
||||
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .message import Message
|
||||
from .strip import Strip
|
||||
|
||||
|
||||
class MessageTarget(Protocol):
|
||||
@@ -27,5 +29,5 @@ class EventTarget(Protocol):
|
||||
...
|
||||
|
||||
|
||||
Lines = List[List[Segment]]
|
||||
SegmentLines = List[List["Segment"]]
|
||||
CallbackType = Union[Callable[[], Awaitable[None]], Callable[[], None]]
|
||||
|
||||
@@ -9,3 +9,5 @@ if sys.version_info >= (3, 8):
|
||||
from typing import Final, Literal, Protocol, TypedDict
|
||||
else:
|
||||
from typing_extensions import Final, Literal, Protocol, TypedDict
|
||||
|
||||
__all__ = ["TypeAlias", "Final", "Literal", "Protocol", "TypedDict"]
|
||||
|
||||
70
src/textual/_win_sleep.py
Normal file
@@ -0,0 +1,70 @@
|
||||
"""
|
||||
A version of `time.sleep` that is more accurate than the standard library (even on Python 3.11).
|
||||
|
||||
This should only be imported on Windows.
|
||||
|
||||
"""
|
||||
|
||||
from time import sleep as time_sleep
|
||||
|
||||
|
||||
__all__ = ["sleep"]
|
||||
|
||||
|
||||
INFINITE = 0xFFFFFFFF
|
||||
WAIT_FAILED = 0xFFFFFFFF
|
||||
CREATE_WAITABLE_TIMER_HIGH_RESOLUTION = 0x00000002
|
||||
TIMER_ALL_ACCESS = 0x1F0003
|
||||
|
||||
try:
|
||||
import ctypes
|
||||
from ctypes.wintypes import LARGE_INTEGER
|
||||
|
||||
kernel32 = ctypes.windll.kernel32
|
||||
except Exception:
|
||||
sleep = time_sleep
|
||||
else:
|
||||
|
||||
def sleep(secs: float) -> None:
|
||||
"""A replacement sleep for Windows.
|
||||
|
||||
Note that unlike `time.sleep` this *may* sleep for slightly less than the
|
||||
specified time. This is generally not an issue for Textual's use case.
|
||||
|
||||
Args:
|
||||
secs (float): Seconds to sleep for.
|
||||
"""
|
||||
|
||||
# Subtract a millisecond to account for overhead
|
||||
sleep_for = max(0, secs - 0.001)
|
||||
if sleep_for < 0.0005:
|
||||
# Less than 0.5ms and its not worth doing the sleep
|
||||
return
|
||||
|
||||
handle = kernel32.CreateWaitableTimerExW(
|
||||
None,
|
||||
None,
|
||||
CREATE_WAITABLE_TIMER_HIGH_RESOLUTION,
|
||||
TIMER_ALL_ACCESS,
|
||||
)
|
||||
if not handle:
|
||||
time_sleep(sleep_for)
|
||||
return
|
||||
|
||||
try:
|
||||
if not kernel32.SetWaitableTimer(
|
||||
handle,
|
||||
ctypes.byref(LARGE_INTEGER(int(sleep_for * -10_000_000))),
|
||||
0,
|
||||
None,
|
||||
None,
|
||||
0,
|
||||
):
|
||||
kernel32.CloseHandle(handle)
|
||||
time_sleep(sleep_for)
|
||||
return
|
||||
|
||||
if kernel32.WaitForSingleObject(handle, INFINITE) == WAIT_FAILED:
|
||||
time_sleep(sleep_for)
|
||||
finally:
|
||||
kernel32.CloseHandle(handle)
|
||||
@@ -54,36 +54,40 @@ class XTermParser(Parser[events.Event]):
|
||||
if sgr_match:
|
||||
_buttons, _x, _y, state = sgr_match.groups()
|
||||
buttons = int(_buttons)
|
||||
button = (buttons + 1) & 3
|
||||
x = int(_x) - 1
|
||||
y = int(_y) - 1
|
||||
delta_x = x - self.last_x
|
||||
delta_y = y - self.last_y
|
||||
self.last_x = x
|
||||
self.last_y = y
|
||||
event: events.Event
|
||||
event_class: type[events.MouseEvent]
|
||||
|
||||
if buttons & 64:
|
||||
event = (
|
||||
events.MouseScrollUp if button == 1 else events.MouseScrollDown
|
||||
)(sender, x, y)
|
||||
else:
|
||||
event = (
|
||||
events.MouseMove
|
||||
if buttons & 32
|
||||
else (events.MouseDown if state == "M" else events.MouseUp)
|
||||
)(
|
||||
sender,
|
||||
x,
|
||||
y,
|
||||
delta_x,
|
||||
delta_y,
|
||||
button,
|
||||
bool(buttons & 4),
|
||||
bool(buttons & 8),
|
||||
bool(buttons & 16),
|
||||
screen_x=x,
|
||||
screen_y=y,
|
||||
event_class = (
|
||||
events.MouseScrollDown if buttons & 1 else events.MouseScrollUp
|
||||
)
|
||||
button = 0
|
||||
else:
|
||||
if buttons & 32:
|
||||
event_class = events.MouseMove
|
||||
else:
|
||||
event_class = events.MouseDown if state == "M" else events.MouseUp
|
||||
|
||||
button = (buttons + 1) & 3
|
||||
|
||||
event = event_class(
|
||||
sender,
|
||||
x,
|
||||
y,
|
||||
delta_x,
|
||||
delta_y,
|
||||
button,
|
||||
bool(buttons & 4),
|
||||
bool(buttons & 8),
|
||||
bool(buttons & 16),
|
||||
screen_x=x,
|
||||
screen_y=y,
|
||||
)
|
||||
return event
|
||||
return None
|
||||
|
||||
|
||||
@@ -4,6 +4,10 @@ import ast
|
||||
import re
|
||||
|
||||
|
||||
class SkipAction(Exception):
|
||||
"""Raise in an action to skip the action (and allow any parent bindings to run)."""
|
||||
|
||||
|
||||
class ActionError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
@@ -38,7 +38,7 @@ from rich.protocol import is_renderable
|
||||
from rich.segment import Segment, Segments
|
||||
from rich.traceback import Traceback
|
||||
|
||||
from . import Logger, LogGroup, LogVerbosity, actions, events, log, messages
|
||||
from . import actions, Logger, LogGroup, LogVerbosity, events, log, messages
|
||||
from ._animator import DEFAULT_EASING, Animatable, Animator, EasingFunction
|
||||
from ._ansi_sequences import SYNC_END, SYNC_START
|
||||
from ._callback import invoke
|
||||
@@ -47,6 +47,7 @@ from ._event_broker import NoHandler, extract_handler_actions
|
||||
from ._filter import LineFilter, Monochrome
|
||||
from ._path import _make_path_object_relative
|
||||
from ._typing import Final, TypeAlias
|
||||
from .actions import SkipAction
|
||||
from .await_remove import AwaitRemove
|
||||
from .binding import Binding, Bindings
|
||||
from .css.query import NoMatches
|
||||
@@ -63,7 +64,7 @@ from .messages import CallbackType
|
||||
from .reactive import Reactive
|
||||
from .renderables.blank import Blank
|
||||
from .screen import Screen
|
||||
from .widget import AwaitMount, MountError, Widget
|
||||
from .widget import AwaitMount, Widget
|
||||
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@@ -237,6 +238,12 @@ class App(Generic[ReturnType], DOMNode):
|
||||
TITLE: str | None = None
|
||||
SUB_TITLE: str | None = None
|
||||
|
||||
BINDINGS = [
|
||||
Binding("ctrl+c", "quit", "Quit", show=False, priority=True),
|
||||
Binding("tab", "focus_next", "Focus Next", show=False),
|
||||
Binding("shift+tab", "focus_previous", "Focus Previous", show=False),
|
||||
]
|
||||
|
||||
title: Reactive[str] = Reactive("")
|
||||
sub_title: Reactive[str] = Reactive("")
|
||||
dark: Reactive[bool] = Reactive(True)
|
||||
@@ -298,7 +305,6 @@ class App(Generic[ReturnType], DOMNode):
|
||||
|
||||
self._logger = Logger(self._log)
|
||||
|
||||
self._bindings.bind("ctrl+c", "quit", show=False, universal=True)
|
||||
self._refresh_required = False
|
||||
|
||||
self.design = DEFAULT_COLORS
|
||||
@@ -348,6 +354,7 @@ class App(Generic[ReturnType], DOMNode):
|
||||
self.devtools = DevtoolsClient()
|
||||
|
||||
self._return_value: ReturnType | None = None
|
||||
self._exit = False
|
||||
|
||||
self.css_monitor = (
|
||||
FileMonitor(self.css_path, self._on_css_change)
|
||||
@@ -357,6 +364,7 @@ class App(Generic[ReturnType], DOMNode):
|
||||
self._screenshot: str | None = None
|
||||
self._dom_lock = asyncio.Lock()
|
||||
self._dom_ready = False
|
||||
self.set_class(self.dark, "-dark-mode")
|
||||
|
||||
@property
|
||||
def return_value(self) -> ReturnType | None:
|
||||
@@ -414,14 +422,20 @@ class App(Generic[ReturnType], DOMNode):
|
||||
"""list[Screen]: A *copy* of the screen stack."""
|
||||
return self._screen_stack.copy()
|
||||
|
||||
def exit(self, result: ReturnType | None = None) -> None:
|
||||
def exit(
|
||||
self, result: ReturnType | None = None, message: RenderableType | None = None
|
||||
) -> None:
|
||||
"""Exit the app, and return the supplied result.
|
||||
|
||||
Args:
|
||||
result (ReturnType | None, optional): Return value. Defaults to None.
|
||||
message (RenderableType | None): Optional message to display on exit.
|
||||
"""
|
||||
self._exit = True
|
||||
self._return_value = result
|
||||
self.post_message_no_wait(messages.ExitApp(sender=self))
|
||||
if message:
|
||||
self._exit_renderables.append(message)
|
||||
|
||||
@property
|
||||
def focused(self) -> Widget | None:
|
||||
@@ -462,7 +476,14 @@ class App(Generic[ReturnType], DOMNode):
|
||||
"""Watches the dark bool."""
|
||||
self.set_class(dark, "-dark-mode")
|
||||
self.set_class(not dark, "-light-mode")
|
||||
self.refresh_css()
|
||||
try:
|
||||
self.refresh_css()
|
||||
except ScreenStackError:
|
||||
# It's possible that `dark` can be set before we have a default
|
||||
# screen, in an app's `on_load`, for example. So let's eat the
|
||||
# ScreenStackError -- the above styles will be handled once the
|
||||
# screen is spun up anyway.
|
||||
pass
|
||||
|
||||
def get_driver_class(self) -> Type[Driver]:
|
||||
"""Get a driver class for this platform.
|
||||
@@ -1080,9 +1101,9 @@ class App(Generic[ReturnType], DOMNode):
|
||||
_screen = self.get_screen(screen)
|
||||
if not _screen.is_running:
|
||||
widgets = self._register(self, _screen)
|
||||
return (_screen, AwaitMount(widgets))
|
||||
return (_screen, AwaitMount(_screen, widgets))
|
||||
else:
|
||||
return (_screen, AwaitMount([]))
|
||||
return (_screen, AwaitMount(_screen, []))
|
||||
|
||||
def _replace_screen(self, screen: Screen) -> Screen:
|
||||
"""Handle the replaced screen.
|
||||
@@ -1128,7 +1149,7 @@ class App(Generic[ReturnType], DOMNode):
|
||||
self.screen.post_message_no_wait(events.ScreenResume(self))
|
||||
self.log.system(f"{self.screen} is current (SWITCHED)")
|
||||
return await_mount
|
||||
return AwaitMount([])
|
||||
return AwaitMount(self.screen, [])
|
||||
|
||||
def install_screen(self, screen: Screen, name: str | None = None) -> AwaitMount:
|
||||
"""Install a screen.
|
||||
@@ -1377,11 +1398,10 @@ class App(Generic[ReturnType], DOMNode):
|
||||
raise
|
||||
|
||||
finally:
|
||||
self._running = True
|
||||
await self._ready()
|
||||
await invoke_ready_callback()
|
||||
|
||||
self._running = True
|
||||
|
||||
try:
|
||||
await self._process_messages_loop()
|
||||
except asyncio.CancelledError:
|
||||
@@ -1406,28 +1426,29 @@ class App(Generic[ReturnType], DOMNode):
|
||||
)
|
||||
driver = self._driver = driver_class(self.console, self, size=terminal_size)
|
||||
|
||||
driver.start_application_mode()
|
||||
try:
|
||||
if headless:
|
||||
await run_process_messages()
|
||||
else:
|
||||
if self.devtools is not None:
|
||||
devtools = self.devtools
|
||||
assert devtools is not None
|
||||
from .devtools.redirect_output import StdoutRedirector
|
||||
|
||||
redirector = StdoutRedirector(devtools)
|
||||
with redirect_stderr(redirector):
|
||||
with redirect_stdout(redirector): # type: ignore
|
||||
await run_process_messages()
|
||||
if not self._exit:
|
||||
driver.start_application_mode()
|
||||
try:
|
||||
if headless:
|
||||
await run_process_messages()
|
||||
else:
|
||||
null_file = _NullFile()
|
||||
with redirect_stderr(null_file):
|
||||
with redirect_stdout(null_file):
|
||||
await run_process_messages()
|
||||
if self.devtools is not None:
|
||||
devtools = self.devtools
|
||||
assert devtools is not None
|
||||
from .devtools.redirect_output import StdoutRedirector
|
||||
|
||||
finally:
|
||||
driver.stop_application_mode()
|
||||
redirector = StdoutRedirector(devtools)
|
||||
with redirect_stderr(redirector):
|
||||
with redirect_stdout(redirector): # type: ignore
|
||||
await run_process_messages()
|
||||
else:
|
||||
null_file = _NullFile()
|
||||
with redirect_stderr(null_file):
|
||||
with redirect_stdout(null_file):
|
||||
await run_process_messages()
|
||||
|
||||
finally:
|
||||
driver.stop_application_mode()
|
||||
except Exception as error:
|
||||
self._handle_exception(error)
|
||||
|
||||
@@ -1473,7 +1494,7 @@ class App(Generic[ReturnType], DOMNode):
|
||||
nodes: set[DOMNode] = {
|
||||
child
|
||||
for node in self._require_stylesheet_update
|
||||
for child in node.walk_children()
|
||||
for child in node.walk_children(with_self=True)
|
||||
}
|
||||
self._require_stylesheet_update.clear()
|
||||
self.stylesheet.update_nodes(nodes, animate=True)
|
||||
@@ -1563,7 +1584,6 @@ class App(Generic[ReturnType], DOMNode):
|
||||
if widget.children:
|
||||
self._register(widget, *widget.children)
|
||||
apply_stylesheet(widget)
|
||||
|
||||
return list(widgets)
|
||||
|
||||
def _unregister(self, widget: Widget) -> None:
|
||||
@@ -1729,22 +1749,23 @@ class App(Generic[ReturnType], DOMNode):
|
||||
]
|
||||
return namespace_bindings
|
||||
|
||||
async def check_bindings(self, key: str, universal: bool = False) -> bool:
|
||||
async def check_bindings(self, key: str, priority: bool = False) -> bool:
|
||||
"""Handle a key press.
|
||||
|
||||
Args:
|
||||
key (str): A key
|
||||
universal (bool): Check universal keys if True, otherwise non-universal keys.
|
||||
key (str): A key.
|
||||
priority (bool): If `True` check from `App` down, otherwise from focused up.
|
||||
|
||||
Returns:
|
||||
bool: True if the key was handled by a binding, otherwise False
|
||||
"""
|
||||
|
||||
for namespace, bindings in self._binding_chain:
|
||||
for namespace, bindings in (
|
||||
reversed(self._binding_chain) if priority else self._binding_chain
|
||||
):
|
||||
binding = bindings.keys.get(key)
|
||||
if binding is not None and binding.universal == universal:
|
||||
await self.action(binding.action, default_namespace=namespace)
|
||||
return True
|
||||
if binding is not None and binding.priority == priority:
|
||||
if await self.action(binding.action, namespace):
|
||||
return True
|
||||
return False
|
||||
|
||||
async def on_event(self, event: events.Event) -> None:
|
||||
@@ -1762,7 +1783,7 @@ class App(Generic[ReturnType], DOMNode):
|
||||
self.mouse_position = Offset(event.x, event.y)
|
||||
await self.screen._forward_event(event)
|
||||
elif isinstance(event, events.Key):
|
||||
if not await self.check_bindings(event.key, universal=True):
|
||||
if not await self.check_bindings(event.key, priority=True):
|
||||
forward_target = self.focused or self.screen
|
||||
await forward_target._forward_event(event)
|
||||
else:
|
||||
@@ -1813,32 +1834,41 @@ class App(Generic[ReturnType], DOMNode):
|
||||
async def _dispatch_action(
|
||||
self, namespace: object, action_name: str, params: Any
|
||||
) -> bool:
|
||||
"""Dispatch an action to an action method.
|
||||
|
||||
Args:
|
||||
namespace (object): Namespace (object) of action.
|
||||
action_name (str): Name of the action.
|
||||
params (Any): Action parameters.
|
||||
|
||||
Returns:
|
||||
bool: True if handled, otherwise False.
|
||||
"""
|
||||
_rich_traceback_guard = True
|
||||
|
||||
log(
|
||||
"<action>",
|
||||
namespace=namespace,
|
||||
action_name=action_name,
|
||||
params=params,
|
||||
)
|
||||
_rich_traceback_guard = True
|
||||
|
||||
public_method_name = f"action_{action_name}"
|
||||
private_method_name = f"_{public_method_name}"
|
||||
|
||||
private_method = getattr(namespace, private_method_name, None)
|
||||
public_method = getattr(namespace, public_method_name, None)
|
||||
|
||||
if private_method is None and public_method is None:
|
||||
try:
|
||||
private_method = getattr(namespace, f"_action_{action_name}", None)
|
||||
if callable(private_method):
|
||||
await invoke(private_method, *params)
|
||||
return True
|
||||
public_method = getattr(namespace, f"action_{action_name}", None)
|
||||
if callable(public_method):
|
||||
await invoke(public_method, *params)
|
||||
return True
|
||||
log(
|
||||
f"<action> {action_name!r} has no target. Couldn't find methods {public_method_name!r} or {private_method_name!r}"
|
||||
f"<action> {action_name!r} has no target."
|
||||
f" Could not find methods '_action_{action_name}' or 'action_{action_name}'"
|
||||
)
|
||||
|
||||
if callable(private_method):
|
||||
await invoke(private_method, *params)
|
||||
return True
|
||||
elif callable(public_method):
|
||||
await invoke(public_method, *params)
|
||||
return True
|
||||
|
||||
except SkipAction:
|
||||
# The action method raised this to explicitly not handle the action
|
||||
log("<action> {action_name!r} skipped.")
|
||||
return False
|
||||
|
||||
async def _broker_event(
|
||||
@@ -1849,7 +1879,7 @@ class App(Generic[ReturnType], DOMNode):
|
||||
Args:
|
||||
event_name (str): _description_
|
||||
event (events.Event): An event object.
|
||||
default_namespace (object | None): TODO: _description_
|
||||
default_namespace (object | None): The default namespace, where one isn't supplied.
|
||||
|
||||
Returns:
|
||||
bool: True if an action was processed.
|
||||
@@ -1879,13 +1909,8 @@ class App(Generic[ReturnType], DOMNode):
|
||||
message.stop()
|
||||
|
||||
async def _on_key(self, event: events.Key) -> None:
|
||||
if event.key == "tab":
|
||||
self.screen.focus_next()
|
||||
elif event.key == "shift+tab":
|
||||
self.screen.focus_previous()
|
||||
else:
|
||||
if not (await self.check_bindings(event.key)):
|
||||
await self.dispatch_key(event)
|
||||
if not (await self.check_bindings(event.key)):
|
||||
await self.dispatch_key(event)
|
||||
|
||||
async def _on_shutdown_request(self, event: events.ShutdownRequest) -> None:
|
||||
log("shutdown request")
|
||||
@@ -2042,7 +2067,8 @@ class App(Generic[ReturnType], DOMNode):
|
||||
self._unregister(root)
|
||||
|
||||
async def action_check_bindings(self, key: str) -> None:
|
||||
await self.check_bindings(key)
|
||||
if not await self.check_bindings(key, priority=True):
|
||||
await self.check_bindings(key, priority=False)
|
||||
|
||||
async def action_quit(self) -> None:
|
||||
"""Quit the app as soon as possible."""
|
||||
@@ -2104,6 +2130,14 @@ class App(Generic[ReturnType], DOMNode):
|
||||
async def action_toggle_class(self, selector: str, class_name: str) -> None:
|
||||
self.screen.query(selector).toggle_class(class_name)
|
||||
|
||||
def action_focus_next(self) -> None:
|
||||
"""Focus the next widget."""
|
||||
self.screen.focus_next()
|
||||
|
||||
def action_focus_previous(self) -> None:
|
||||
"""Focus the previous widget."""
|
||||
self.screen.focus_previous()
|
||||
|
||||
def _on_terminal_supports_synchronized_output(
|
||||
self, message: messages.TerminalSupportsSynchronizedOutput
|
||||
) -> None:
|
||||
|
||||
@@ -5,6 +5,7 @@ from typing import Iterable, MutableMapping
|
||||
|
||||
import rich.repr
|
||||
|
||||
from textual.keys import _character_to_key
|
||||
from textual._typing import TypeAlias
|
||||
|
||||
BindingType: TypeAlias = "Binding | tuple[str, str, str]"
|
||||
@@ -18,8 +19,14 @@ class NoBinding(Exception):
|
||||
"""A binding was not found."""
|
||||
|
||||
|
||||
class InvalidBinding(Exception):
|
||||
"""Binding key is in an invalid format."""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Binding:
|
||||
"""The configuration of a key binding."""
|
||||
|
||||
key: str
|
||||
"""str: Key to bind. This can also be a comma-separated list of keys to map multiple keys to a single action."""
|
||||
action: str
|
||||
@@ -30,15 +37,29 @@ class Binding:
|
||||
"""bool: Show the action in Footer, or False to hide."""
|
||||
key_display: str | None = None
|
||||
"""str | None: How the key should be shown in footer."""
|
||||
universal: bool = False
|
||||
"""bool: Allow forwarding from app to focused widget."""
|
||||
priority: bool = False
|
||||
"""bool: Enable priority binding for this key."""
|
||||
|
||||
|
||||
@rich.repr.auto
|
||||
class Bindings:
|
||||
"""Manage a set of bindings."""
|
||||
|
||||
def __init__(self, bindings: Iterable[BindingType] | None = None) -> None:
|
||||
def __init__(
|
||||
self,
|
||||
bindings: Iterable[BindingType] | None = None,
|
||||
) -> None:
|
||||
"""Initialise a collection of bindings.
|
||||
|
||||
Args:
|
||||
bindings (Iterable[BindingType] | None, optional): An optional set of initial bindings.
|
||||
|
||||
Note:
|
||||
The iterable of bindings can contain either a `Binding`
|
||||
instance, or a tuple of 3 values mapping to the first three
|
||||
properties of a `Binding`.
|
||||
"""
|
||||
|
||||
def make_bindings(bindings: Iterable[BindingType]) -> Iterable[Binding]:
|
||||
for binding in bindings:
|
||||
# If it's a tuple of length 3, convert into a Binding first
|
||||
@@ -49,20 +70,25 @@ class Bindings:
|
||||
)
|
||||
binding = Binding(*binding)
|
||||
|
||||
binding_keys = binding.key.split(",")
|
||||
if len(binding_keys) > 1:
|
||||
for key in binding_keys:
|
||||
new_binding = Binding(
|
||||
key=key,
|
||||
action=binding.action,
|
||||
description=binding.description,
|
||||
show=binding.show,
|
||||
key_display=binding.key_display,
|
||||
universal=binding.universal,
|
||||
# At this point we have a Binding instance, but the key may
|
||||
# be a list of keys, so now we unroll that single Binding
|
||||
# into a (potential) collection of Binding instances.
|
||||
for key in binding.key.split(","):
|
||||
key = key.strip()
|
||||
if not key:
|
||||
raise InvalidBinding(
|
||||
f"Can not bind empty string in {binding.key!r}"
|
||||
)
|
||||
yield new_binding
|
||||
else:
|
||||
yield binding
|
||||
if len(key) == 1:
|
||||
key = _character_to_key(key)
|
||||
yield Binding(
|
||||
key=key,
|
||||
action=binding.action,
|
||||
description=binding.description,
|
||||
show=binding.show,
|
||||
key_display=binding.key_display,
|
||||
priority=binding.priority,
|
||||
)
|
||||
|
||||
self.keys: MutableMapping[str, Binding] = (
|
||||
{binding.key: binding for binding in make_bindings(bindings)}
|
||||
@@ -75,7 +101,7 @@ class Bindings:
|
||||
|
||||
@classmethod
|
||||
def merge(cls, bindings: Iterable[Bindings]) -> Bindings:
|
||||
"""Merge a bindings. Subsequence bound keys override initial keys.
|
||||
"""Merge a bindings. Subsequent bound keys override initial keys.
|
||||
|
||||
Args:
|
||||
bindings (Iterable[Bindings]): A number of bindings.
|
||||
@@ -105,7 +131,7 @@ class Bindings:
|
||||
description: str = "",
|
||||
show: bool = True,
|
||||
key_display: str | None = None,
|
||||
universal: bool = False,
|
||||
priority: bool = False,
|
||||
) -> None:
|
||||
"""Bind keys to an action.
|
||||
|
||||
@@ -115,7 +141,7 @@ class Bindings:
|
||||
description (str, optional): An optional description for the binding.
|
||||
show (bool, optional): A flag to say if the binding should appear in the footer.
|
||||
key_display (str | None, optional): Optional string to display in the footer for the key.
|
||||
universal (bool, optional): Allow forwarding from the app to the focused widget.
|
||||
priority (bool, optional): Is this a priority binding, checked form app down to focused widget?
|
||||
"""
|
||||
all_keys = [key.strip() for key in keys.split(",")]
|
||||
for key in all_keys:
|
||||
@@ -125,7 +151,7 @@ class Bindings:
|
||||
description,
|
||||
show=show,
|
||||
key_display=key_display,
|
||||
universal=universal,
|
||||
priority=priority,
|
||||
)
|
||||
|
||||
def get_key(self, key: str) -> Binding:
|
||||
|
||||
@@ -123,3 +123,11 @@ def colors():
|
||||
from textual.cli.previews import colors
|
||||
|
||||
colors.app.run()
|
||||
|
||||
|
||||
@run.command("keys")
|
||||
def keys():
|
||||
"""Show key events."""
|
||||
from textual.cli.previews import keys
|
||||
|
||||
keys.app.run()
|
||||
|
||||
@@ -36,6 +36,10 @@ class BorderApp(App):
|
||||
"""Demonstrates the border styles."""
|
||||
|
||||
CSS = """
|
||||
Screen {
|
||||
align: center middle;
|
||||
overflow: auto;
|
||||
}
|
||||
#text {
|
||||
margin: 2 4;
|
||||
padding: 2 4;
|
||||
@@ -49,6 +53,7 @@ class BorderApp(App):
|
||||
def compose(self):
|
||||
yield BorderButtons()
|
||||
self.text = Label(TEXT, id="text")
|
||||
self.text.shrink = True
|
||||
yield self.text
|
||||
|
||||
def on_button_pressed(self, event: Button.Pressed) -> None:
|
||||
|
||||
60
src/textual/cli/previews/keys.py
Normal file
@@ -0,0 +1,60 @@
|
||||
from rich.panel import Panel
|
||||
|
||||
from textual.app import App, ComposeResult
|
||||
from textual import events
|
||||
from textual.containers import Horizontal
|
||||
from textual.widgets import Button, Header, TextLog
|
||||
|
||||
|
||||
INSTRUCTIONS = """\
|
||||
Press some keys!
|
||||
|
||||
Because we want to display all the keys, ctrl+C won't quit this example. Use the Quit button below to exit the app.\
|
||||
"""
|
||||
|
||||
|
||||
class KeyLog(TextLog, inherit_bindings=False):
|
||||
"""We don't want to handle scroll keys."""
|
||||
|
||||
|
||||
class KeysApp(App, inherit_bindings=False):
|
||||
"""Show key events in a text log."""
|
||||
|
||||
TITLE = "Textual Keys"
|
||||
BINDINGS = [("c", "clear", "Clear")]
|
||||
CSS = """
|
||||
#buttons {
|
||||
dock: bottom;
|
||||
height: 3;
|
||||
}
|
||||
Button {
|
||||
width: 1fr;
|
||||
}
|
||||
"""
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
yield Header()
|
||||
yield Horizontal(
|
||||
Button("Clear", id="clear", variant="warning"),
|
||||
Button("Quit", id="quit", variant="error"),
|
||||
id="buttons",
|
||||
)
|
||||
yield KeyLog()
|
||||
|
||||
def on_ready(self) -> None:
|
||||
self.query_one(KeyLog).write(Panel(INSTRUCTIONS), expand=True)
|
||||
|
||||
def on_key(self, event: events.Key) -> None:
|
||||
self.query_one(KeyLog).write(event)
|
||||
|
||||
def on_button_pressed(self, event: Button.Pressed) -> None:
|
||||
if event.button.id == "quit":
|
||||
self.exit()
|
||||
elif event.button.id == "clear":
|
||||
self.query_one(KeyLog).clear()
|
||||
|
||||
|
||||
app = KeysApp()
|
||||
|
||||
if __name__ == "__main__":
|
||||
app.run()
|
||||
@@ -794,9 +794,8 @@ class NameListProperty:
|
||||
class ColorProperty:
|
||||
"""Descriptor for getting and setting color properties."""
|
||||
|
||||
def __init__(self, default_color: Color | str, background: bool = False) -> None:
|
||||
def __init__(self, default_color: Color | str) -> None:
|
||||
self._default_color = Color.parse(default_color)
|
||||
self._is_background = background
|
||||
|
||||
def __set_name__(self, owner: StylesBase, name: str) -> None:
|
||||
self.name = name
|
||||
@@ -830,11 +829,10 @@ class ColorProperty:
|
||||
_rich_traceback_omit = True
|
||||
if color is None:
|
||||
if obj.clear_rule(self.name):
|
||||
obj.refresh(children=self._is_background)
|
||||
obj.refresh(children=True)
|
||||
elif isinstance(color, Color):
|
||||
if obj.set_rule(self.name, color):
|
||||
obj.refresh(children=self._is_background)
|
||||
|
||||
obj.refresh(children=True)
|
||||
elif isinstance(color, str):
|
||||
alpha = 1.0
|
||||
parsed_color = Color(255, 255, 255)
|
||||
@@ -855,8 +853,9 @@ class ColorProperty:
|
||||
),
|
||||
)
|
||||
parsed_color = parsed_color.with_alpha(alpha)
|
||||
|
||||
if obj.set_rule(self.name, parsed_color):
|
||||
obj.refresh(children=self._is_background)
|
||||
obj.refresh(children=True)
|
||||
else:
|
||||
raise StyleValueError(f"Invalid color value {color}")
|
||||
|
||||
|
||||
@@ -36,20 +36,21 @@ VALID_ALIGN_VERTICAL: Final = {"top", "middle", "bottom"}
|
||||
VALID_TEXT_ALIGN: Final = {"start", "end", "left", "right", "center", "justify"}
|
||||
VALID_SCROLLBAR_GUTTER: Final = {"auto", "stable"}
|
||||
VALID_STYLE_FLAGS: Final = {
|
||||
"b",
|
||||
"blink",
|
||||
"bold",
|
||||
"dim",
|
||||
"i",
|
||||
"italic",
|
||||
"none",
|
||||
"not",
|
||||
"bold",
|
||||
"blink",
|
||||
"italic",
|
||||
"underline",
|
||||
"overline",
|
||||
"strike",
|
||||
"b",
|
||||
"i",
|
||||
"u",
|
||||
"uu",
|
||||
"o",
|
||||
"overline",
|
||||
"reverse",
|
||||
"strike",
|
||||
"u",
|
||||
"underline",
|
||||
"uu",
|
||||
}
|
||||
|
||||
NULL_SPACING: Final = Spacing.all(0)
|
||||
|
||||
@@ -4,8 +4,7 @@ from rich.console import ConsoleOptions, Console, RenderResult
|
||||
from rich.traceback import Traceback
|
||||
|
||||
from ._help_renderables import HelpText
|
||||
from .tokenize import Token
|
||||
from .tokenizer import TokenError
|
||||
from .tokenizer import Token, TokenError
|
||||
|
||||
|
||||
class DeclarationError(Exception):
|
||||
@@ -32,7 +31,7 @@ class StyleValueError(ValueError):
|
||||
error is raised.
|
||||
"""
|
||||
|
||||
def __init__(self, *args, help_text: HelpText | None = None):
|
||||
def __init__(self, *args: object, help_text: HelpText | None = None):
|
||||
super().__init__(*args)
|
||||
self.help_text = help_text
|
||||
|
||||
|
||||
@@ -25,14 +25,14 @@ def match(selector_sets: Iterable[SelectorSet], node: DOMNode) -> bool:
|
||||
|
||||
|
||||
def _check_selectors(selectors: list[Selector], css_path_nodes: list[DOMNode]) -> bool:
|
||||
"""Match a list of selectors against a node.
|
||||
"""Match a list of selectors against DOM nodes.
|
||||
|
||||
Args:
|
||||
selectors (list[Selector]): A list of selectors.
|
||||
node (DOMNode): A DOM node.
|
||||
css_path_nodes (list[DOMNode]): The DOM nodes to check the selectors against.
|
||||
|
||||
Returns:
|
||||
bool: True if the node matches the selector.
|
||||
bool: True if any node in css_path_nodes matches a selector.
|
||||
"""
|
||||
|
||||
DESCENDENT = CombinatorType.DESCENDENT
|
||||
|
||||
@@ -209,12 +209,12 @@ class StylesBase(ABC):
|
||||
node: DOMNode | None = None
|
||||
|
||||
display = StringEnumProperty(VALID_DISPLAY, "block", layout=True)
|
||||
visibility = StringEnumProperty(VALID_VISIBILITY, "visible")
|
||||
visibility = StringEnumProperty(VALID_VISIBILITY, "visible", layout=True)
|
||||
layout = LayoutProperty()
|
||||
|
||||
auto_color = BooleanProperty(default=False)
|
||||
color = ColorProperty(Color(255, 255, 255))
|
||||
background = ColorProperty(Color(0, 0, 0, 0), background=True)
|
||||
background = ColorProperty(Color(0, 0, 0, 0))
|
||||
text_style = StyleFlagsProperty()
|
||||
|
||||
opacity = FractionalProperty()
|
||||
@@ -421,7 +421,7 @@ class StylesBase(ABC):
|
||||
|
||||
Args:
|
||||
layout (bool, optional): Also require a layout. Defaults to False.
|
||||
children (bool, opional): Also refresh children. Defaults to False.
|
||||
children (bool, optional): Also refresh children. Defaults to False.
|
||||
"""
|
||||
|
||||
@abstractmethod
|
||||
|
||||
@@ -490,7 +490,7 @@ class Stylesheet:
|
||||
animate (bool, optional): Enable CSS animation. Defaults to False.
|
||||
"""
|
||||
|
||||
self.update_nodes(root.walk_children(), animate=animate)
|
||||
self.update_nodes(root.walk_children(with_self=True), animate=animate)
|
||||
|
||||
def update_nodes(self, nodes: Iterable[DOMNode], animate: bool = False) -> None:
|
||||
"""Update styles for nodes.
|
||||
|
||||
@@ -242,7 +242,7 @@ class Tokenizer:
|
||||
while True:
|
||||
if line_no >= len(self.lines):
|
||||
raise EOFError(
|
||||
self.path, self.code, line_no, col_no, "Unexpected end of file"
|
||||
self.path, self.code, (line_no, col_no), "Unexpected end of file"
|
||||
)
|
||||
line = self.lines[line_no]
|
||||
match = expect.search(line, col_no)
|
||||
|
||||
@@ -29,7 +29,7 @@ Sidebar {
|
||||
}
|
||||
|
||||
Sidebar:focus-within {
|
||||
offset: 0 0 !important;
|
||||
offset: 0 0 !important;
|
||||
}
|
||||
|
||||
Sidebar.-hidden {
|
||||
|
||||
@@ -40,8 +40,8 @@ async def _on_startup(app: Application) -> None:
|
||||
def _run_devtools(verbose: bool, exclude: list[str] | None = None) -> None:
|
||||
app = _make_devtools_aiohttp_app(verbose=verbose, exclude=exclude)
|
||||
|
||||
def noop_print(_: str):
|
||||
return None
|
||||
def noop_print(_: str) -> None:
|
||||
pass
|
||||
|
||||
try:
|
||||
run_app(
|
||||
@@ -77,7 +77,3 @@ def _make_devtools_aiohttp_app(
|
||||
)
|
||||
|
||||
return app
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
_run_devtools()
|
||||
|
||||
@@ -22,7 +22,7 @@ from rich.tree import Tree
|
||||
|
||||
from ._context import NoActiveAppError
|
||||
from ._node_list import NodeList
|
||||
from .binding import Binding, Bindings, BindingType
|
||||
from .binding import Bindings, BindingType
|
||||
from .color import BLACK, WHITE, Color
|
||||
from .css._error_tools import friendly_list
|
||||
from .css.constants import VALID_DISPLAY, VALID_VISIBILITY
|
||||
@@ -225,11 +225,18 @@ class DOMNode(MessagePump):
|
||||
"""
|
||||
bindings: list[Bindings] = []
|
||||
|
||||
# To start with, assume that bindings won't be priority bindings.
|
||||
priority = False
|
||||
|
||||
for base in reversed(cls.__mro__):
|
||||
if issubclass(base, DOMNode):
|
||||
if not base._inherit_bindings:
|
||||
bindings.clear()
|
||||
bindings.append(Bindings(base.__dict__.get("BINDINGS", [])))
|
||||
bindings.append(
|
||||
Bindings(
|
||||
base.__dict__.get("BINDINGS", []),
|
||||
)
|
||||
)
|
||||
keys = {}
|
||||
for bindings_ in bindings:
|
||||
keys.update(bindings_.keys)
|
||||
@@ -507,8 +514,8 @@ class DOMNode(MessagePump):
|
||||
@property
|
||||
def rich_style(self) -> Style:
|
||||
"""Get a Rich Style object for this DOMNode."""
|
||||
background = WHITE
|
||||
color = BLACK
|
||||
background = Color(0, 0, 0, 0)
|
||||
color = Color(255, 255, 255, 0)
|
||||
style = Style()
|
||||
for node in reversed(self.ancestors_with_self):
|
||||
styles = node.styles
|
||||
@@ -520,7 +527,8 @@ class DOMNode(MessagePump):
|
||||
if styles.has_rule("auto_color") and styles.auto_color:
|
||||
color = background.get_contrast_text(color.a)
|
||||
style += Style.from_color(
|
||||
(background + color).rich_color, background.rich_color
|
||||
(background + color).rich_color if (background.a or color.a) else None,
|
||||
background.rich_color if background.a else None,
|
||||
)
|
||||
return style
|
||||
|
||||
@@ -604,7 +612,7 @@ class DOMNode(MessagePump):
|
||||
"""Reset styles back to their initial state"""
|
||||
from .widget import Widget
|
||||
|
||||
for node in self.walk_children():
|
||||
for node in self.walk_children(with_self=True):
|
||||
node._css_styles.reset()
|
||||
if isinstance(node, Widget):
|
||||
node._set_dirty()
|
||||
@@ -637,7 +645,7 @@ class DOMNode(MessagePump):
|
||||
self,
|
||||
filter_type: type[WalkType],
|
||||
*,
|
||||
with_self: bool = True,
|
||||
with_self: bool = False,
|
||||
method: WalkMethod = "depth",
|
||||
reverse: bool = False,
|
||||
) -> list[WalkType]:
|
||||
@@ -647,7 +655,7 @@ class DOMNode(MessagePump):
|
||||
def walk_children(
|
||||
self,
|
||||
*,
|
||||
with_self: bool = True,
|
||||
with_self: bool = False,
|
||||
method: WalkMethod = "depth",
|
||||
reverse: bool = False,
|
||||
) -> list[DOMNode]:
|
||||
@@ -657,16 +665,16 @@ class DOMNode(MessagePump):
|
||||
self,
|
||||
filter_type: type[WalkType] | None = None,
|
||||
*,
|
||||
with_self: bool = True,
|
||||
with_self: bool = False,
|
||||
method: WalkMethod = "depth",
|
||||
reverse: bool = False,
|
||||
) -> list[DOMNode] | list[WalkType]:
|
||||
"""Generate descendant nodes.
|
||||
"""Walk the subtree rooted at this node, and return every descendant encountered in a list.
|
||||
|
||||
Args:
|
||||
filter_type (type[WalkType] | None, optional): Filter only this type, or None for no filter.
|
||||
Defaults to None.
|
||||
with_self (bool, optional): Also yield self in addition to descendants. Defaults to True.
|
||||
with_self (bool, optional): Also yield self in addition to descendants. Defaults to False.
|
||||
method (Literal["breadth", "depth"], optional): One of "depth" or "breadth". Defaults to "depth".
|
||||
reverse (bool, optional): Reverse the order (bottom up). Defaults to False.
|
||||
|
||||
|
||||
@@ -16,7 +16,6 @@ if TYPE_CHECKING:
|
||||
from .timer import Timer as TimerClass
|
||||
from .timer import TimerCallback
|
||||
from .widget import Widget
|
||||
import asyncio
|
||||
|
||||
|
||||
@rich.repr.auto
|
||||
@@ -183,7 +182,7 @@ class MouseRelease(Event, bubble=False):
|
||||
|
||||
|
||||
class InputEvent(Event):
|
||||
pass
|
||||
"""Base class for input events."""
|
||||
|
||||
|
||||
@rich.repr.auto
|
||||
@@ -191,45 +190,56 @@ class Key(InputEvent):
|
||||
"""Sent when the user hits a key on the keyboard.
|
||||
|
||||
Args:
|
||||
sender (MessageTarget): The sender of the event (the App).
|
||||
key (str): A key name (textual.keys.Keys).
|
||||
char (str | None, optional): A printable character or None if it is not printable.
|
||||
sender (MessageTarget): The sender of the event (always the App).
|
||||
key (str): The key that was pressed.
|
||||
character (str | None, optional): A printable character or ``None`` if it is not printable.
|
||||
|
||||
Attributes:
|
||||
key_aliases (list[str]): The aliases for the key, including the key itself
|
||||
aliases (list[str]): The aliases for the key, including the key itself.
|
||||
"""
|
||||
|
||||
__slots__ = ["key", "char"]
|
||||
__slots__ = ["key", "character", "aliases"]
|
||||
|
||||
def __init__(self, sender: MessageTarget, key: str, char: str | None) -> None:
|
||||
def __init__(self, sender: MessageTarget, key: str, character: str | None) -> None:
|
||||
super().__init__(sender)
|
||||
self.key = key
|
||||
self.char = (key if len(key) == 1 else None) if char is None else char
|
||||
self.key_aliases = [_normalize_key(alias) for alias in _get_key_aliases(key)]
|
||||
self.character = (
|
||||
(key if len(key) == 1 else None) if character is None else character
|
||||
)
|
||||
self.aliases = _get_key_aliases(key)
|
||||
|
||||
def __rich_repr__(self) -> rich.repr.Result:
|
||||
yield "key", self.key
|
||||
yield "char", self.char, None
|
||||
yield "character", self.character
|
||||
yield "name", self.name
|
||||
yield "is_printable", self.is_printable
|
||||
yield "aliases", self.aliases, [self.key]
|
||||
|
||||
@property
|
||||
def key_name(self) -> str | None:
|
||||
def name(self) -> str:
|
||||
"""Name of a key suitable for use as a Python identifier."""
|
||||
return _normalize_key(self.key)
|
||||
return _key_to_identifier(self.key).lower()
|
||||
|
||||
@property
|
||||
def name_aliases(self) -> list[str]:
|
||||
"""The corresponding name for every alias in `aliases` list."""
|
||||
return [_key_to_identifier(key) for key in self.aliases]
|
||||
|
||||
@property
|
||||
def is_printable(self) -> bool:
|
||||
"""Return True if the key is printable. Currently, we assume any key event that
|
||||
isn't defined in key bindings is printable.
|
||||
"""Check if the key is printable (produces a unicode character).
|
||||
|
||||
Returns:
|
||||
bool: True if the key is printable.
|
||||
"""
|
||||
return False if self.char is None else self.char.isprintable()
|
||||
return False if self.character is None else self.character.isprintable()
|
||||
|
||||
|
||||
def _normalize_key(key: str) -> str:
|
||||
def _key_to_identifier(key: str) -> str:
|
||||
"""Convert the key string to a name suitable for use as a Python identifier."""
|
||||
return key.replace("+", "_")
|
||||
if len(key) == 1 and key.isupper():
|
||||
key = f"upper_{key.lower()}"
|
||||
return key.replace("+", "_").lower()
|
||||
|
||||
|
||||
@rich.repr.auto
|
||||
@@ -409,22 +419,14 @@ class MouseUp(MouseEvent, bubble=True, verbose=True):
|
||||
pass
|
||||
|
||||
|
||||
class MouseScrollDown(InputEvent, bubble=True, verbose=True):
|
||||
__slots__ = ["x", "y"]
|
||||
|
||||
def __init__(self, sender: MessageTarget, x: int, y: int) -> None:
|
||||
super().__init__(sender)
|
||||
self.x = x
|
||||
self.y = y
|
||||
@rich.repr.auto
|
||||
class MouseScrollDown(MouseEvent, bubble=True):
|
||||
pass
|
||||
|
||||
|
||||
class MouseScrollUp(InputEvent, bubble=True, verbose=True):
|
||||
__slots__ = ["x", "y"]
|
||||
|
||||
def __init__(self, sender: MessageTarget, x: int, y: int) -> None:
|
||||
super().__init__(sender)
|
||||
self.x = x
|
||||
self.y = y
|
||||
@rich.repr.auto
|
||||
class MouseScrollUp(MouseEvent, bubble=True):
|
||||
pass
|
||||
|
||||
|
||||
class Click(MouseEvent, bubble=True):
|
||||
|
||||
@@ -129,7 +129,7 @@ class Offset(NamedTuple):
|
||||
"""
|
||||
x1, y1 = self
|
||||
x2, y2 = other
|
||||
distance = ((x2 - x1) * (x2 - x1) + (y2 - y1) * (y2 - y1)) ** 0.5
|
||||
distance: float = ((x2 - x1) * (x2 - x1) + (y2 - y1) * (y2 - y1)) ** 0.5
|
||||
return distance
|
||||
|
||||
|
||||
@@ -217,6 +217,8 @@ class Size(NamedTuple):
|
||||
|
||||
def __contains__(self, other: Any) -> bool:
|
||||
try:
|
||||
x: int
|
||||
y: int
|
||||
x, y = other
|
||||
except Exception:
|
||||
raise TypeError(
|
||||
@@ -320,7 +322,7 @@ class Region(NamedTuple):
|
||||
Offset: An offset required to add to region to move it inside window_region.
|
||||
"""
|
||||
|
||||
if region in window_region:
|
||||
if region in window_region and not top:
|
||||
# Region is already inside the window, so no need to move it.
|
||||
return NULL_OFFSET
|
||||
|
||||
@@ -341,19 +343,19 @@ class Region(NamedTuple):
|
||||
key=abs,
|
||||
)
|
||||
|
||||
if not (
|
||||
if top:
|
||||
delta_y = top_ - window_top
|
||||
|
||||
elif not (
|
||||
(window_bottom > top_ >= window_top)
|
||||
and (window_bottom > bottom >= window_top)
|
||||
):
|
||||
# The window needs to scroll on the Y axis to bring region in to view
|
||||
if top:
|
||||
delta_y = top_ - window_top
|
||||
else:
|
||||
delta_y = min(
|
||||
top_ - window_top,
|
||||
top_ - (window_bottom - region.height),
|
||||
key=abs,
|
||||
)
|
||||
delta_y = min(
|
||||
top_ - window_top,
|
||||
top_ - (window_bottom - region.height),
|
||||
key=abs,
|
||||
)
|
||||
return Offset(delta_x, delta_y)
|
||||
|
||||
def __bool__(self) -> bool:
|
||||
|
||||
@@ -70,10 +70,10 @@ class Keys(str, Enum):
|
||||
ControlShift9 = "ctrl+shift+9"
|
||||
ControlShift0 = "ctrl+shift+0"
|
||||
|
||||
ControlBackslash = "ctrl+\\"
|
||||
ControlSquareClose = "ctrl+]"
|
||||
ControlCircumflex = "ctrl+^"
|
||||
ControlUnderscore = "ctrl+_"
|
||||
ControlBackslash = "ctrl+backslash"
|
||||
ControlSquareClose = "ctrl+right_square_bracket"
|
||||
ControlCircumflex = "ctrl+circumflex_accent"
|
||||
ControlUnderscore = "ctrl+underscore"
|
||||
|
||||
Left = "left"
|
||||
Right = "right"
|
||||
@@ -245,9 +245,25 @@ def _get_key_display(key: str) -> str:
|
||||
return display_alias
|
||||
|
||||
original_key = REPLACED_KEYS.get(key, key)
|
||||
upper_original = original_key.upper().replace("_", " ")
|
||||
try:
|
||||
unicode_character = unicodedata.lookup(original_key.upper().replace("_", " "))
|
||||
unicode_character = unicodedata.lookup(upper_original)
|
||||
except KeyError:
|
||||
return original_key.upper()
|
||||
return upper_original
|
||||
|
||||
return unicode_character
|
||||
# Check if printable. `delete` for example maps to a control sequence
|
||||
# which we don't want to write to the terminal.
|
||||
if unicode_character.isprintable():
|
||||
return unicode_character
|
||||
return upper_original
|
||||
|
||||
|
||||
def _character_to_key(character: str) -> str:
|
||||
"""Convert a single character to a key value."""
|
||||
assert len(character) == 1
|
||||
if not character.isalnum():
|
||||
key = unicodedata.name(character).lower().replace("-", "_").replace(" ", "_")
|
||||
else:
|
||||
key = character
|
||||
key = KEY_NAME_REPLACEMENTS.get(key, key)
|
||||
return key
|
||||
|
||||
@@ -24,7 +24,6 @@ class Message:
|
||||
|
||||
__slots__ = [
|
||||
"sender",
|
||||
"name",
|
||||
"time",
|
||||
"_forwarded",
|
||||
"_no_default_action",
|
||||
@@ -40,13 +39,14 @@ class Message:
|
||||
|
||||
def __init__(self, sender: MessageTarget) -> None:
|
||||
self.sender = sender
|
||||
self.name = camel_to_snake(self.__class__.__name__)
|
||||
|
||||
self.time = _clock.get_time_no_wait()
|
||||
self._forwarded = False
|
||||
self._no_default_action = False
|
||||
self._stop_propagation = False
|
||||
name = camel_to_snake(self.__class__.__name__)
|
||||
self._handler_name = (
|
||||
f"on_{self.namespace}_{self.name}" if self.namespace else f"on_{self.name}"
|
||||
f"on_{self.namespace}_{name}" if self.namespace else f"on_{name}"
|
||||
)
|
||||
super().__init__()
|
||||
|
||||
|
||||
@@ -593,12 +593,12 @@ class MessagePump(metaclass=MessagePumpMeta):
|
||||
|
||||
handled = False
|
||||
invoked_method = None
|
||||
key_name = event.key_name
|
||||
key_name = event.name
|
||||
if not key_name:
|
||||
return False
|
||||
|
||||
for key_alias in event.key_aliases:
|
||||
key_method = get_key_handler(self, key_alias)
|
||||
for key_method_name in event.name_aliases:
|
||||
key_method = get_key_handler(self, key_method_name)
|
||||
if key_method is not None:
|
||||
if invoked_method:
|
||||
_raise_duplicate_key_handlers_error(
|
||||
|
||||
@@ -3,24 +3,23 @@ from __future__ import annotations
|
||||
import rich.repr
|
||||
|
||||
import asyncio
|
||||
from typing import TYPE_CHECKING
|
||||
from typing import Generic
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .app import App
|
||||
from .app import App, ReturnType
|
||||
|
||||
|
||||
@rich.repr.auto(angular=True)
|
||||
class Pilot:
|
||||
class Pilot(Generic[ReturnType]):
|
||||
"""Pilot object to drive an app."""
|
||||
|
||||
def __init__(self, app: App) -> None:
|
||||
def __init__(self, app: App[ReturnType]) -> None:
|
||||
self._app = app
|
||||
|
||||
def __rich_repr__(self) -> rich.repr.Result:
|
||||
yield "app", self._app
|
||||
|
||||
@property
|
||||
def app(self) -> App:
|
||||
def app(self) -> App[ReturnType]:
|
||||
"""App: A reference to the application."""
|
||||
return self._app
|
||||
|
||||
@@ -47,7 +46,7 @@ class Pilot:
|
||||
"""Wait for any animation to complete."""
|
||||
await self._app.animator.wait_for_idle()
|
||||
|
||||
async def exit(self, result: object) -> None:
|
||||
async def exit(self, result: ReturnType) -> None:
|
||||
"""Exit the app with the given result.
|
||||
|
||||
Args:
|
||||
|
||||
@@ -175,15 +175,11 @@ class Reactive(Generic[ReactiveType]):
|
||||
current_value = getattr(obj, name)
|
||||
# Check for validate function
|
||||
validate_function = getattr(obj, f"validate_{name}", None)
|
||||
# Check if this is the first time setting the value
|
||||
first_set = getattr(obj, f"__first_set_{self.internal_name}", True)
|
||||
# Call validate, but not on first set.
|
||||
if callable(validate_function) and not first_set:
|
||||
# Call validate
|
||||
if callable(validate_function):
|
||||
value = validate_function(value)
|
||||
# If the value has changed, or this is the first time setting the value
|
||||
if current_value != value or first_set or self._always_update:
|
||||
# Set the first set flag to False
|
||||
setattr(obj, f"__first_set_{self.internal_name}", False)
|
||||
if current_value != value or self._always_update:
|
||||
# Store the internal value
|
||||
setattr(obj, self.internal_name, value)
|
||||
# Check all watchers
|
||||
@@ -200,7 +196,6 @@ class Reactive(Generic[ReactiveType]):
|
||||
obj (Reactable): The reactable object.
|
||||
name (str): Attribute name.
|
||||
old_value (Any): The old (previous) value of the attribute.
|
||||
first_set (bool, optional): True if this is the first time setting the value. Defaults to False.
|
||||
"""
|
||||
_rich_traceback_omit = True
|
||||
# Get the current value.
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import statistics
|
||||
from typing import Sequence, Iterable, Callable, TypeVar
|
||||
from typing import Generic, Sequence, Iterable, Callable, TypeVar
|
||||
|
||||
from rich.color import Color
|
||||
from rich.console import ConsoleOptions, Console, RenderResult
|
||||
@@ -12,8 +12,10 @@ from textual.renderables._blend_colors import blend_colors
|
||||
|
||||
T = TypeVar("T", int, float)
|
||||
|
||||
SummaryFunction = Callable[[Sequence[T]], float]
|
||||
|
||||
class Sparkline:
|
||||
|
||||
class Sparkline(Generic[T]):
|
||||
"""A sparkline representing a series of data.
|
||||
|
||||
Args:
|
||||
@@ -33,16 +35,16 @@ class Sparkline:
|
||||
width: int | None,
|
||||
min_color: Color = Color.from_rgb(0, 255, 0),
|
||||
max_color: Color = Color.from_rgb(255, 0, 0),
|
||||
summary_function: Callable[[list[T]], float] = max,
|
||||
summary_function: SummaryFunction[T] = max,
|
||||
) -> None:
|
||||
self.data = data
|
||||
self.data: Sequence[T] = data
|
||||
self.width = width
|
||||
self.min_color = Style.from_color(min_color)
|
||||
self.max_color = Style.from_color(max_color)
|
||||
self.summary_function = summary_function
|
||||
self.summary_function: SummaryFunction[T] = summary_function
|
||||
|
||||
@classmethod
|
||||
def _buckets(cls, data: Sequence[T], num_buckets: int) -> Iterable[list[T]]:
|
||||
def _buckets(cls, data: Sequence[T], num_buckets: int) -> Iterable[Sequence[T]]:
|
||||
"""Partition ``data`` into ``num_buckets`` buckets. For example, the data
|
||||
[1, 2, 3, 4] partitioned into 2 buckets is [[1, 2], [3, 4]].
|
||||
|
||||
@@ -73,13 +75,15 @@ class Sparkline:
|
||||
minimum, maximum = min(self.data), max(self.data)
|
||||
extent = maximum - minimum or 1
|
||||
|
||||
buckets = list(self._buckets(self.data, num_buckets=self.width))
|
||||
buckets = tuple(self._buckets(self.data, num_buckets=width))
|
||||
|
||||
bucket_index = 0
|
||||
bucket_index = 0.0
|
||||
bars_rendered = 0
|
||||
step = len(buckets) / width
|
||||
summary_function = self.summary_function
|
||||
min_color, max_color = self.min_color.color, self.max_color.color
|
||||
assert min_color is not None
|
||||
assert max_color is not None
|
||||
while bars_rendered < width:
|
||||
partition = buckets[int(bucket_index)]
|
||||
partition_summary = summary_function(partition)
|
||||
@@ -94,10 +98,16 @@ class Sparkline:
|
||||
if __name__ == "__main__":
|
||||
console = Console()
|
||||
|
||||
def last(l):
|
||||
def last(l: Sequence[T]) -> T:
|
||||
return l[-1]
|
||||
|
||||
funcs = min, max, last, statistics.median, statistics.mean
|
||||
funcs: Sequence[SummaryFunction[int]] = (
|
||||
min,
|
||||
max,
|
||||
last,
|
||||
statistics.median,
|
||||
statistics.mean,
|
||||
)
|
||||
nums = [10, 2, 30, 60, 45, 20, 7, 8, 9, 10, 50, 13, 10, 6, 5, 4, 3, 7, 20]
|
||||
console.print(f"data = {nums}\n")
|
||||
for f in funcs:
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import functools
|
||||
from typing import Iterable
|
||||
from typing import Iterable, Tuple, cast
|
||||
|
||||
from rich.cells import cell_len
|
||||
from rich.color import Color
|
||||
@@ -62,12 +62,17 @@ class TextOpacity:
|
||||
_Segment = Segment
|
||||
_from_color = Style.from_color
|
||||
if opacity == 0:
|
||||
for text, style, control in segments:
|
||||
for text, style, control in cast(
|
||||
# use Tuple rather than tuple so Python 3.7 doesn't complain
|
||||
Iterable[Tuple[str, Style, object]],
|
||||
segments,
|
||||
):
|
||||
invisible_style = _from_color(bgcolor=style.bgcolor)
|
||||
yield _Segment(cell_len(text) * " ", invisible_style)
|
||||
else:
|
||||
for segment in segments:
|
||||
text, style, control = segment
|
||||
# use Tuple rather than tuple so Python 3.7 doesn't complain
|
||||
text, style, control = cast(Tuple[str, Style, object], segment)
|
||||
if not style:
|
||||
yield segment
|
||||
continue
|
||||
@@ -85,40 +90,3 @@ class TextOpacity:
|
||||
) -> RenderResult:
|
||||
segments = console.render(self.renderable, options)
|
||||
return self.process_segments(segments, self.opacity)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
from rich.live import Live
|
||||
from rich.panel import Panel
|
||||
from rich.text import Text
|
||||
|
||||
from time import sleep
|
||||
|
||||
console = Console()
|
||||
|
||||
panel = Panel.fit(
|
||||
Text("Steak: £30", style="#fcffde on #03761e"),
|
||||
title="Menu",
|
||||
style="#ffffff on #000000",
|
||||
)
|
||||
console.print(panel)
|
||||
|
||||
opacity_panel = TextOpacity(panel, opacity=0.5)
|
||||
console.print(opacity_panel)
|
||||
|
||||
def frange(start, end, step):
|
||||
current = start
|
||||
while current < end:
|
||||
yield current
|
||||
current += step
|
||||
|
||||
while current >= 0:
|
||||
yield current
|
||||
current -= step
|
||||
|
||||
import itertools
|
||||
|
||||
with Live(opacity_panel, refresh_per_second=60) as live:
|
||||
for value in itertools.cycle(frange(0, 1, 0.05)):
|
||||
opacity_panel.value = value
|
||||
sleep(0.05)
|
||||
|
||||
@@ -26,6 +26,10 @@ UPDATE_PERIOD: Final[float] = 1 / 120
|
||||
class Screen(Widget):
|
||||
"""A widget for the root of the app."""
|
||||
|
||||
# The screen is a special case and unless a class that inherits from us
|
||||
# says otherwise, all screen-level bindings should be treated as having
|
||||
# priority.
|
||||
|
||||
DEFAULT_CSS = """
|
||||
Screen {
|
||||
layout: vertical;
|
||||
@@ -290,21 +294,20 @@ class Screen(Widget):
|
||||
# No focus, so blur currently focused widget if it exists
|
||||
if self.focused is not None:
|
||||
self.focused.post_message_no_wait(events.Blur(self))
|
||||
self.focused.emit_no_wait(events.DescendantBlur(self))
|
||||
self.focused = None
|
||||
self.log.debug("focus was removed")
|
||||
elif widget.can_focus:
|
||||
if self.focused != widget:
|
||||
if self.focused is not None:
|
||||
# Blur currently focused widget
|
||||
self.focused.post_message_no_wait(events.Blur(self))
|
||||
self.focused.emit_no_wait(events.DescendantBlur(self))
|
||||
# Change focus
|
||||
self.focused = widget
|
||||
# Send focus event
|
||||
if scroll_visible:
|
||||
self.screen.scroll_to_widget(widget)
|
||||
widget.post_message_no_wait(events.Focus(self))
|
||||
widget.emit_no_wait(events.DescendantFocus(self))
|
||||
self.log.debug(widget, "was focused")
|
||||
|
||||
async def _on_idle(self, event: events.Idle) -> None:
|
||||
# Check for any widgets marked as 'dirty' (needs a repaint)
|
||||
|
||||
@@ -30,6 +30,16 @@ class ScrollView(Widget):
|
||||
"""Not transparent, i.e. renders something."""
|
||||
return False
|
||||
|
||||
def watch_scroll_x(self, new_value: float) -> None:
|
||||
if self.show_horizontal_scrollbar:
|
||||
self.horizontal_scrollbar.position = int(new_value)
|
||||
self.refresh()
|
||||
|
||||
def watch_scroll_y(self, new_value: float) -> None:
|
||||
if self.show_vertical_scrollbar:
|
||||
self.vertical_scrollbar.position = int(new_value)
|
||||
self.refresh()
|
||||
|
||||
def on_mount(self):
|
||||
self._refresh_scrollbars()
|
||||
|
||||
@@ -68,6 +78,8 @@ class ScrollView(Widget):
|
||||
virtual_size (Size): New virtual size.
|
||||
container_size (Size): New container size.
|
||||
"""
|
||||
if self._size != size or container_size != container_size:
|
||||
self.refresh()
|
||||
if (
|
||||
self._size != size
|
||||
or virtual_size != self.virtual_size
|
||||
@@ -77,9 +89,7 @@ class ScrollView(Widget):
|
||||
virtual_size = self.virtual_size
|
||||
self._container_size = size - self.styles.gutter.totals
|
||||
self._scroll_update(virtual_size)
|
||||
|
||||
self.scroll_to(self.scroll_x, self.scroll_y, animate=False)
|
||||
self.refresh()
|
||||
|
||||
def render(self) -> RenderableType:
|
||||
"""Render the scrollable region (if `render_lines` is not implemented).
|
||||
|
||||
@@ -225,14 +225,15 @@ class ScrollBar(Widget):
|
||||
|
||||
def render(self) -> RenderableType:
|
||||
styles = self.parent.styles
|
||||
background = (
|
||||
styles.scrollbar_background_hover
|
||||
if self.mouse_over
|
||||
else styles.scrollbar_background
|
||||
)
|
||||
color = (
|
||||
styles.scrollbar_color_active if self.grabbed else styles.scrollbar_color
|
||||
)
|
||||
if self.grabbed:
|
||||
background = styles.scrollbar_background_active
|
||||
color = styles.scrollbar_color_active
|
||||
elif self.mouse_over:
|
||||
background = styles.scrollbar_background_hover
|
||||
color = styles.scrollbar_color_hover
|
||||
else:
|
||||
background = styles.scrollbar_background
|
||||
color = styles.scrollbar_color
|
||||
color = background + color
|
||||
scrollbar_style = Style.from_color(color.rich_color, background.rich_color)
|
||||
return ScrollBarRender(
|
||||
|
||||
289
src/textual/strip.py
Normal file
@@ -0,0 +1,289 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from itertools import chain
|
||||
from typing import Iterable, Iterator
|
||||
|
||||
import rich.repr
|
||||
from rich.cells import cell_len, set_cell_size
|
||||
from rich.segment import Segment
|
||||
from rich.style import Style
|
||||
|
||||
from ._cache import FIFOCache
|
||||
from ._filter import LineFilter
|
||||
|
||||
|
||||
@rich.repr.auto
|
||||
class Strip:
|
||||
"""Represents a 'strip' (horizontal line) of a Textual Widget.
|
||||
|
||||
A Strip is like an immutable list of Segments. The immutability allows for effective caching.
|
||||
|
||||
Args:
|
||||
segments (Iterable[Segment]): An iterable of segments.
|
||||
cell_length (int | None, optional): The cell length if known, or None to calculate on demand. Defaults to None.
|
||||
"""
|
||||
|
||||
__slots__ = [
|
||||
"_segments",
|
||||
"_cell_length",
|
||||
"_divide_cache",
|
||||
"_crop_cache",
|
||||
]
|
||||
|
||||
def __init__(
|
||||
self, segments: Iterable[Segment], cell_length: int | None = None
|
||||
) -> None:
|
||||
self._segments = list(segments)
|
||||
self._cell_length = cell_length
|
||||
self._divide_cache: FIFOCache[tuple[int, ...], list[Strip]] = FIFOCache(4)
|
||||
self._crop_cache: FIFOCache[tuple[int, int], Strip] = FIFOCache(4)
|
||||
|
||||
def __rich_repr__(self) -> rich.repr.Result:
|
||||
yield self._segments
|
||||
yield self.cell_length
|
||||
|
||||
@classmethod
|
||||
def blank(cls, cell_length: int, style: Style | None) -> Strip:
|
||||
"""Create a blank strip.
|
||||
|
||||
Args:
|
||||
cell_length (int): Desired cell length.
|
||||
style (Style | None): Style of blank.
|
||||
|
||||
Returns:
|
||||
Strip: New strip.
|
||||
"""
|
||||
return cls([Segment(" " * cell_length, style)], cell_length)
|
||||
|
||||
@classmethod
|
||||
def from_lines(cls, lines: list[list[Segment]], cell_length: int) -> list[Strip]:
|
||||
"""Convert lines (lists of segments) to a list of Strips.
|
||||
|
||||
Args:
|
||||
lines (list[list[Segment]]): List of lines, where a line is a list of segments.
|
||||
cell_length (int): Cell length of lines (must be same).
|
||||
|
||||
Returns:
|
||||
list[Strip]: List of strips.
|
||||
"""
|
||||
return [cls(segments, cell_length) for segments in lines]
|
||||
|
||||
@property
|
||||
def cell_length(self) -> int:
|
||||
"""Get the number of cells required to render this object."""
|
||||
# Done on demand and cached, as this is an O(n) operation
|
||||
if self._cell_length is None:
|
||||
self._cell_length = Segment.get_line_length(self._segments)
|
||||
return self._cell_length
|
||||
|
||||
@classmethod
|
||||
def join(cls, strips: Iterable[Strip | None]) -> Strip:
|
||||
"""Join a number of strips in to one.
|
||||
|
||||
Args:
|
||||
strips (Iterable[Strip]): An iterable of Strips.
|
||||
|
||||
Returns:
|
||||
Strip: A new combined strip.
|
||||
"""
|
||||
|
||||
segments: list[list[Segment]] = []
|
||||
add_segments = segments.append
|
||||
total_cell_length = 0
|
||||
for strip in strips:
|
||||
if strip is not None:
|
||||
total_cell_length += strip.cell_length
|
||||
add_segments(strip._segments)
|
||||
strip = cls(chain.from_iterable(segments), total_cell_length)
|
||||
return strip
|
||||
|
||||
def __bool__(self) -> bool:
|
||||
return bool(self._segments)
|
||||
|
||||
def __iter__(self) -> Iterator[Segment]:
|
||||
return iter(self._segments)
|
||||
|
||||
def __reversed__(self) -> Iterator[Segment]:
|
||||
return reversed(self._segments)
|
||||
|
||||
def __len__(self) -> int:
|
||||
return len(self._segments)
|
||||
|
||||
def __eq__(self, strip: object) -> bool:
|
||||
return isinstance(strip, Strip) and (
|
||||
self._segments == strip._segments and self.cell_length == strip.cell_length
|
||||
)
|
||||
|
||||
def adjust_cell_length(self, cell_length: int, style: Style | None = None) -> Strip:
|
||||
"""Adjust the cell length, possibly truncating or extending.
|
||||
|
||||
Args:
|
||||
cell_length (int): New desired cell length.
|
||||
style (Style | None): Style when extending, or `None`. Defaults to `None`.
|
||||
|
||||
Returns:
|
||||
Strip: A new strip with the supplied cell length.
|
||||
"""
|
||||
|
||||
new_line: list[Segment]
|
||||
line = self._segments
|
||||
current_cell_length = self.cell_length
|
||||
|
||||
_Segment = Segment
|
||||
|
||||
if current_cell_length < cell_length:
|
||||
# Cell length is larger, so pad with spaces.
|
||||
new_line = line + [
|
||||
_Segment(" " * (cell_length - current_cell_length), style)
|
||||
]
|
||||
|
||||
elif current_cell_length > cell_length:
|
||||
# Cell length is shorter so we need to truncate.
|
||||
new_line = []
|
||||
append = new_line.append
|
||||
line_length = 0
|
||||
for segment in line:
|
||||
segment_length = segment.cell_length
|
||||
if line_length + segment_length < cell_length:
|
||||
append(segment)
|
||||
line_length += segment_length
|
||||
else:
|
||||
text, segment_style, _ = segment
|
||||
text = set_cell_size(text, cell_length - line_length)
|
||||
append(_Segment(text, segment_style))
|
||||
break
|
||||
else:
|
||||
# Strip is already the required cell length, so return self.
|
||||
return self
|
||||
|
||||
return Strip(new_line, cell_length)
|
||||
|
||||
def simplify(self) -> Strip:
|
||||
"""Simplify the segments (join segments with same style)
|
||||
|
||||
Returns:
|
||||
Strip: New strip.
|
||||
"""
|
||||
line = Strip(
|
||||
Segment.simplify(self._segments),
|
||||
self._cell_length,
|
||||
)
|
||||
return line
|
||||
|
||||
def apply_filter(self, filter: LineFilter) -> Strip:
|
||||
"""Apply a filter to all segments in the strip.
|
||||
|
||||
Args:
|
||||
filter (LineFilter): A line filter object.
|
||||
|
||||
Returns:
|
||||
Strip: A new Strip.
|
||||
"""
|
||||
return Strip(filter.apply(self._segments), self._cell_length)
|
||||
|
||||
def style_links(self, link_id: str, link_style: Style) -> Strip:
|
||||
"""Apply a style to Segments with the given link_id.
|
||||
|
||||
Args:
|
||||
link_id (str): A link id.
|
||||
link_style (Style): Style to apply.
|
||||
|
||||
Returns:
|
||||
Strip: New strip (or same Strip if no changes).
|
||||
"""
|
||||
_Segment = Segment
|
||||
if not any(
|
||||
segment.style._link_id == link_id
|
||||
for segment in self._segments
|
||||
if segment.style
|
||||
):
|
||||
return self
|
||||
segments = [
|
||||
_Segment(
|
||||
text,
|
||||
(style + link_style if style is not None else None)
|
||||
if (style and not style._null and style._link_id == link_id)
|
||||
else style,
|
||||
control,
|
||||
)
|
||||
for text, style, control in self._segments
|
||||
]
|
||||
return Strip(segments, self._cell_length)
|
||||
|
||||
def crop(self, start: int, end: int) -> Strip:
|
||||
"""Crop a strip between two cell positions.
|
||||
|
||||
Args:
|
||||
start (int): The start cell position (inclusive).
|
||||
end (int): The end cell position (exclusive).
|
||||
|
||||
Returns:
|
||||
Strip: A new Strip.
|
||||
"""
|
||||
if start == 0 and end == self.cell_length:
|
||||
return self
|
||||
cache_key = (start, end)
|
||||
cached = self._crop_cache.get(cache_key)
|
||||
if cached is not None:
|
||||
return cached
|
||||
_cell_len = cell_len
|
||||
pos = 0
|
||||
output_segments: list[Segment] = []
|
||||
add_segment = output_segments.append
|
||||
iter_segments = iter(self._segments)
|
||||
segment: Segment | None = None
|
||||
if start > self.cell_length:
|
||||
strip = Strip([], 0)
|
||||
else:
|
||||
for segment in iter_segments:
|
||||
end_pos = pos + _cell_len(segment.text)
|
||||
if end_pos > start:
|
||||
segment = segment.split_cells(start - pos)[1]
|
||||
break
|
||||
pos = end_pos
|
||||
|
||||
if end >= self.cell_length:
|
||||
# The end crop is the end of the segments, so we can collect all remaining segments
|
||||
if segment:
|
||||
add_segment(segment)
|
||||
output_segments.extend(iter_segments)
|
||||
strip = Strip(output_segments, self.cell_length - start)
|
||||
else:
|
||||
pos = start
|
||||
while segment is not None:
|
||||
end_pos = pos + _cell_len(segment.text)
|
||||
if end_pos < end:
|
||||
add_segment(segment)
|
||||
else:
|
||||
add_segment(segment.split_cells(end - pos)[0])
|
||||
break
|
||||
pos = end_pos
|
||||
segment = next(iter_segments, None)
|
||||
strip = Strip(output_segments, end - start)
|
||||
self._crop_cache[cache_key] = strip
|
||||
return strip
|
||||
|
||||
def divide(self, cuts: Iterable[int]) -> list[Strip]:
|
||||
"""Divide the strip in to multiple smaller strips by cutting at given (cell) indices.
|
||||
|
||||
Args:
|
||||
cuts (Iterable[int]): An iterable of cell positions as ints.
|
||||
|
||||
Returns:
|
||||
list[Strip]: A new list of strips.
|
||||
"""
|
||||
|
||||
pos = 0
|
||||
cache_key = tuple(cuts)
|
||||
cached = self._divide_cache.get(cache_key)
|
||||
if cached is not None:
|
||||
return cached
|
||||
|
||||
strips: list[Strip] = []
|
||||
add_strip = strips.append
|
||||
for segments, cut in zip(Segment.divide(self._segments, cuts), cuts):
|
||||
add_strip(Strip(segments, cut - pos))
|
||||
pos += cut
|
||||
|
||||
self._divide_cache[cache_key] = strips
|
||||
return strips
|
||||
@@ -9,19 +9,15 @@ from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import weakref
|
||||
from asyncio import (
|
||||
CancelledError,
|
||||
Event,
|
||||
Task,
|
||||
)
|
||||
from asyncio import CancelledError, Event, Task
|
||||
from typing import Awaitable, Callable, Union
|
||||
|
||||
from rich.repr import Result, rich_repr
|
||||
|
||||
from . import events
|
||||
from . import _clock, events
|
||||
from ._callback import invoke
|
||||
from ._context import active_app
|
||||
from . import _clock
|
||||
from ._time import sleep
|
||||
from ._types import MessageTarget
|
||||
|
||||
TimerCallback = Union[Callable[[], Awaitable[None]], Callable[[], None]]
|
||||
@@ -140,6 +136,7 @@ class Timer:
|
||||
_interval = self._interval
|
||||
await self._active.wait()
|
||||
start = _clock.get_time_no_wait()
|
||||
|
||||
while _repeat is None or count <= _repeat:
|
||||
next_timer = start + ((count + 1) * _interval)
|
||||
now = await _clock.get_time()
|
||||
@@ -148,8 +145,8 @@ class Timer:
|
||||
continue
|
||||
now = await _clock.get_time()
|
||||
wait_time = max(0, next_timer - now)
|
||||
if wait_time:
|
||||
await _clock.sleep(wait_time)
|
||||
if wait_time > 1 / 1000:
|
||||
await sleep(wait_time)
|
||||
|
||||
count += 1
|
||||
await self._active.wait()
|
||||
|
||||
@@ -37,13 +37,12 @@ from rich.text import Text
|
||||
from . import errors, events, messages
|
||||
from ._animator import DEFAULT_EASING, Animatable, BoundAnimator, EasingFunction
|
||||
from ._arrange import DockArrangeResult, arrange
|
||||
from ._cache import LRUCache
|
||||
from ._context import active_app
|
||||
from ._easing import DEFAULT_SCROLL_EASING
|
||||
from ._layout import Layout
|
||||
from ._segment_tools import align_lines
|
||||
from ._styles_cache import StylesCache
|
||||
from ._types import Lines
|
||||
from .actions import SkipAction
|
||||
from .await_remove import AwaitRemove
|
||||
from .binding import Binding
|
||||
from .box_model import BoxModel, get_box_model
|
||||
@@ -56,6 +55,7 @@ from .message import Message
|
||||
from .messages import CallbackType
|
||||
from .reactive import Reactive
|
||||
from .render import measure
|
||||
from .strip import Strip
|
||||
from .walk import walk_depth_first
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@@ -85,7 +85,8 @@ class AwaitMount:
|
||||
|
||||
"""
|
||||
|
||||
def __init__(self, widgets: Sequence[Widget]) -> None:
|
||||
def __init__(self, parent: Widget, widgets: Sequence[Widget]) -> None:
|
||||
self._parent = parent
|
||||
self._widgets = widgets
|
||||
|
||||
def __await__(self) -> Generator[None, None, None]:
|
||||
@@ -97,6 +98,7 @@ class AwaitMount:
|
||||
]
|
||||
if aws:
|
||||
await wait(aws)
|
||||
self._parent.refresh(layout=True)
|
||||
|
||||
return await_mount().__await__()
|
||||
|
||||
@@ -153,7 +155,7 @@ class RenderCache(NamedTuple):
|
||||
"""Stores results of a previous render."""
|
||||
|
||||
size: Size
|
||||
lines: Lines
|
||||
lines: list[Strip]
|
||||
|
||||
|
||||
class WidgetError(Exception):
|
||||
@@ -188,8 +190,10 @@ class Widget(DOMNode):
|
||||
Widget{
|
||||
scrollbar-background: $panel-darken-1;
|
||||
scrollbar-background-hover: $panel-darken-2;
|
||||
scrollbar-background-active: $panel-darken-3;
|
||||
scrollbar-color: $primary-lighten-1;
|
||||
scrollbar-color-active: $warning-darken-1;
|
||||
scrollbar-color-hover: $primary-lighten-1;
|
||||
scrollbar-corner-color: $panel-darken-1;
|
||||
scrollbar-size-vertical: 2;
|
||||
scrollbar-size-horizontal: 1;
|
||||
@@ -247,8 +251,8 @@ class Widget(DOMNode):
|
||||
self._content_width_cache: tuple[object, int] = (None, 0)
|
||||
self._content_height_cache: tuple[object, int] = (None, 0)
|
||||
|
||||
self._arrangement_cache_updates: int = -1
|
||||
self._arrangement_cache: LRUCache[Size, DockArrangeResult] = LRUCache(4)
|
||||
self._arrangement_cache_key: tuple[Size, int] = (Size(), -1)
|
||||
self._cached_arrangement: DockArrangeResult | None = None
|
||||
|
||||
self._styles_cache = StylesCache()
|
||||
self._rich_style_cache: dict[str, tuple[Style, Style]] = {}
|
||||
@@ -460,22 +464,23 @@ class Widget(DOMNode):
|
||||
"""
|
||||
assert self.is_container
|
||||
|
||||
if self._arrangement_cache_updates != self.children._updates:
|
||||
self._arrangement_cache_updates = self.children._updates
|
||||
self._arrangement_cache.clear()
|
||||
cache_key = (size, self.children._updates)
|
||||
if (
|
||||
self._arrangement_cache_key == cache_key
|
||||
and self._cached_arrangement is not None
|
||||
):
|
||||
return self._cached_arrangement
|
||||
|
||||
cached_arrangement = self._arrangement_cache.get(size, None)
|
||||
if cached_arrangement is not None:
|
||||
return cached_arrangement
|
||||
|
||||
arrangement = self._arrangement_cache[size] = arrange(
|
||||
self._arrangement_cache_key = cache_key
|
||||
arrangement = self._cached_arrangement = arrange(
|
||||
self, self.children, size, self.screen.size
|
||||
)
|
||||
|
||||
return arrangement
|
||||
|
||||
def _clear_arrangement_cache(self) -> None:
|
||||
"""Clear arrangement cache, forcing a new arrange operation."""
|
||||
self._arrangement_cache.clear()
|
||||
self._cached_arrangement = None
|
||||
|
||||
def _get_virtual_dom(self) -> Iterable[Widget]:
|
||||
"""Get widgets not part of the DOM.
|
||||
@@ -595,12 +600,12 @@ class Widget(DOMNode):
|
||||
else:
|
||||
parent = self
|
||||
|
||||
return AwaitMount(
|
||||
self.app._register(
|
||||
parent, *widgets, before=insert_before, after=insert_after
|
||||
)
|
||||
mounted = self.app._register(
|
||||
parent, *widgets, before=insert_before, after=insert_after
|
||||
)
|
||||
|
||||
return AwaitMount(self, mounted)
|
||||
|
||||
def move_child(
|
||||
self,
|
||||
child: int | Widget,
|
||||
@@ -1805,7 +1810,7 @@ class Widget(DOMNode):
|
||||
if spacing is not None:
|
||||
window = window.shrink(spacing)
|
||||
|
||||
if window in region:
|
||||
if window in region and not top:
|
||||
return Offset()
|
||||
|
||||
delta_x, delta_y = Region.get_scroll_to_visible(window, region, top=top)
|
||||
@@ -2094,11 +2099,11 @@ class Widget(DOMNode):
|
||||
align_vertical,
|
||||
)
|
||||
)
|
||||
|
||||
self._render_cache = RenderCache(self.size, lines)
|
||||
strips = [Strip(line, width) for line in lines]
|
||||
self._render_cache = RenderCache(self.size, strips)
|
||||
self._dirty_regions.clear()
|
||||
|
||||
def render_line(self, y: int) -> list[Segment]:
|
||||
def render_line(self, y: int) -> Strip:
|
||||
"""Render a line of content.
|
||||
|
||||
Args:
|
||||
@@ -2112,10 +2117,10 @@ class Widget(DOMNode):
|
||||
try:
|
||||
line = self._render_cache.lines[y]
|
||||
except IndexError:
|
||||
line = [Segment(" " * self.size.width, self.rich_style)]
|
||||
line = Strip.blank(self.size.width, self.rich_style)
|
||||
return line
|
||||
|
||||
def render_lines(self, crop: Region) -> Lines:
|
||||
def render_lines(self, crop: Region) -> list[Strip]:
|
||||
"""Render the widget in to lines.
|
||||
|
||||
Args:
|
||||
@@ -2124,8 +2129,8 @@ class Widget(DOMNode):
|
||||
Returns:
|
||||
Lines: A list of list of segments.
|
||||
"""
|
||||
lines = self._styles_cache.render_widget(self, crop)
|
||||
return lines
|
||||
strips = self._styles_cache.render_widget(self, crop)
|
||||
return strips
|
||||
|
||||
def get_style_at(self, x: int, y: int) -> Style:
|
||||
"""Get the Rich style in a widget at a given relative offset.
|
||||
@@ -2173,8 +2178,10 @@ class Widget(DOMNode):
|
||||
|
||||
if layout:
|
||||
self._layout_required = True
|
||||
if isinstance(self._parent, Widget):
|
||||
self._parent._clear_arrangement_cache()
|
||||
for ancestor in self.ancestors:
|
||||
if not isinstance(ancestor, Widget):
|
||||
break
|
||||
ancestor._clear_arrangement_cache()
|
||||
|
||||
if repaint:
|
||||
self._set_dirty(*regions)
|
||||
@@ -2342,17 +2349,22 @@ class Widget(DOMNode):
|
||||
self.mouse_over = True
|
||||
|
||||
def _on_focus(self, event: events.Focus) -> None:
|
||||
for node in self.ancestors_with_self:
|
||||
if node._has_focus_within:
|
||||
self.app.update_styles(node)
|
||||
self.has_focus = True
|
||||
self.refresh()
|
||||
self.emit_no_wait(events.DescendantFocus(self))
|
||||
|
||||
def _on_blur(self, event: events.Blur) -> None:
|
||||
if any(node._has_focus_within for node in self.ancestors_with_self):
|
||||
self.app.update_styles(self)
|
||||
self.has_focus = False
|
||||
self.refresh()
|
||||
self.emit_no_wait(events.DescendantBlur(self))
|
||||
|
||||
def _on_descendant_blur(self, event: events.DescendantBlur) -> None:
|
||||
if self._has_focus_within:
|
||||
self.app.update_styles(self)
|
||||
|
||||
def _on_descendant_focus(self, event: events.DescendantBlur) -> None:
|
||||
if self._has_focus_within:
|
||||
self.app.update_styles(self)
|
||||
|
||||
def _on_mouse_scroll_down(self, event) -> None:
|
||||
if self.allow_vertical_scroll:
|
||||
@@ -2397,33 +2409,41 @@ class Widget(DOMNode):
|
||||
self.scroll_to_region(message.region, animate=True)
|
||||
|
||||
def action_scroll_home(self) -> None:
|
||||
if self._allow_scroll:
|
||||
self.scroll_home()
|
||||
if not self._allow_scroll:
|
||||
raise SkipAction()
|
||||
self.scroll_home()
|
||||
|
||||
def action_scroll_end(self) -> None:
|
||||
if self._allow_scroll:
|
||||
self.scroll_end()
|
||||
if not self._allow_scroll:
|
||||
raise SkipAction()
|
||||
self.scroll_end()
|
||||
|
||||
def action_scroll_left(self) -> None:
|
||||
if self.allow_horizontal_scroll:
|
||||
self.scroll_left()
|
||||
if not self.allow_horizontal_scroll:
|
||||
raise SkipAction()
|
||||
self.scroll_left()
|
||||
|
||||
def action_scroll_right(self) -> None:
|
||||
if self.allow_horizontal_scroll:
|
||||
self.scroll_right()
|
||||
if not self.allow_horizontal_scroll:
|
||||
raise SkipAction()
|
||||
self.scroll_right()
|
||||
|
||||
def action_scroll_up(self) -> None:
|
||||
if self.allow_vertical_scroll:
|
||||
self.scroll_up()
|
||||
if not self.allow_vertical_scroll:
|
||||
raise SkipAction()
|
||||
self.scroll_up()
|
||||
|
||||
def action_scroll_down(self) -> None:
|
||||
if self.allow_vertical_scroll:
|
||||
self.scroll_down()
|
||||
if not self.allow_vertical_scroll:
|
||||
raise SkipAction()
|
||||
self.scroll_down()
|
||||
|
||||
def action_page_down(self) -> None:
|
||||
if self.allow_vertical_scroll:
|
||||
self.scroll_page_down()
|
||||
if not self.allow_vertical_scroll:
|
||||
raise SkipAction()
|
||||
self.scroll_page_down()
|
||||
|
||||
def action_page_up(self) -> None:
|
||||
if self.allow_vertical_scroll:
|
||||
self.scroll_page_up()
|
||||
if not self.allow_vertical_scroll:
|
||||
raise SkipAction()
|
||||
self.scroll_page_up()
|
||||
|
||||
@@ -14,11 +14,12 @@ from rich.text import Text, TextType
|
||||
from .. import events, messages
|
||||
from .._cache import LRUCache
|
||||
from .._segment_tools import line_crop
|
||||
from .._types import Lines
|
||||
from .._types import SegmentLines
|
||||
from ..geometry import Region, Size, Spacing, clamp
|
||||
from ..reactive import Reactive
|
||||
from ..render import measure
|
||||
from ..scroll_view import ScrollView
|
||||
from ..strip import Strip
|
||||
from .._typing import Literal
|
||||
|
||||
CursorType = Literal["cell", "row", "column"]
|
||||
@@ -207,14 +208,14 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True):
|
||||
self.row_count = 0
|
||||
self._y_offsets: list[tuple[int, int]] = []
|
||||
self._row_render_cache: LRUCache[
|
||||
tuple[int, int, Style, int, int], tuple[Lines, Lines]
|
||||
tuple[int, int, Style, int, int], tuple[SegmentLines, SegmentLines]
|
||||
]
|
||||
self._row_render_cache = LRUCache(1000)
|
||||
self._cell_render_cache: LRUCache[tuple[int, int, Style, bool, bool], Lines]
|
||||
self._cell_render_cache = LRUCache(10000)
|
||||
self._line_cache: LRUCache[
|
||||
tuple[int, int, int, int, int, int, Style], list[Segment]
|
||||
self._cell_render_cache: LRUCache[
|
||||
tuple[int, int, Style, bool, bool], SegmentLines
|
||||
]
|
||||
self._cell_render_cache = LRUCache(10000)
|
||||
self._line_cache: LRUCache[tuple[int, int, int, int, int, int, Style], Strip]
|
||||
self._line_cache = LRUCache(1000)
|
||||
|
||||
self._line_no = 0
|
||||
@@ -312,7 +313,7 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True):
|
||||
cell_region = Region(x, y, width, height)
|
||||
return cell_region
|
||||
|
||||
def clear(self) -> None:
|
||||
def clear(self, columns: bool = False) -> None:
|
||||
"""Clear the table.
|
||||
|
||||
Args:
|
||||
@@ -323,6 +324,8 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True):
|
||||
self._y_offsets.clear()
|
||||
self.data.clear()
|
||||
self.rows.clear()
|
||||
if columns:
|
||||
self.columns.clear()
|
||||
self._line_no = 0
|
||||
self._require_update_dimensions = True
|
||||
self.refresh()
|
||||
@@ -450,7 +453,7 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True):
|
||||
width: int,
|
||||
cursor: bool = False,
|
||||
hover: bool = False,
|
||||
) -> Lines:
|
||||
) -> SegmentLines:
|
||||
"""Render the given cell.
|
||||
|
||||
Args:
|
||||
@@ -488,7 +491,7 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True):
|
||||
base_style: Style,
|
||||
cursor_column: int = -1,
|
||||
hover_column: int = -1,
|
||||
) -> tuple[Lines, Lines]:
|
||||
) -> tuple[SegmentLines, SegmentLines]:
|
||||
"""Render a row in to lines for each cell.
|
||||
|
||||
Args:
|
||||
@@ -563,9 +566,7 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True):
|
||||
raise LookupError("Y coord {y!r} is greater than total height")
|
||||
return self._y_offsets[y]
|
||||
|
||||
def _render_line(
|
||||
self, y: int, x1: int, x2: int, base_style: Style
|
||||
) -> list[Segment]:
|
||||
def _render_line(self, y: int, x1: int, x2: int, base_style: Style) -> Strip:
|
||||
"""Render a line in to a list of segments.
|
||||
|
||||
Args:
|
||||
@@ -583,7 +584,7 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True):
|
||||
try:
|
||||
row_index, line_no = self._get_offsets(y)
|
||||
except LookupError:
|
||||
return [Segment(" " * width, base_style)]
|
||||
return Strip.blank(width, base_style)
|
||||
cursor_column = (
|
||||
self.cursor_column
|
||||
if (self.show_cursor and self.cursor_row == row_index)
|
||||
@@ -610,13 +611,12 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True):
|
||||
scrollable_line: list[Segment] = list(chain.from_iterable(scrollable))
|
||||
|
||||
segments = fixed_line + line_crop(scrollable_line, x1 + fixed_width, x2, width)
|
||||
segments = Segment.adjust_line_length(segments, width, style=base_style)
|
||||
simplified_segments = list(Segment.simplify(segments))
|
||||
strip = Strip(segments).adjust_cell_length(width, base_style).simplify()
|
||||
|
||||
self._line_cache[cache_key] = simplified_segments
|
||||
return segments
|
||||
self._line_cache[cache_key] = strip
|
||||
return strip
|
||||
|
||||
def render_line(self, y: int) -> list[Segment]:
|
||||
def render_line(self, y: int) -> Strip:
|
||||
width, height = self.size
|
||||
scroll_x, scroll_y = self.scroll_offset
|
||||
fixed_top_row_count = sum(
|
||||
|
||||
@@ -47,19 +47,19 @@ class DirectoryTree(Tree[DirEntry]):
|
||||
|
||||
DEFAULT_CSS = """
|
||||
DirectoryTree > .directory-tree--folder {
|
||||
text-style: bold;
|
||||
text-style: bold;
|
||||
}
|
||||
|
||||
DirectoryTree > .directory-tree--file {
|
||||
|
||||
|
||||
}
|
||||
|
||||
DirectoryTree > .directory-tree--extension {
|
||||
text-style: italic;
|
||||
DirectoryTree > .directory-tree--extension {
|
||||
text-style: italic;
|
||||
}
|
||||
|
||||
DirectoryTree > .directory-tree--hidden {
|
||||
color: $text 50%;
|
||||
color: $text 50%;
|
||||
}
|
||||
"""
|
||||
|
||||
@@ -87,7 +87,7 @@ class DirectoryTree(Tree[DirEntry]):
|
||||
)
|
||||
|
||||
def process_label(self, label: TextType):
|
||||
"""Process a str or Text in to a label. Maybe overridden in a subclass to change modify how labels are rendered.
|
||||
"""Process a str or Text into a label. Maybe overridden in a subclass to modify how labels are rendered.
|
||||
|
||||
Args:
|
||||
label (TextType): Label.
|
||||
|
||||
@@ -91,13 +91,13 @@ class Header(Widget):
|
||||
Header {
|
||||
dock: top;
|
||||
width: 100%;
|
||||
background: $secondary-background;
|
||||
background: $foreground 5%;
|
||||
color: $text;
|
||||
height: 1;
|
||||
}
|
||||
Header.-tall {
|
||||
height: 3;
|
||||
}
|
||||
}
|
||||
"""
|
||||
|
||||
tall = Reactive(False)
|
||||
|
||||
@@ -233,8 +233,8 @@ class Input(Widget, can_focus=True):
|
||||
return
|
||||
elif event.is_printable:
|
||||
event.stop()
|
||||
assert event.char is not None
|
||||
self.insert_text_at_cursor(event.char)
|
||||
assert event.character is not None
|
||||
self.insert_text_at_cursor(event.character)
|
||||
event.prevent_default()
|
||||
|
||||
def on_paste(self, event: events.Paste) -> None:
|
||||
|
||||
@@ -24,7 +24,7 @@ def _check_renderable(renderable: object):
|
||||
)
|
||||
|
||||
|
||||
class Static(Widget):
|
||||
class Static(Widget, inherit_bindings=False):
|
||||
"""A widget to display simple static content, or use as a base class for more complex widgets.
|
||||
|
||||
Args:
|
||||
|
||||
@@ -15,7 +15,7 @@ from ..geometry import Size, Region
|
||||
from ..scroll_view import ScrollView
|
||||
from .._cache import LRUCache
|
||||
from .._segment_tools import line_crop
|
||||
from .._types import Lines
|
||||
from ..strip import Strip
|
||||
|
||||
|
||||
class TextLog(ScrollView, can_focus=True):
|
||||
@@ -48,8 +48,8 @@ class TextLog(ScrollView, can_focus=True):
|
||||
super().__init__(name=name, id=id, classes=classes)
|
||||
self.max_lines = max_lines
|
||||
self._start_line: int = 0
|
||||
self.lines: list[list[Segment]] = []
|
||||
self._line_cache: LRUCache[tuple[int, int, int, int], list[Segment]]
|
||||
self.lines: list[Strip] = []
|
||||
self._line_cache: LRUCache[tuple[int, int, int, int], Strip]
|
||||
self._line_cache = LRUCache(1024)
|
||||
self.max_width: int = 0
|
||||
self.min_width = min_width
|
||||
@@ -61,11 +61,20 @@ class TextLog(ScrollView, can_focus=True):
|
||||
def _on_styles_updated(self) -> None:
|
||||
self._line_cache.clear()
|
||||
|
||||
def write(self, content: RenderableType | object) -> None:
|
||||
def write(
|
||||
self,
|
||||
content: RenderableType | object,
|
||||
width: int | None = None,
|
||||
expand: bool = False,
|
||||
shrink: bool = True,
|
||||
) -> None:
|
||||
"""Write text or a rich renderable.
|
||||
|
||||
Args:
|
||||
content (RenderableType): Rich renderable (or text).
|
||||
width (int): Width to render or None to use optimal width. Defaults to `None`.
|
||||
expand (bool): Enable expand to widget width, or False to use `width`. Defaults to `False`.
|
||||
shrink (bool): Enable shrinking of content to fit width. Defaults to `True`.
|
||||
"""
|
||||
|
||||
renderable: RenderableType
|
||||
@@ -88,13 +97,20 @@ class TextLog(ScrollView, can_focus=True):
|
||||
if isinstance(renderable, Text) and not self.wrap:
|
||||
render_options = render_options.update(overflow="ignore", no_wrap=True)
|
||||
|
||||
width = max(
|
||||
self.min_width,
|
||||
measure_renderables(console, render_options, [renderable]).maximum,
|
||||
render_width = measure_renderables(
|
||||
console, render_options, [renderable]
|
||||
).maximum
|
||||
container_width = (
|
||||
self.scrollable_content_region.width if width is None else width
|
||||
)
|
||||
|
||||
if expand and render_width < container_width:
|
||||
render_width = container_width
|
||||
if shrink and render_width > container_width:
|
||||
render_width = container_width
|
||||
|
||||
segments = self.app.console.render(
|
||||
renderable, render_options.update_width(width)
|
||||
renderable, render_options.update_width(render_width)
|
||||
)
|
||||
lines = list(Segment.split_lines(segments))
|
||||
if not lines:
|
||||
@@ -104,7 +120,8 @@ class TextLog(ScrollView, can_focus=True):
|
||||
self.max_width,
|
||||
max(sum(segment.cell_length for segment in _line) for _line in lines),
|
||||
)
|
||||
self.lines.extend(lines)
|
||||
strips = Strip.from_lines(lines, render_width)
|
||||
self.lines.extend(strips)
|
||||
|
||||
if self.max_lines is not None and len(self.lines) > self.max_lines:
|
||||
self._start_line += len(self.lines) - self.max_lines
|
||||
@@ -115,19 +132,20 @@ class TextLog(ScrollView, can_focus=True):
|
||||
|
||||
def clear(self) -> None:
|
||||
"""Clear the text log."""
|
||||
del self.lines[:]
|
||||
self.lines.clear()
|
||||
self._line_cache.clear()
|
||||
self._start_line = 0
|
||||
self.max_width = 0
|
||||
self.virtual_size = Size(self.max_width, len(self.lines))
|
||||
self.refresh()
|
||||
|
||||
def render_line(self, y: int) -> list[Segment]:
|
||||
def render_line(self, y: int) -> Strip:
|
||||
scroll_x, scroll_y = self.scroll_offset
|
||||
line = self._render_line(scroll_y + y, scroll_x, self.size.width)
|
||||
line = list(Segment.apply_style(line, self.rich_style))
|
||||
return line
|
||||
strip = Strip(Segment.apply_style(line, self.rich_style), self.size.width)
|
||||
return strip
|
||||
|
||||
def render_lines(self, crop: Region) -> Lines:
|
||||
def render_lines(self, crop: Region) -> list[Strip]:
|
||||
"""Render the widget in to lines.
|
||||
|
||||
Args:
|
||||
@@ -139,19 +157,20 @@ class TextLog(ScrollView, can_focus=True):
|
||||
lines = self._styles_cache.render_widget(self, crop)
|
||||
return lines
|
||||
|
||||
def _render_line(self, y: int, scroll_x: int, width: int) -> list[Segment]:
|
||||
def _render_line(self, y: int, scroll_x: int, width: int) -> Strip:
|
||||
|
||||
if y >= len(self.lines):
|
||||
return [Segment(" " * width, self.rich_style)]
|
||||
return Strip.blank(width, self.rich_style)
|
||||
|
||||
key = (y + self._start_line, scroll_x, width, self.max_width)
|
||||
if key in self._line_cache:
|
||||
return self._line_cache[key]
|
||||
|
||||
line = self.lines[y]
|
||||
line = Segment.adjust_line_length(
|
||||
line, max(self.max_width, width), self.rich_style
|
||||
line = (
|
||||
self.lines[y]
|
||||
.adjust_cell_length(max(self.max_width, width), self.rich_style)
|
||||
.crop(scroll_x, scroll_x + width)
|
||||
)
|
||||
line = line_crop(line, scroll_x, scroll_x + width, self.max_width)
|
||||
|
||||
self._line_cache[key] = line
|
||||
return line
|
||||
|
||||
@@ -5,22 +5,21 @@ from typing import ClassVar, Generic, NewType, TypeVar
|
||||
|
||||
import rich.repr
|
||||
from rich.segment import Segment
|
||||
from rich.style import Style, NULL_STYLE
|
||||
from rich.style import NULL_STYLE, Style
|
||||
from rich.text import Text, TextType
|
||||
|
||||
|
||||
from ..binding import Binding
|
||||
from ..geometry import clamp, Region, Size
|
||||
from .._loop import loop_last
|
||||
from .. import events
|
||||
from .._cache import LRUCache
|
||||
from ..message import Message
|
||||
from ..reactive import reactive, var
|
||||
from .._loop import loop_last
|
||||
from .._segment_tools import line_crop, line_pad
|
||||
from .._types import MessageTarget
|
||||
from .._typing import TypeAlias
|
||||
from ..binding import Binding
|
||||
from ..geometry import Region, Size, clamp
|
||||
from ..message import Message
|
||||
from ..reactive import reactive, var
|
||||
from ..scroll_view import ScrollView
|
||||
|
||||
from .. import events
|
||||
from ..strip import Strip
|
||||
|
||||
NodeID = NewType("NodeID", int)
|
||||
TreeDataType = TypeVar("TreeDataType")
|
||||
@@ -32,12 +31,12 @@ TOGGLE_STYLE = Style.from_meta({"toggle": True})
|
||||
|
||||
|
||||
@dataclass
|
||||
class _TreeLine:
|
||||
path: list[TreeNode]
|
||||
class _TreeLine(Generic[TreeDataType]):
|
||||
path: list[TreeNode[TreeDataType]]
|
||||
last: bool
|
||||
|
||||
@property
|
||||
def node(self) -> TreeNode:
|
||||
def node(self) -> TreeNode[TreeDataType]:
|
||||
"""TreeNode: The node associated with this line."""
|
||||
return self.path[-1]
|
||||
|
||||
@@ -72,10 +71,10 @@ class TreeNode(Generic[TreeDataType]):
|
||||
self._tree = tree
|
||||
self._parent = parent
|
||||
self._id = id
|
||||
self._label = label
|
||||
self._label = tree.process_label(label)
|
||||
self.data = data
|
||||
self._expanded = expanded
|
||||
self._children: list[TreeNode] = []
|
||||
self._children: list[TreeNode[TreeDataType]] = []
|
||||
|
||||
self._hover_ = False
|
||||
self._selected_ = False
|
||||
@@ -164,6 +163,15 @@ class TreeNode(Generic[TreeDataType]):
|
||||
self._updates += 1
|
||||
self._tree._invalidate()
|
||||
|
||||
@property
|
||||
def label(self) -> TextType:
|
||||
"""TextType: The label for the node."""
|
||||
return self._label
|
||||
|
||||
@label.setter
|
||||
def label(self, new_label: TextType) -> None:
|
||||
self.set_label(new_label)
|
||||
|
||||
def set_label(self, label: TextType) -> None:
|
||||
"""Set a new label for the node.
|
||||
|
||||
@@ -365,7 +373,7 @@ class Tree(Generic[TreeDataType], ScrollView, can_focus=True):
|
||||
self._current_id = 0
|
||||
self.root = self._add_node(None, text_label, data)
|
||||
|
||||
self._line_cache: LRUCache[LineCacheKey, list[Segment]] = LRUCache(1024)
|
||||
self._line_cache: LRUCache[LineCacheKey, Strip] = LRUCache(1024)
|
||||
self._tree_lines_cached: list[_TreeLine] | None = None
|
||||
self._cursor_node: TreeNode[TreeDataType] | None = None
|
||||
|
||||
@@ -451,6 +459,7 @@ class Tree(Generic[TreeDataType], ScrollView, can_focus=True):
|
||||
|
||||
def clear(self) -> None:
|
||||
"""Clear all nodes under root."""
|
||||
self._line_cache.clear()
|
||||
self._tree_lines_cached = None
|
||||
self._current_id = 0
|
||||
root_label = self.root._label
|
||||
@@ -466,11 +475,11 @@ class Tree(Generic[TreeDataType], ScrollView, can_focus=True):
|
||||
self._updates += 1
|
||||
self.refresh()
|
||||
|
||||
def select_node(self, node: TreeNode | None) -> None:
|
||||
def select_node(self, node: TreeNode[TreeDataType] | None) -> None:
|
||||
"""Move the cursor to the given node, or reset cursor.
|
||||
|
||||
Args:
|
||||
node (TreeNode | None): A tree node, or None to reset cursor.
|
||||
node (TreeNode[TreeDataType] | None): A tree node, or None to reset cursor.
|
||||
"""
|
||||
self.cursor_line = -1 if node is None else node._line
|
||||
|
||||
@@ -570,11 +579,11 @@ class Tree(Generic[TreeDataType], ScrollView, can_focus=True):
|
||||
"""
|
||||
self.scroll_to_region(Region(0, line, self.size.width, 1))
|
||||
|
||||
def scroll_to_node(self, node: TreeNode) -> None:
|
||||
def scroll_to_node(self, node: TreeNode[TreeDataType]) -> None:
|
||||
"""Scroll to the given node.
|
||||
|
||||
Args:
|
||||
node (TreeNode): Node to scroll in to view.
|
||||
node (TreeNode[TreeDataType]): Node to scroll in to view.
|
||||
"""
|
||||
line = node._line
|
||||
if line != -1:
|
||||
@@ -614,6 +623,11 @@ class Tree(Generic[TreeDataType], ScrollView, can_focus=True):
|
||||
assert self._tree_lines_cached is not None
|
||||
return self._tree_lines_cached
|
||||
|
||||
def _on_idle(self) -> None:
|
||||
"""Check tree needs a rebuild on idle."""
|
||||
# Property calls build if required
|
||||
self._tree_lines
|
||||
|
||||
def _build(self) -> None:
|
||||
"""Builds the tree by traversing nodes, and creating tree lines."""
|
||||
|
||||
@@ -623,7 +637,9 @@ class Tree(Generic[TreeDataType], ScrollView, can_focus=True):
|
||||
|
||||
root = self.root
|
||||
|
||||
def add_node(path: list[TreeNode], node: TreeNode, last: bool) -> None:
|
||||
def add_node(
|
||||
path: list[TreeNode[TreeDataType]], node: TreeNode[TreeDataType], last: bool
|
||||
) -> None:
|
||||
child_path = [*path, node]
|
||||
node._line = len(lines)
|
||||
add_line(TreeLine(child_path, last))
|
||||
@@ -660,7 +676,7 @@ class Tree(Generic[TreeDataType], ScrollView, can_focus=True):
|
||||
self.cursor_line = -1
|
||||
self.refresh()
|
||||
|
||||
def render_line(self, y: int) -> list[Segment]:
|
||||
def render_line(self, y: int) -> Strip:
|
||||
width = self.size.width
|
||||
scroll_x, scroll_y = self.scroll_offset
|
||||
style = self.rich_style
|
||||
@@ -671,14 +687,12 @@ class Tree(Generic[TreeDataType], ScrollView, can_focus=True):
|
||||
style,
|
||||
)
|
||||
|
||||
def _render_line(
|
||||
self, y: int, x1: int, x2: int, base_style: Style
|
||||
) -> list[Segment]:
|
||||
def _render_line(self, y: int, x1: int, x2: int, base_style: Style) -> Strip:
|
||||
tree_lines = self._tree_lines
|
||||
width = self.size.width
|
||||
|
||||
if y >= len(tree_lines):
|
||||
return [Segment(" " * width, base_style)]
|
||||
return Strip.blank(width, base_style)
|
||||
|
||||
line = tree_lines[y]
|
||||
|
||||
@@ -693,7 +707,7 @@ class Tree(Generic[TreeDataType], ScrollView, can_focus=True):
|
||||
tuple(node._updates for node in line.path),
|
||||
)
|
||||
if cache_key in self._line_cache:
|
||||
segments = self._line_cache[cache_key]
|
||||
strip = self._line_cache[cache_key]
|
||||
else:
|
||||
base_guide_style = self.get_component_rich_style(
|
||||
"tree--guides", partial=True
|
||||
@@ -779,11 +793,10 @@ class Tree(Generic[TreeDataType], ScrollView, can_focus=True):
|
||||
segments = list(guides.render(self.app.console))
|
||||
pad_width = max(self.virtual_size.width, width)
|
||||
segments = line_pad(segments, 0, pad_width - guides.cell_len, line_style)
|
||||
self._line_cache[cache_key] = segments
|
||||
strip = self._line_cache[cache_key] = Strip(segments)
|
||||
|
||||
segments = line_crop(segments, x1, x2, width)
|
||||
|
||||
return segments
|
||||
strip = strip.crop(x1, x2)
|
||||
return strip
|
||||
|
||||
def _on_resize(self, event: events.Resize) -> None:
|
||||
self._line_cache.grow(event.size.height)
|
||||
@@ -805,7 +818,7 @@ class Tree(Generic[TreeDataType], ScrollView, can_focus=True):
|
||||
cursor_line = meta["line"]
|
||||
if meta.get("toggle", False):
|
||||
node = self.get_node_at_line(cursor_line)
|
||||
if node is not None and self.auto_expand:
|
||||
if node is not None:
|
||||
self._toggle_node(node)
|
||||
|
||||
else:
|
||||
|
||||
@@ -188,13 +188,12 @@ def pytest_terminal_summary(
|
||||
Displays the link to the snapshot report that was generated in a prior hook.
|
||||
"""
|
||||
diffs = getattr(config, "_textual_snapshots", None)
|
||||
console = Console()
|
||||
console = Console(legacy_windows=False, force_terminal=True)
|
||||
if diffs:
|
||||
snapshot_report_location = config._textual_snapshot_html_report
|
||||
console.rule("[b red]Textual Snapshot Report", style="red")
|
||||
console.print("[b red]Textual Snapshot Report", style="red")
|
||||
console.print(
|
||||
f"\n[black on red]{len(diffs)} mismatched snapshots[/]\n"
|
||||
f"\n[b]View the [link=file://{snapshot_report_location}]failure report[/].\n"
|
||||
)
|
||||
console.print(f"[dim]{snapshot_report_location}\n")
|
||||
console.rule(style="red")
|
||||
|
||||
64
tests/snapshot_tests/snapshot_apps/nested_auto_heights.py
Normal file
@@ -0,0 +1,64 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from textual.app import App, ComposeResult
|
||||
from textual.containers import Vertical
|
||||
from textual.widgets import Static
|
||||
|
||||
|
||||
class NestedAutoApp(App[None]):
|
||||
CSS = """
|
||||
Screen {
|
||||
background: red;
|
||||
}
|
||||
|
||||
#my-static-container {
|
||||
border: heavy lightgreen;
|
||||
background: green;
|
||||
height: auto;
|
||||
max-height: 10;
|
||||
}
|
||||
|
||||
#my-static-wrapper {
|
||||
border: heavy lightblue;
|
||||
background: blue;
|
||||
width: auto;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
#my-static {
|
||||
border: heavy gray;
|
||||
background: black;
|
||||
width: auto;
|
||||
height: auto;
|
||||
}
|
||||
"""
|
||||
BINDINGS = [
|
||||
("1", "1", "1"),
|
||||
("2", "2", "2"),
|
||||
("q", "quit", "Quit"),
|
||||
]
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
self._static = Static("", id="my-static")
|
||||
yield Vertical(
|
||||
Vertical(
|
||||
self._static,
|
||||
id="my-static-wrapper",
|
||||
),
|
||||
id="my-static-container",
|
||||
)
|
||||
|
||||
def action_1(self) -> None:
|
||||
self._static.update(
|
||||
"\n".join(f"Lorem {i} Ipsum {i} Sit {i}" for i in range(1, 21))
|
||||
)
|
||||
|
||||
def action_2(self) -> None:
|
||||
self._static.update("JUST ONE LINE")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
app = NestedAutoApp()
|
||||
app.run()
|
||||
@@ -71,7 +71,8 @@ def test_input_and_focus(snap_compare):
|
||||
"tab",
|
||||
*"Darren", # Focus first input, write "Darren"
|
||||
"tab",
|
||||
*"Burns", # Tab focus to second input, write "Burns"
|
||||
*"Burns",
|
||||
"_", # Tab focus to second input, write "Burns"
|
||||
]
|
||||
assert snap_compare(WIDGET_EXAMPLES_DIR / "input.py", press=press)
|
||||
|
||||
@@ -101,7 +102,9 @@ def test_header_render(snap_compare):
|
||||
|
||||
|
||||
def test_list_view(snap_compare):
|
||||
assert snap_compare(WIDGET_EXAMPLES_DIR / "list_view.py", press=["tab", "down", "down", "up"])
|
||||
assert snap_compare(
|
||||
WIDGET_EXAMPLES_DIR / "list_view.py", press=["tab", "down", "down", "up"]
|
||||
)
|
||||
|
||||
|
||||
def test_textlog_max_lines(snap_compare):
|
||||
@@ -160,6 +163,11 @@ def test_offsets(snap_compare):
|
||||
assert snap_compare("snapshot_apps/offsets.py")
|
||||
|
||||
|
||||
def test_nested_auto_heights(snap_compare):
|
||||
"""Test refreshing widget within a auto sized container"""
|
||||
assert snap_compare("snapshot_apps/nested_auto_heights.py", press=["1", "2", "_"])
|
||||
|
||||
|
||||
# --- Other ---
|
||||
|
||||
|
||||
@@ -169,4 +177,8 @@ def test_key_display(snap_compare):
|
||||
|
||||
def test_demo(snap_compare):
|
||||
"""Test the demo app (python -m textual)"""
|
||||
assert snap_compare(Path("../../src/textual/demo.py"))
|
||||
assert snap_compare(
|
||||
Path("../../src/textual/demo.py"),
|
||||
press=["down", "down", "down", "_", "_"],
|
||||
terminal_size=(100, 30),
|
||||
)
|
||||
|
||||
@@ -2,10 +2,12 @@ from string import ascii_lowercase
|
||||
|
||||
import pytest
|
||||
|
||||
from textual.binding import Bindings, Binding, BindingError, NoBinding
|
||||
from textual.app import App
|
||||
from textual.binding import Bindings, Binding, BindingError, NoBinding, InvalidBinding
|
||||
|
||||
BINDING1 = Binding("a,b", action="action1", description="description1")
|
||||
BINDING2 = Binding("c", action="action2", description="description2")
|
||||
BINDING3 = Binding(" d , e ", action="action3", description="description3")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
@@ -13,38 +15,77 @@ def bindings():
|
||||
yield Bindings([BINDING1, BINDING2])
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def more_bindings():
|
||||
yield Bindings([BINDING1, BINDING2, BINDING3])
|
||||
|
||||
|
||||
def test_bindings_get_key(bindings):
|
||||
assert bindings.get_key("b") == Binding("b", action="action1", description="description1")
|
||||
assert bindings.get_key("b") == Binding(
|
||||
"b", action="action1", description="description1"
|
||||
)
|
||||
assert bindings.get_key("c") == BINDING2
|
||||
with pytest.raises(NoBinding):
|
||||
bindings.get_key("control+meta+alt+shift+super+hyper+t")
|
||||
|
||||
|
||||
def test_bindings_get_key_spaced_list(more_bindings):
|
||||
assert more_bindings.get_key("d").action == more_bindings.get_key("e").action
|
||||
|
||||
|
||||
def test_bindings_merge_simple(bindings):
|
||||
left = Bindings([BINDING1])
|
||||
right = Bindings([BINDING2])
|
||||
assert Bindings.merge([left, right]).keys == bindings.keys
|
||||
|
||||
|
||||
def test_bindings_merge_overlap():
|
||||
left = Bindings([BINDING1])
|
||||
another_binding = Binding("a", action="another_action", description="another_description")
|
||||
another_binding = Binding(
|
||||
"a", action="another_action", description="another_description"
|
||||
)
|
||||
assert Bindings.merge([left, Bindings([another_binding])]).keys == {
|
||||
"a": another_binding,
|
||||
"b": Binding("b", action="action1", description="description1"),
|
||||
}
|
||||
|
||||
|
||||
def test_bad_binding_tuple():
|
||||
with pytest.raises(BindingError):
|
||||
_ = Bindings((("a", "action"),))
|
||||
with pytest.raises(BindingError):
|
||||
_ = Bindings((("a", "action", "description","too much"),))
|
||||
_ = Bindings((("a", "action", "description", "too much"),))
|
||||
|
||||
|
||||
def test_binding_from_tuples():
|
||||
assert Bindings((( BINDING2.key, BINDING2.action, BINDING2.description),)).get_key("c") == BINDING2
|
||||
assert (
|
||||
Bindings(((BINDING2.key, BINDING2.action, BINDING2.description),)).get_key("c")
|
||||
== BINDING2
|
||||
)
|
||||
|
||||
|
||||
def test_shown():
|
||||
bindings = Bindings([
|
||||
Binding(
|
||||
key, action=f"action_{key}", description=f"Emits {key}",show=bool(ord(key)%2)
|
||||
) for key in ascii_lowercase
|
||||
])
|
||||
assert len(bindings.shown_keys)==(len(ascii_lowercase)/2)
|
||||
bindings = Bindings(
|
||||
[
|
||||
Binding(
|
||||
key,
|
||||
action=f"action_{key}",
|
||||
description=f"Emits {key}",
|
||||
show=bool(ord(key) % 2),
|
||||
)
|
||||
for key in ascii_lowercase
|
||||
]
|
||||
)
|
||||
assert len(bindings.shown_keys) == (len(ascii_lowercase) / 2)
|
||||
|
||||
|
||||
def test_invalid_binding():
|
||||
with pytest.raises(InvalidBinding):
|
||||
|
||||
class BrokenApp(App):
|
||||
BINDINGS = [(",,,", "foo", "Broken")]
|
||||
|
||||
with pytest.raises(InvalidBinding):
|
||||
|
||||
class BrokenApp(App):
|
||||
BINDINGS = [(", ,", "foo", "Broken")]
|
||||
|
||||
651
tests/test_binding_inheritance.py
Normal file
@@ -0,0 +1,651 @@
|
||||
"""Tests relating to key binding inheritance.
|
||||
|
||||
In here you'll find some tests for general key binding inheritance, but
|
||||
there is an emphasis on the inheriting of movement key bindings as they (as
|
||||
of the time of writing) hold a special place in the Widget hierarchy of
|
||||
Textual.
|
||||
|
||||
<URL:https://github.com/Textualize/textual/issues/1343> holds much of the
|
||||
background relating to this.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from textual.actions import SkipAction
|
||||
from textual.app import App, ComposeResult
|
||||
from textual.binding import Binding
|
||||
from textual.containers import Container
|
||||
from textual.screen import Screen
|
||||
from textual.widget import Widget
|
||||
from textual.widgets import Static
|
||||
|
||||
##############################################################################
|
||||
# These are the movement keys within Textual; they kind of have a special
|
||||
# status in that they will get bound to movement-related methods.
|
||||
MOVEMENT_KEYS = ["up", "down", "left", "right", "home", "end", "pageup", "pagedown"]
|
||||
|
||||
##############################################################################
|
||||
# An application with no bindings anywhere.
|
||||
#
|
||||
# The idea of this first little test is that an application that has no
|
||||
# bindings set anywhere, and uses a default screen, should only have the one
|
||||
# binding in place: ctrl+c; it's hard-coded in the app class for now.
|
||||
|
||||
|
||||
class NoBindings(App[None]):
|
||||
"""An app with zero bindings."""
|
||||
|
||||
|
||||
async def test_just_app_no_bindings() -> None:
|
||||
"""An app with no bindings should have no bindings, other than ctrl+c."""
|
||||
async with NoBindings().run_test() as pilot:
|
||||
assert list(pilot.app._bindings.keys.keys()) == ["ctrl+c", "tab", "shift+tab"]
|
||||
assert pilot.app._bindings.get_key("ctrl+c").priority is True
|
||||
assert pilot.app._bindings.get_key("tab").priority is False
|
||||
assert pilot.app._bindings.get_key("shift+tab").priority is False
|
||||
|
||||
|
||||
##############################################################################
|
||||
# An application with a single alpha binding.
|
||||
#
|
||||
# Sticking with just an app and the default screen: this configuration has a
|
||||
# BINDINGS on the app itself, and simply binds the letter a -- in other
|
||||
# words avoiding anything to do with movement keys. The result should be
|
||||
# that we see the letter a, ctrl+c, and nothing else.
|
||||
|
||||
|
||||
class AlphaBinding(App[None]):
|
||||
"""An app with a simple alpha key binding."""
|
||||
|
||||
BINDINGS = [Binding("a", "a", "a", priority=True)]
|
||||
|
||||
|
||||
async def test_just_app_alpha_binding() -> None:
|
||||
"""An app with a single binding should have just the one binding."""
|
||||
async with AlphaBinding().run_test() as pilot:
|
||||
assert sorted(pilot.app._bindings.keys.keys()) == sorted(
|
||||
["ctrl+c", "tab", "shift+tab", "a"]
|
||||
)
|
||||
assert pilot.app._bindings.get_key("ctrl+c").priority is True
|
||||
assert pilot.app._bindings.get_key("a").priority is True
|
||||
|
||||
|
||||
##############################################################################
|
||||
# An application with a single low-priority alpha binding.
|
||||
#
|
||||
# The same as the above, but in this case we're going to, on purpose, lower
|
||||
# the priority of our own bindings, while any define by App itself should
|
||||
# remain the same.
|
||||
|
||||
|
||||
class LowAlphaBinding(App[None]):
|
||||
"""An app with a simple low-priority alpha key binding."""
|
||||
|
||||
BINDINGS = [Binding("a", "a", "a", priority=False)]
|
||||
|
||||
|
||||
async def test_just_app_low_priority_alpha_binding() -> None:
|
||||
"""An app with a single low-priority binding should have just the one binding."""
|
||||
async with LowAlphaBinding().run_test() as pilot:
|
||||
assert sorted(pilot.app._bindings.keys.keys()) == sorted(
|
||||
["ctrl+c", "tab", "shift+tab", "a"]
|
||||
)
|
||||
assert pilot.app._bindings.get_key("ctrl+c").priority is True
|
||||
assert pilot.app._bindings.get_key("a").priority is False
|
||||
|
||||
|
||||
##############################################################################
|
||||
# A non-default screen with a single alpha key binding.
|
||||
#
|
||||
# There's little point in testing a screen with no bindings added as that's
|
||||
# pretty much the same as an app with a default screen (for the purposes of
|
||||
# these tests). So, let's test a screen with a single alpha-key binding.
|
||||
|
||||
|
||||
class ScreenWithBindings(Screen):
|
||||
"""A screen with a simple alpha key binding."""
|
||||
|
||||
BINDINGS = [Binding("a", "a", "a", priority=True)]
|
||||
|
||||
|
||||
class AppWithScreenThatHasABinding(App[None]):
|
||||
"""An app with no extra bindings but with a custom screen with a binding."""
|
||||
|
||||
SCREENS = {"main": ScreenWithBindings}
|
||||
|
||||
def on_mount(self) -> None:
|
||||
self.push_screen("main")
|
||||
|
||||
|
||||
async def test_app_screen_with_bindings() -> None:
|
||||
"""Test a screen with a single key binding defined."""
|
||||
async with AppWithScreenThatHasABinding().run_test() as pilot:
|
||||
# The screen will contain all of the movement keys, because it
|
||||
# inherits from Widget. That's fine. Let's check they're there, but
|
||||
# also let's check that they all have a non-priority binding.
|
||||
assert all(
|
||||
pilot.app.screen._bindings.get_key(key).priority is False
|
||||
for key in MOVEMENT_KEYS
|
||||
)
|
||||
# Let's also check that the 'a' key is there, and it *is* a priority
|
||||
# binding.
|
||||
assert pilot.app.screen._bindings.get_key("a").priority is True
|
||||
|
||||
|
||||
##############################################################################
|
||||
# A non-default screen with a single low-priority alpha key binding.
|
||||
#
|
||||
# As above, but because Screen sets akk keys as high priority by default, we
|
||||
# want to be sure that if we set our keys in our subclass as low priority as
|
||||
# default, they come through as such.
|
||||
|
||||
|
||||
class ScreenWithLowBindings(Screen):
|
||||
"""A screen with a simple low-priority alpha key binding."""
|
||||
|
||||
BINDINGS = [Binding("a", "a", "a", priority=False)]
|
||||
|
||||
|
||||
class AppWithScreenThatHasALowBinding(App[None]):
|
||||
"""An app with no extra bindings but with a custom screen with a low-priority binding."""
|
||||
|
||||
SCREENS = {"main": ScreenWithLowBindings}
|
||||
|
||||
def on_mount(self) -> None:
|
||||
self.push_screen("main")
|
||||
|
||||
|
||||
async def test_app_screen_with_low_bindings() -> None:
|
||||
"""Test a screen with a single low-priority key binding defined."""
|
||||
async with AppWithScreenThatHasALowBinding().run_test() as pilot:
|
||||
# Screens inherit from Widget which means they get movement keys
|
||||
# too, so let's ensure they're all in there, along with our own key,
|
||||
# and that everyone is low-priority.
|
||||
assert all(
|
||||
pilot.app.screen._bindings.get_key(key).priority is False
|
||||
for key in ["a", *MOVEMENT_KEYS]
|
||||
)
|
||||
|
||||
|
||||
##############################################################################
|
||||
# From here on in we're going to start simulating keystrokes to ensure that
|
||||
# any bindings that are in place actually fire the correct actions. To help
|
||||
# with this let's build a simple key/binding/action recorder base app.
|
||||
|
||||
|
||||
class AppKeyRecorder(App[None]):
|
||||
"""Base application class that can be used to record keystrokes."""
|
||||
|
||||
ALPHAS = "abcxyz"
|
||||
"""str: The alpha keys to test against."""
|
||||
|
||||
ALL_KEYS = [*ALPHAS, *MOVEMENT_KEYS]
|
||||
"""list[str]: All the test keys."""
|
||||
|
||||
@staticmethod
|
||||
def make_bindings(action_prefix: str = "") -> list[Binding]:
|
||||
"""Make the binding list for testing an app.
|
||||
|
||||
Args:
|
||||
action_prefix (str, optional): An optional prefix for the action name.
|
||||
|
||||
Returns:
|
||||
list[Binding]: The resulting list of bindings.
|
||||
"""
|
||||
return [
|
||||
Binding(key, f"{action_prefix}record('{key}')", key)
|
||||
for key in [*AppKeyRecorder.ALPHAS, *MOVEMENT_KEYS]
|
||||
]
|
||||
|
||||
def __init__(self) -> None:
|
||||
"""Initialise the recording app."""
|
||||
super().__init__()
|
||||
self.pressed_keys: list[str] = []
|
||||
|
||||
async def action_record(self, key: str) -> None:
|
||||
"""Record a key, as used from a binding.
|
||||
|
||||
Args:
|
||||
key (str): The name of the key to record.
|
||||
"""
|
||||
self.pressed_keys.append(key)
|
||||
|
||||
def all_recorded(self, marker_prefix: str = "") -> None:
|
||||
"""Were all the bindings recorded from the presses?
|
||||
|
||||
Args:
|
||||
marker_prefix (str, optional): An optional prefix for the result markers.
|
||||
"""
|
||||
assert self.pressed_keys == [f"{marker_prefix}{key}" for key in self.ALL_KEYS]
|
||||
|
||||
|
||||
##############################################################################
|
||||
# An app with bindings for movement keys.
|
||||
#
|
||||
# Having gone through various permutations of testing for what bindings are
|
||||
# seen to be in place, we now move on to adding bindings, invoking them and
|
||||
# seeing what happens. First off let's start with an application that has
|
||||
# bindings, both for an alpha key, and also for all of the movement keys.
|
||||
|
||||
|
||||
class AppWithMovementKeysBound(AppKeyRecorder):
|
||||
"""An application with bindings."""
|
||||
|
||||
BINDINGS = AppKeyRecorder.make_bindings()
|
||||
|
||||
|
||||
async def test_pressing_alpha_on_app() -> None:
|
||||
"""Test that pressing the alpha key, when it's bound on the app, results in an action fire."""
|
||||
async with AppWithMovementKeysBound().run_test() as pilot:
|
||||
await pilot.press(*AppKeyRecorder.ALPHAS)
|
||||
await pilot.pause(2 / 100)
|
||||
assert pilot.app.pressed_keys == [*AppKeyRecorder.ALPHAS]
|
||||
|
||||
|
||||
async def test_pressing_movement_keys_app() -> None:
|
||||
"""Test that pressing the movement keys, when they're bound on the app, results in an action fire."""
|
||||
async with AppWithMovementKeysBound().run_test() as pilot:
|
||||
await pilot.press(*AppKeyRecorder.ALL_KEYS)
|
||||
await pilot.pause(2 / 100)
|
||||
pilot.app.all_recorded()
|
||||
|
||||
|
||||
##############################################################################
|
||||
# An app with a focused child widget with bindings.
|
||||
#
|
||||
# Now let's spin up an application, using the default screen, where the app
|
||||
# itself is composing in a widget that can have, and has, focus. The widget
|
||||
# also has bindings for all of the test keys. That child widget should be
|
||||
# able to handle all of the test keys on its own and nothing else should
|
||||
# grab them.
|
||||
|
||||
|
||||
class FocusableWidgetWithBindings(Static, can_focus=True):
|
||||
"""A widget that has its own bindings for the movement keys."""
|
||||
|
||||
BINDINGS = AppKeyRecorder.make_bindings("local_")
|
||||
|
||||
async def action_local_record(self, key: str) -> None:
|
||||
# Sneaky forward reference. Just for the purposes of testing.
|
||||
await self.app.action_record(f"locally_{key}")
|
||||
|
||||
|
||||
class AppWithWidgetWithBindings(AppKeyRecorder):
|
||||
"""A test app that composes with a widget that has movement bindings."""
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
yield FocusableWidgetWithBindings()
|
||||
|
||||
def on_mount(self) -> None:
|
||||
self.query_one(FocusableWidgetWithBindings).focus()
|
||||
|
||||
|
||||
async def test_focused_child_widget_with_movement_bindings() -> None:
|
||||
"""A focused child widget with movement bindings should handle its own actions."""
|
||||
async with AppWithWidgetWithBindings().run_test() as pilot:
|
||||
await pilot.press(*AppKeyRecorder.ALL_KEYS)
|
||||
await pilot.pause(2 / 100)
|
||||
pilot.app.all_recorded("locally_")
|
||||
|
||||
|
||||
##############################################################################
|
||||
# A focused widget within a screen that handles bindings.
|
||||
#
|
||||
# Similar to the previous test, here we're wrapping an app around a
|
||||
# non-default screen, which in turn wraps a widget that can has, and will
|
||||
# have, focus. The difference here however is that the screen has the
|
||||
# bindings. What we should expect to see is that the bindings don't fire on
|
||||
# the widget (it has none) and instead get caught by the screen.
|
||||
|
||||
|
||||
class FocusableWidgetWithNoBindings(Static, can_focus=True):
|
||||
"""A widget that can receive focus but has no bindings."""
|
||||
|
||||
|
||||
class ScreenWithMovementBindings(Screen):
|
||||
"""A screen that binds keys, including movement keys."""
|
||||
|
||||
BINDINGS = AppKeyRecorder.make_bindings("screen_")
|
||||
|
||||
async def action_screen_record(self, key: str) -> None:
|
||||
# Sneaky forward reference. Just for the purposes of testing.
|
||||
await self.app.action_record(f"screenly_{key}")
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
yield FocusableWidgetWithNoBindings()
|
||||
|
||||
def on_mount(self) -> None:
|
||||
self.query_one(FocusableWidgetWithNoBindings).focus()
|
||||
|
||||
|
||||
class AppWithScreenWithBindingsWidgetNoBindings(AppKeyRecorder):
|
||||
"""An app with a non-default screen that handles movement key bindings."""
|
||||
|
||||
SCREENS = {"main": ScreenWithMovementBindings}
|
||||
|
||||
def on_mount(self) -> None:
|
||||
self.push_screen("main")
|
||||
|
||||
|
||||
async def test_focused_child_widget_with_movement_bindings_on_screen() -> None:
|
||||
"""A focused child widget, with movement bindings in the screen, should trigger screen actions."""
|
||||
async with AppWithScreenWithBindingsWidgetNoBindings().run_test() as pilot:
|
||||
await pilot.press(*AppKeyRecorder.ALL_KEYS)
|
||||
await pilot.pause(2 / 100)
|
||||
pilot.app.all_recorded("screenly_")
|
||||
|
||||
|
||||
##############################################################################
|
||||
# A focused widget within a container within a screen that handles bindings.
|
||||
#
|
||||
# Similar again to the previous test, here we're wrapping an app around a
|
||||
# non-default screen, which in turn wraps a container which wraps a widget
|
||||
# that can have, and will have, focus. The issue here is that if the
|
||||
# container isn't scrolling, especially if it's set up to just wrap a widget
|
||||
# and do nothing else, it should not rob the screen of the binding hits.
|
||||
|
||||
|
||||
class ScreenWithMovementBindingsAndContainerAroundWidget(Screen):
|
||||
"""A screen that binds keys, including movement keys."""
|
||||
|
||||
BINDINGS = AppKeyRecorder.make_bindings("screen_")
|
||||
|
||||
async def action_screen_record(self, key: str) -> None:
|
||||
# Sneaky forward reference. Just for the purposes of testing.
|
||||
await self.app.action_record(f"screenly_{key}")
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
yield Container(FocusableWidgetWithNoBindings())
|
||||
|
||||
def on_mount(self) -> None:
|
||||
self.query_one(FocusableWidgetWithNoBindings).focus()
|
||||
|
||||
|
||||
class AppWithScreenWithBindingsWrappedWidgetNoBindings(AppKeyRecorder):
|
||||
"""An app with a non-default screen that handles movement key bindings."""
|
||||
|
||||
SCREENS = {"main": ScreenWithMovementBindings}
|
||||
|
||||
def on_mount(self) -> None:
|
||||
self.push_screen("main")
|
||||
|
||||
|
||||
async def test_contained_focused_child_widget_with_movement_bindings_on_screen() -> None:
|
||||
"""A contained focused child widget, with movement bindings in the screen, should trigger screen actions."""
|
||||
async with AppWithScreenWithBindingsWrappedWidgetNoBindings().run_test() as pilot:
|
||||
await pilot.press(*AppKeyRecorder.ALL_KEYS)
|
||||
await pilot.pause(2 / 100)
|
||||
pilot.app.all_recorded("screenly_")
|
||||
|
||||
|
||||
##############################################################################
|
||||
# A focused widget with bindings but no inheriting of bindings, on app.
|
||||
#
|
||||
# Now we move on to testing inherit_bindings. To start with we go back to an
|
||||
# app with a default screen, with the app itself composing in a widget that
|
||||
# can and will have focus, which has bindings for all the test keys, and
|
||||
# crucially has inherit_bindings set to False.
|
||||
#
|
||||
# We should expect to see all of the test keys recorded post-press.
|
||||
|
||||
|
||||
class WidgetWithBindingsNoInherit(Static, can_focus=True, inherit_bindings=False):
|
||||
"""A widget that has its own bindings for the movement keys, no binding inheritance."""
|
||||
|
||||
BINDINGS = AppKeyRecorder.make_bindings("local_")
|
||||
|
||||
async def action_local_record(self, key: str) -> None:
|
||||
# Sneaky forward reference. Just for the purposes of testing.
|
||||
await self.app.action_record(f"locally_{key}")
|
||||
|
||||
|
||||
class AppWithWidgetWithBindingsNoInherit(AppKeyRecorder):
|
||||
"""A test app that composes with a widget that has movement bindings without binding inheritance."""
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
yield WidgetWithBindingsNoInherit()
|
||||
|
||||
def on_mount(self) -> None:
|
||||
self.query_one(WidgetWithBindingsNoInherit).focus()
|
||||
|
||||
|
||||
async def test_focused_child_widget_with_movement_bindings_no_inherit() -> None:
|
||||
"""A focused child widget with movement bindings and inherit_bindings=False should handle its own actions."""
|
||||
async with AppWithWidgetWithBindingsNoInherit().run_test() as pilot:
|
||||
await pilot.press(*AppKeyRecorder.ALL_KEYS)
|
||||
await pilot.pause(2 / 100)
|
||||
pilot.app.all_recorded("locally_")
|
||||
|
||||
|
||||
##############################################################################
|
||||
# A focused widget with no bindings and no inheriting of bindings, on screen.
|
||||
#
|
||||
# Now let's test with a widget that can and will have focus, which has no
|
||||
# bindings, and which won't inherit bindings either. The bindings we're
|
||||
# going to test are moved up to the screen. We should expect to see all of
|
||||
# the test keys not be consumed by the focused widget, but instead they
|
||||
# should make it up to the screen.
|
||||
#
|
||||
# NOTE: no bindings are declared for the widget, which is different from
|
||||
# zero bindings declared.
|
||||
|
||||
|
||||
class FocusableWidgetWithNoBindingsNoInherit(
|
||||
Static, can_focus=True, inherit_bindings=False
|
||||
):
|
||||
"""A widget that can receive focus but has no bindings and doesn't inherit bindings."""
|
||||
|
||||
|
||||
class ScreenWithMovementBindingsNoInheritChild(Screen):
|
||||
"""A screen that binds keys, including movement keys."""
|
||||
|
||||
BINDINGS = AppKeyRecorder.make_bindings("screen_")
|
||||
|
||||
async def action_screen_record(self, key: str) -> None:
|
||||
# Sneaky forward reference. Just for the purposes of testing.
|
||||
await self.app.action_record(f"screenly_{key}")
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
yield FocusableWidgetWithNoBindingsNoInherit()
|
||||
|
||||
def on_mount(self) -> None:
|
||||
self.query_one(FocusableWidgetWithNoBindingsNoInherit).focus()
|
||||
|
||||
|
||||
class AppWithScreenWithBindingsWidgetNoBindingsNoInherit(AppKeyRecorder):
|
||||
"""An app with a non-default screen that handles movement key bindings, child no-inherit."""
|
||||
|
||||
SCREENS = {"main": ScreenWithMovementBindingsNoInheritChild}
|
||||
|
||||
def on_mount(self) -> None:
|
||||
self.push_screen("main")
|
||||
|
||||
|
||||
async def test_focused_child_widget_no_inherit_with_movement_bindings_on_screen() -> None:
|
||||
"""A focused child widget, that doesn't inherit bindings, with movement bindings in the screen, should trigger screen actions."""
|
||||
async with AppWithScreenWithBindingsWidgetNoBindingsNoInherit().run_test() as pilot:
|
||||
await pilot.press(*AppKeyRecorder.ALL_KEYS)
|
||||
await pilot.pause(2 / 100)
|
||||
pilot.app.all_recorded("screenly_")
|
||||
|
||||
|
||||
##############################################################################
|
||||
# A focused widget with zero bindings declared, but no inheriting of
|
||||
# bindings, on screen.
|
||||
#
|
||||
# Now let's test with a widget that can and will have focus, which has zero
|
||||
# (an empty collection of) bindings, and which won't inherit bindings
|
||||
# either. The bindings we're going to test are moved up to the screen. We
|
||||
# should expect to see all of the test keys not be consumed by the focused
|
||||
# widget, but instead they should make it up to the screen.
|
||||
#
|
||||
# NOTE: zero bindings are declared for the widget, which is different from
|
||||
# no bindings declared.
|
||||
|
||||
|
||||
class FocusableWidgetWithEmptyBindingsNoInherit(
|
||||
Static, can_focus=True, inherit_bindings=False
|
||||
):
|
||||
"""A widget that can receive focus but has empty bindings and doesn't inherit bindings."""
|
||||
|
||||
BINDINGS = []
|
||||
|
||||
|
||||
class ScreenWithMovementBindingsNoInheritEmptyChild(Screen):
|
||||
"""A screen that binds keys, including movement keys."""
|
||||
|
||||
BINDINGS = AppKeyRecorder.make_bindings("screen_")
|
||||
|
||||
async def action_screen_record(self, key: str) -> None:
|
||||
# Sneaky forward reference. Just for the purposes of testing.
|
||||
await self.app.action_record(f"screenly_{key}")
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
yield FocusableWidgetWithEmptyBindingsNoInherit()
|
||||
|
||||
def on_mount(self) -> None:
|
||||
self.query_one(FocusableWidgetWithEmptyBindingsNoInherit).focus()
|
||||
|
||||
|
||||
class AppWithScreenWithBindingsWidgetEmptyBindingsNoInherit(AppKeyRecorder):
|
||||
"""An app with a non-default screen that handles movement key bindings, child no-inherit."""
|
||||
|
||||
SCREENS = {"main": ScreenWithMovementBindingsNoInheritEmptyChild}
|
||||
|
||||
def on_mount(self) -> None:
|
||||
self.push_screen("main")
|
||||
|
||||
|
||||
async def test_focused_child_widget_no_inherit_empty_bindings_with_movement_bindings_on_screen() -> None:
|
||||
"""A focused child widget, that doesn't inherit bindings and sets BINDINGS empty, with movement bindings in the screen, should trigger screen actions."""
|
||||
async with AppWithScreenWithBindingsWidgetEmptyBindingsNoInherit().run_test() as pilot:
|
||||
await pilot.press(*AppKeyRecorder.ALL_KEYS)
|
||||
await pilot.pause(2 / 100)
|
||||
pilot.app.all_recorded("screenly_")
|
||||
|
||||
|
||||
##############################################################################
|
||||
# Testing priority of overlapping bindings.
|
||||
#
|
||||
# Here we we'll have an app, screen, and a focused widget, along with a
|
||||
# combination of overlapping bindings, each with different forms of
|
||||
# priority, so we can check who wins where.
|
||||
#
|
||||
# Here are the permutations tested, with the expected winner:
|
||||
#
|
||||
# |-----|----------|----------|----------|--------|
|
||||
# | Key | App | Screen | Widget | Winner |
|
||||
# |-----|----------|----------|----------|--------|
|
||||
# | 0 | | | | Widget |
|
||||
# | A | Priority | | | App |
|
||||
# | B | | Priority | | Screen |
|
||||
# | C | | | Priority | Widget |
|
||||
# | D | Priority | Priority | | App |
|
||||
# | E | Priority | | Priority | App |
|
||||
# | F | | Priority | Priority | Screen |
|
||||
|
||||
|
||||
class PriorityOverlapWidget(Static, can_focus=True):
|
||||
"""A focusable widget with a priority binding."""
|
||||
|
||||
BINDINGS = [
|
||||
Binding("0", "app.record('widget_0')", "0", priority=False),
|
||||
Binding("a", "app.record('widget_a')", "a", priority=False),
|
||||
Binding("b", "app.record('widget_b')", "b", priority=False),
|
||||
Binding("c", "app.record('widget_c')", "c", priority=True),
|
||||
Binding("d", "app.record('widget_d')", "d", priority=False),
|
||||
Binding("e", "app.record('widget_e')", "e", priority=True),
|
||||
Binding("f", "app.record('widget_f')", "f", priority=True),
|
||||
]
|
||||
|
||||
|
||||
class PriorityOverlapScreen(Screen):
|
||||
"""A screen with a priority binding."""
|
||||
|
||||
BINDINGS = [
|
||||
Binding("0", "app.record('screen_0')", "0", priority=False),
|
||||
Binding("a", "app.record('screen_a')", "a", priority=False),
|
||||
Binding("b", "app.record('screen_b')", "b", priority=True),
|
||||
Binding("c", "app.record('screen_c')", "c", priority=False),
|
||||
Binding("d", "app.record('screen_d')", "c", priority=True),
|
||||
Binding("e", "app.record('screen_e')", "e", priority=False),
|
||||
Binding("f", "app.record('screen_f')", "f", priority=True),
|
||||
]
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
yield PriorityOverlapWidget()
|
||||
|
||||
def on_mount(self) -> None:
|
||||
self.query_one(PriorityOverlapWidget).focus()
|
||||
|
||||
|
||||
class PriorityOverlapApp(AppKeyRecorder):
|
||||
"""An application with a priority binding."""
|
||||
|
||||
BINDINGS = [
|
||||
Binding("0", "record('app_0')", "0", priority=False),
|
||||
Binding("a", "record('app_a')", "a", priority=True),
|
||||
Binding("b", "record('app_b')", "b", priority=False),
|
||||
Binding("c", "record('app_c')", "c", priority=False),
|
||||
Binding("d", "record('app_d')", "c", priority=True),
|
||||
Binding("e", "record('app_e')", "e", priority=True),
|
||||
Binding("f", "record('app_f')", "f", priority=False),
|
||||
]
|
||||
|
||||
SCREENS = {"main": PriorityOverlapScreen}
|
||||
|
||||
def on_mount(self) -> None:
|
||||
self.push_screen("main")
|
||||
|
||||
|
||||
async def test_overlapping_priority_bindings() -> None:
|
||||
"""Test an app stack with overlapping bindings."""
|
||||
async with PriorityOverlapApp().run_test() as pilot:
|
||||
await pilot.press(*"0abcdef")
|
||||
await pilot.pause(2 / 100)
|
||||
assert pilot.app.pressed_keys == [
|
||||
"widget_0",
|
||||
"app_a",
|
||||
"screen_b",
|
||||
"widget_c",
|
||||
"app_d",
|
||||
"app_e",
|
||||
"screen_f",
|
||||
]
|
||||
|
||||
|
||||
async def test_skip_action() -> None:
|
||||
"""Test that a binding may be skipped by an action raising SkipAction"""
|
||||
|
||||
class Handle(Widget, can_focus=True):
|
||||
BINDINGS = [("t", "test('foo')", "Test")]
|
||||
|
||||
def action_test(self, text: str) -> None:
|
||||
self.app.exit(text)
|
||||
|
||||
no_handle_invoked = False
|
||||
|
||||
class NoHandle(Widget, can_focus=True):
|
||||
BINDINGS = [("t", "test('bar')", "Test")]
|
||||
|
||||
def action_test(self, text: str) -> bool:
|
||||
nonlocal no_handle_invoked
|
||||
no_handle_invoked = True
|
||||
raise SkipAction()
|
||||
|
||||
class SkipApp(App):
|
||||
def compose(self) -> ComposeResult:
|
||||
yield Handle(NoHandle())
|
||||
|
||||
def on_mount(self) -> None:
|
||||
self.query_one(NoHandle).focus()
|
||||
|
||||
async with SkipApp().run_test() as pilot:
|
||||
# Check the NoHandle widget has focus
|
||||
assert pilot.app.query_one(NoHandle).has_focus
|
||||
# Press the "t" key
|
||||
await pilot.press("t")
|
||||
# Check the action on the no handle widget was called
|
||||
assert no_handle_invoked
|
||||
# Check the return value, confirming that the action on Handle was called
|
||||
assert pilot.app.return_value == "foo"
|
||||
@@ -3,12 +3,14 @@ from __future__ import unicode_literals
|
||||
|
||||
import pytest
|
||||
|
||||
from textual._cache import LRUCache
|
||||
from textual._cache import FIFOCache, LRUCache
|
||||
|
||||
|
||||
def test_lru_cache():
|
||||
cache = LRUCache(3)
|
||||
|
||||
assert str(cache) == "<LRUCache maxsize=3 hits=0 misses=0>"
|
||||
|
||||
# insert some values
|
||||
cache["foo"] = 1
|
||||
cache["bar"] = 2
|
||||
@@ -35,6 +37,38 @@ def test_lru_cache():
|
||||
assert "eggegg" in cache
|
||||
|
||||
|
||||
def test_lru_cache_hits():
|
||||
cache = LRUCache(4)
|
||||
assert cache.hits == 0
|
||||
assert cache.misses == 0
|
||||
|
||||
try:
|
||||
cache["foo"]
|
||||
except KeyError:
|
||||
assert cache.hits == 0
|
||||
assert cache.misses == 1
|
||||
|
||||
cache["foo"] = 1
|
||||
assert cache.hits == 0
|
||||
assert cache.misses == 1
|
||||
|
||||
cache["foo"]
|
||||
cache["foo"]
|
||||
|
||||
assert cache.hits == 2
|
||||
assert cache.misses == 1
|
||||
|
||||
cache.get("bar")
|
||||
assert cache.hits == 2
|
||||
assert cache.misses == 2
|
||||
|
||||
cache.get("foo")
|
||||
assert cache.hits == 3
|
||||
assert cache.misses == 2
|
||||
|
||||
assert str(cache) == "<LRUCache maxsize=4 hits=3 misses=2>"
|
||||
|
||||
|
||||
def test_lru_cache_get():
|
||||
cache = LRUCache(3)
|
||||
|
||||
@@ -61,6 +95,7 @@ def test_lru_cache_get():
|
||||
assert "egg" not in cache
|
||||
assert "eggegg" in cache
|
||||
|
||||
|
||||
def test_lru_cache_maxsize():
|
||||
cache = LRUCache(3)
|
||||
|
||||
@@ -74,7 +109,7 @@ def test_lru_cache_maxsize():
|
||||
assert cache.maxsize == 30, "Incorrect cache maxsize after setting it"
|
||||
|
||||
# Add more than maxsize items to the cache and be sure
|
||||
for spam in range(cache.maxsize+10):
|
||||
for spam in range(cache.maxsize + 10):
|
||||
cache[f"spam{spam}"] = spam
|
||||
|
||||
# Finally, check the cache is the max size we set.
|
||||
@@ -146,3 +181,63 @@ def test_lru_cache_len(keys: list[str], expected_len: int):
|
||||
for value, key in enumerate(keys):
|
||||
cache[key] = value
|
||||
assert len(cache) == expected_len
|
||||
|
||||
|
||||
def test_fifo_cache():
|
||||
cache = FIFOCache(4)
|
||||
assert len(cache) == 0
|
||||
assert not cache
|
||||
assert "foo" not in cache
|
||||
cache["foo"] = 1
|
||||
assert "foo" in cache
|
||||
assert len(cache) == 1
|
||||
assert cache
|
||||
cache["bar"] = 2
|
||||
cache["baz"] = 3
|
||||
cache["egg"] = 4
|
||||
# Cache is full
|
||||
assert list(cache.keys()) == ["foo", "bar", "baz", "egg"]
|
||||
assert len(cache) == 4
|
||||
cache["Paul"] = 100
|
||||
assert list(cache.keys()) == ["bar", "baz", "egg", "Paul"]
|
||||
assert len(cache) == 4
|
||||
assert cache["baz"] == 3
|
||||
assert cache["bar"] == 2
|
||||
cache["Chani"] = 101
|
||||
assert list(cache.keys()) == ["baz", "egg", "Paul", "Chani"]
|
||||
assert len(cache) == 4
|
||||
cache.clear()
|
||||
assert len(cache) == 0
|
||||
assert list(cache.keys()) == []
|
||||
|
||||
|
||||
def test_fifo_cache_hits():
|
||||
cache = FIFOCache(4)
|
||||
assert cache.hits == 0
|
||||
assert cache.misses == 0
|
||||
|
||||
try:
|
||||
cache["foo"]
|
||||
except KeyError:
|
||||
assert cache.hits == 0
|
||||
assert cache.misses == 1
|
||||
|
||||
cache["foo"] = 1
|
||||
assert cache.hits == 0
|
||||
assert cache.misses == 1
|
||||
|
||||
cache["foo"]
|
||||
cache["foo"]
|
||||
|
||||
assert cache.hits == 2
|
||||
assert cache.misses == 1
|
||||
|
||||
cache.get("bar")
|
||||
assert cache.hits == 2
|
||||
assert cache.misses == 2
|
||||
|
||||
cache.get("foo")
|
||||
assert cache.hits == 3
|
||||
assert cache.misses == 2
|
||||
|
||||
assert str(cache) == "<FIFOCache maxsize=4 hits=3 misses=2>"
|
||||
|
||||
44
tests/test_dark_toggle.py
Normal file
@@ -0,0 +1,44 @@
|
||||
from textual.app import App
|
||||
|
||||
|
||||
class OnLoadDarkSwitch(App[None]):
|
||||
"""App for testing toggling dark mode in on_load."""
|
||||
|
||||
def on_load(self) -> None:
|
||||
self.dark = not self.dark
|
||||
|
||||
|
||||
async def test_toggle_dark_on_load() -> None:
|
||||
"""It should be possible to toggle dark mode in on_load."""
|
||||
async with OnLoadDarkSwitch().run_test() as pilot:
|
||||
assert not pilot.app.dark
|
||||
|
||||
|
||||
class OnMountDarkSwitch(App[None]):
|
||||
"""App for testing toggling dark mode in on_mount."""
|
||||
|
||||
def on_mount(self) -> None:
|
||||
self.dark = not self.dark
|
||||
|
||||
|
||||
async def test_toggle_dark_on_mount() -> None:
|
||||
"""It should be possible to toggle dark mode in on_mount."""
|
||||
async with OnMountDarkSwitch().run_test() as pilot:
|
||||
assert not pilot.app.dark
|
||||
|
||||
|
||||
class ActionDarkSwitch(App[None]):
|
||||
"""App for testing toggling dark mode from an action."""
|
||||
|
||||
BINDINGS = [("d", "toggle", "Toggle Dark Mode")]
|
||||
|
||||
def action_toggle(self) -> None:
|
||||
self.dark = not self.dark
|
||||
|
||||
|
||||
async def test_toggle_dark_in_action() -> None:
|
||||
"""It should be possible to toggle dark mode with an action."""
|
||||
async with OnMountDarkSwitch().run_test() as pilot:
|
||||
await pilot.press("d")
|
||||
await pilot.pause(2 / 100)
|
||||
assert not pilot.app.dark
|
||||