diff --git a/.github/workflows/pythonpackage.yml b/.github/workflows/pythonpackage.yml index 02e367aa44..1772620e98 100644 --- a/.github/workflows/pythonpackage.yml +++ b/.github/workflows/pythonpackage.yml @@ -11,6 +11,9 @@ on: - "**.lock" - "Makefile" +env: + PYTEST_ADDOPTS: "--color=yes" + jobs: build: runs-on: ${{ matrix.os }} diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index d173178cf8..f53b9c445b 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -17,20 +17,20 @@ repos: - id: end-of-file-fixer # ensures that a file is either empty, or ends with one newline - id: mixed-line-ending # replaces or checks mixed line ending - repo: https://github.com/pycqa/isort - rev: 5.12.0 + rev: '5.12.0' hooks: - id: isort name: isort (python) language_version: '3.11' - args: ["--profile", "black", "--filter-files"] + args: ['--profile', 'black', '--filter-files'] - repo: https://github.com/psf/black - rev: 24.1.1 + rev: '24.1.1' hooks: - id: black - repo: https://github.com/hadialqattan/pycln # removes unused imports rev: v2.3.0 hooks: - id: pycln - language_version: "3.11" + language_version: '3.11' args: [--all] exclude: ^tests/snapshot_tests diff --git a/CHANGELOG.md b/CHANGELOG.md index 943e1a7db2..204fcb5f42 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,99 @@ 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.48.0] - 2023-02-01 +## Unreleased + +### Added + +- Added support for environment variable `TEXTUAL_ANIMATIONS` to control what animations Textual displays https://github.com/Textualize/textual/pull/4062 +- Add attribute `App.animation_level` to control whether animations on that app run or not https://github.com/Textualize/textual/pull/4062 + +## [0.51.0] - 2024-02-15 + +### Added + +- TextArea now has `read_only` mode https://github.com/Textualize/textual/pull/4151 +- Add some syntax highlighting to TextArea default theme https://github.com/Textualize/textual/pull/4149 +- Add undo and redo to TextArea https://github.com/Textualize/textual/pull/4124 +- Added support for command palette command discoverability https://github.com/Textualize/textual/pull/4154 + +### Fixed + +- Fixed out-of-view `Tab` not being scrolled into view when `Tabs.active` is assigned https://github.com/Textualize/textual/issues/4150 +- Fixed `TabbedContent.TabActivate` not being posted when `TabbedContent.active` is assigned https://github.com/Textualize/textual/issues/4150 + +### Changed + +- Breaking change: Renamed `TextArea.tab_behaviour` to `TextArea.tab_behavior` https://github.com/Textualize/textual/pull/4124 +- `TextArea.theme` now defaults to `"css"` instead of None, and is no longer optional https://github.com/Textualize/textual/pull/4157 + +### Fixed + +- Improve support for selector lists in nested TCSS https://github.com/Textualize/textual/issues/3969 +- Improve support for rule declarations after nested TCSS rule sets https://github.com/Textualize/textual/issues/3999 + +## [0.50.1] - 2024-02-09 + +### Fixed + +- Fixed tint applied to ANSI colors https://github.com/Textualize/textual/pull/4142 + +## [0.50.0] - 2024-02-08 + +### Fixed + +- Fixed issue with ANSI colors not being converted to truecolor https://github.com/Textualize/textual/pull/4138 +- Fixed duplicate watch methods being attached to DOM nodes https://github.com/Textualize/textual/pull/4030 +- Fixed using `watch` to create additional watchers would trigger other watch methods https://github.com/Textualize/textual/issues/3878 + +### Added + +- Added support for configuring dark and light themes for code in `Markdown` https://github.com/Textualize/textual/issues/3997 + +## [0.49.0] - 2024-02-07 + +### Fixed + +- Fixed scrolling in long `OptionList` by adding max height of 100% https://github.com/Textualize/textual/issues/4021 +- Fixed `DirectoryTree.clear_node` not clearing the node specified https://github.com/Textualize/textual/issues/4122 + +### Changed + +- `DirectoryTree.reload` and `DirectoryTree.reload_node` now preserve state when reloading https://github.com/Textualize/textual/issues/4056 +- Fixed a crash in the TextArea when performing a backward replace https://github.com/Textualize/textual/pull/4126 +- Fixed selection not updating correctly when pasting while there's a non-zero selection https://github.com/Textualize/textual/pull/4126 +- Breaking change: `TextArea` will not use `Escape` to shift focus if the `tab_behaviour` is the default https://github.com/Textualize/textual/issues/4110 +- `TextArea` cursor will now be invisible before first focus https://github.com/Textualize/textual/pull/4128 +- Fix toggling `TextArea.cursor_blink` reactive when widget does not have focus https://github.com/Textualize/textual/pull/4128 + +### Added + +- Added DOMQuery.set https://github.com/Textualize/textual/pull/4075 +- Added DOMNode.set_reactive https://github.com/Textualize/textual/pull/4075 +- Added DOMNode.data_bind https://github.com/Textualize/textual/pull/4075 +- Added DOMNode.action_toggle https://github.com/Textualize/textual/pull/4075 +- Added Worker.cancelled_event https://github.com/Textualize/textual/pull/4075 +- `Tree` (and `DirectoryTree`) grew an attribute `lock` that can be used for synchronization across coroutines https://github.com/Textualize/textual/issues/4056 + + +## [0.48.2] - 2024-02-02 + +### Fixed + +- Fixed a hang in the Linux driver when connected to a pipe https://github.com/Textualize/textual/issues/4104 +- Fixed broken `OptionList` `Option.id` mappings https://github.com/Textualize/textual/issues/4101 + +### Changed + +- Breaking change: keyboard navigation in `RadioSet`, `ListView`, `OptionList`, and `SelectionList`, no longer allows highlighting disabled items https://github.com/Textualize/textual/issues/3881 + +## [0.48.1] - 2024-02-01 + +### Fixed + +- `TextArea` uses CSS theme by default instead of `monokai` https://github.com/Textualize/textual/pull/4091 + +## [0.48.0] - 2024-02-01 ### Changed @@ -35,8 +127,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/). - Added `TabbedContent.active_pane` https://github.com/Textualize/textual/pull/4012 - Added `App.suspend` https://github.com/Textualize/textual/pull/4064 - Added `App.action_suspend_process` https://github.com/Textualize/textual/pull/4064 -- Added support for environment variable `TEXTUAL_ANIMATIONS` to control what animations Textual displays https://github.com/Textualize/textual/pull/4062 -- Add attribute `App.animation_level` to control whether animations on that app run or not https://github.com/Textualize/textual/pull/4062 + ### Fixed @@ -55,7 +146,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/). - Fixed display of keys when used in conjunction with other keys https://github.com/Textualize/textual/pull/3050 - Fixed double detection of Escape on Windows https://github.com/Textualize/textual/issues/4038 -## [0.47.1] - 2023-01-05 +## [0.47.1] - 2024-01-05 ### Fixed @@ -1623,6 +1714,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.51.0]: https://github.com/Textualize/textual/compare/v0.50.1...v0.51.0 +[0.50.1]: https://github.com/Textualize/textual/compare/v0.50.0...v0.50.1 +[0.50.0]: https://github.com/Textualize/textual/compare/v0.49.0...v0.50.0 +[0.49.1]: https://github.com/Textualize/textual/compare/v0.49.0...v0.49.1 +[0.49.0]: https://github.com/Textualize/textual/compare/v0.48.2...v0.49.0 +[0.48.2]: https://github.com/Textualize/textual/compare/v0.48.1...v0.48.2 +[0.48.1]: https://github.com/Textualize/textual/compare/v0.48.0...v0.48.1 [0.48.0]: https://github.com/Textualize/textual/compare/v0.47.1...v0.48.0 [0.47.1]: https://github.com/Textualize/textual/compare/v0.47.0...v0.47.1 [0.47.0]: https://github.com/Textualize/textual/compare/v0.46.0...v0.47.0 diff --git a/docs/blog/posts/toolong-retrospective.md b/docs/blog/posts/toolong-retrospective.md new file mode 100644 index 0000000000..0fa480d033 --- /dev/null +++ b/docs/blog/posts/toolong-retrospective.md @@ -0,0 +1,143 @@ +--- +draft: false +date: 2024-02-11 +categories: + - DevLog +authors: + - willmcgugan +--- + +# File magic with the Python standard library + +I recently published [Toolong](https://github.com/textualize/toolong), an app for viewing log files. +There were some interesting technical challenges in building Toolong that I'd like to cover in this post. + + + +!!! note "Python is awesome" + + This isn't specifically [Textual](https://github.com/textualize/textual/) related. These techniques could be employed in any Python project. + +These techniques aren't difficult, and shouldn't be beyond anyone with an intermediate understanding of Python. +They are the kind of "if you know it you know it" knowledge that you may not need often, but can make a massive difference when you do! + +## Opening large files + +If you were to open a very large text file (multiple gigabyte in size) in an editor, you will almost certainly find that it takes a while. You may also find that it doesn't load at all because you don't have enough memory, or it disables features like syntax highlighting. + +This is because most app will do something analogous to this: + +```python +with open("access.log", "rb") as log_file: + log_data = log_file.read() +``` + +All the data is read in to memory, where it can be easily processed. +This is fine for most files of a reasonable size, but when you get in to the gigabyte territory the read and any additional processing will start to use a significant amount of time and memory. + +Yet Toolong can open a file of *any* size in a second or so, with syntax highlighting. +It can do this because it doesn't need to read the entire log file in to memory. +Toolong opens a file and reads only the portion of it required to display whatever is on screen at that moment. +When you scroll around the log file, Toolong reads the data off disk as required -- fast enough that you may never even notice it. + +### Scanning lines + +There is an additional bit of work that Toolong has to do up front in order to show the file. +If you open a large file you may see a progress bar and a message about "scanning". + +Toolong needs to know where every line starts and ends in a log file, so it can display a scrollbar bar and allow the user to navigate lines in the file. +In other words it needs to know the offset of every new line (`\n`) character within the file. + +This isn't a hard problem in itself. +You might have imagined a loop that reads a chunk at a time and searches for new lines characters. +And that would likely have worked just fine, but there is a bit of magic in the Python standard library that can speed that up. + +The [mmap](https://docs.python.org/3/library/mmap.html) module is a real gem for this kind of thing. +A *memory mapped file* is an OS-level construct that *appears* to load a file instantaneously. +In Python you get an object which behaves like a `bytearray`, but loads data from disk when it is accessed. +The beauty of this module is that you can work with files in much the same way as if you had read the entire file in to memory, while leaving the actual reading of the file to the OS. + +Here's the method that Toolong uses to scan for line breaks. +Forgive the micro-optimizations, I was going for raw execution speed here. + +```python + def scan_line_breaks( + self, batch_time: float = 0.25 + ) -> Iterable[tuple[int, list[int]]]: + """Scan the file for line breaks. + + Args: + batch_time: Time to group the batches. + + Returns: + An iterable of tuples, containing the scan position and a list of offsets of new lines. + """ + fileno = self.fileno + size = self.size + if not size: + return + log_mmap = mmap.mmap(fileno, size, prot=mmap.PROT_READ) + rfind = log_mmap.rfind + position = size + batch: list[int] = [] + append = batch.append + get_length = batch.__len__ + monotonic = time.monotonic + break_time = monotonic() + + while (position := rfind(b"\n", 0, position)) != -1: + append(position) + if get_length() % 1000 == 0 and monotonic() - break_time > batch_time: + break_time = monotonic() + yield (position, batch) + batch = [] + append = batch.append + yield (0, batch) + log_mmap.close() +``` + +This code runs in a thread (actually a [worker](https://textual.textualize.io/guide/workers/)), and will generate line breaks in batches. Without batching, it risks slowing down the UI with millions of rapid events. + +It's fast because most of the work is done in `rfind`, which runs at C speed, while the OS reads from the disk. + +## Watching a file for changes + +Toolong can tail files in realtime. +When something appends to the file, it will be read and displayed virtually instantly. +How is this done? + +You can easily *poll* a file for changes, by periodically querying the size or timestamp of a file until it changes. +The downside of this is that you don't get notified immediately if a file changes between polls. +You could poll at a very fast rate, but if you were to do that you would end up burning a lot of CPU for no good reason. + +There is a very good solution for this in the standard library. +The [selectors](https://docs.python.org/3/library/selectors.html) module is typically used for working with sockets (network data), but can also work with files (at least on macOS and Linux). + +!!! info "Software developers are an unimaginative bunch when it comes to naming things" + + Not to be confused with CSS [selectors](https://textual.textualize.io/guide/CSS/#selectors)! + +The selectors module can tell you precisely when a file can be read. +It can do this very efficiently, because it relies on the OS to tell us when a file can be read, and doesn't need to poll. + +You register a file with a `Selector` object, then call `select()` which returns as soon as there is new data available for reading. + +See [watcher.py](https://github.com/Textualize/toolong/blob/main/src/toolong/watcher.py) in Toolong, which runs a thread to monitors files for changes with a selector. + +## Textual learnings + +This project was a chance for me to "dogfood" Textual. +Other Textual devs have build some cool projects ([Trogon](https://github.com/Textualize/trogon) and [Frogmouth](https://github.com/Textualize/frogmouth)), but before Toolong I had only ever written example apps for docs. + +I paid particular attention to Textual error messages when working on Toolong, and improved many of them in Textual. +Much of what I improved were general programming errors, and not Textual errors per se. +For instance, if you forget to call `super()` on a widget constructor, Textual used to give a fairly cryptic error. +It's a fairly common gotcha, even for experience devs, but now Textual will detect that and tell you how to fix it. + +There's a lot of other improvements which I thought about when working on this app. +Mostly quality of life features that will make implementing some features more intuitive. +Keep an eye out for those in the next few weeks. + +## Found this interesting? + +If you would like to talk about this post or anything Textual related, join us on the [Discord server](https://discord.gg/Enf6Z3qhVr). diff --git a/docs/examples/guide/reactivity/set_reactive01.py b/docs/examples/guide/reactivity/set_reactive01.py new file mode 100644 index 0000000000..d9e34f9dcb --- /dev/null +++ b/docs/examples/guide/reactivity/set_reactive01.py @@ -0,0 +1,67 @@ +from textual.app import App, ComposeResult +from textual.containers import Horizontal +from textual.reactive import reactive, var +from textual.widgets import Label + +GREETINGS = [ + "Bonjour", + "Hola", + "こんにちは", + "你好", + "안녕하세요", + "Hello", +] + + +class Greeter(Horizontal): + """Display a greeting and a name.""" + + DEFAULT_CSS = """ + Greeter { + width: auto; + height: 1; + & Label { + margin: 0 1; + } + } + """ + greeting: reactive[str] = reactive("") + who: reactive[str] = reactive("") + + def __init__(self, greeting: str = "Hello", who: str = "World!") -> None: + super().__init__() + self.greeting = greeting # (1)! + self.who = who + + def compose(self) -> ComposeResult: + yield Label(self.greeting, id="greeting") + yield Label(self.who, id="name") + + def watch_greeting(self, greeting: str) -> None: + self.query_one("#greeting", Label).update(greeting) # (2)! + + def watch_who(self, who: str) -> None: + self.query_one("#who", Label).update(who) + + +class NameApp(App): + + CSS = """ + Screen { + align: center middle; + } + """ + greeting_no: var[int] = var(0) + BINDINGS = [("space", "greeting")] + + def compose(self) -> ComposeResult: + yield Greeter(who="Textual") + + def action_greeting(self) -> None: + self.greeting_no = (self.greeting_no + 1) % len(GREETINGS) + self.query_one(Greeter).greeting = GREETINGS[self.greeting_no] + + +if __name__ == "__main__": + app = NameApp() + app.run() diff --git a/docs/examples/guide/reactivity/set_reactive02.py b/docs/examples/guide/reactivity/set_reactive02.py new file mode 100644 index 0000000000..c4e36fc5cd --- /dev/null +++ b/docs/examples/guide/reactivity/set_reactive02.py @@ -0,0 +1,67 @@ +from textual.app import App, ComposeResult +from textual.containers import Horizontal +from textual.reactive import reactive, var +from textual.widgets import Label + +GREETINGS = [ + "Bonjour", + "Hola", + "こんにちは", + "你好", + "안녕하세요", + "Hello", +] + + +class Greeter(Horizontal): + """Display a greeting and a name.""" + + DEFAULT_CSS = """ + Greeter { + width: auto; + height: 1; + & Label { + margin: 0 1; + } + } + """ + greeting: reactive[str] = reactive("") + who: reactive[str] = reactive("") + + def __init__(self, greeting: str = "Hello", who: str = "World!") -> None: + super().__init__() + self.set_reactive(Greeter.greeting, greeting) # (1)! + self.set_reactive(Greeter.who, who) + + def compose(self) -> ComposeResult: + yield Label(self.greeting, id="greeting") + yield Label(self.who, id="name") + + def watch_greeting(self, greeting: str) -> None: + self.query_one("#greeting", Label).update(greeting) + + def watch_who(self, who: str) -> None: + self.query_one("#who", Label).update(who) + + +class NameApp(App): + + CSS = """ + Screen { + align: center middle; + } + """ + greeting_no: var[int] = var(0) + BINDINGS = [("space", "greeting")] + + def compose(self) -> ComposeResult: + yield Greeter(who="Textual") + + def action_greeting(self) -> None: + self.greeting_no = (self.greeting_no + 1) % len(GREETINGS) + self.query_one(Greeter).greeting = GREETINGS[self.greeting_no] + + +if __name__ == "__main__": + app = NameApp() + app.run() diff --git a/docs/examples/guide/reactivity/world_clock01.py b/docs/examples/guide/reactivity/world_clock01.py new file mode 100644 index 0000000000..04830ab8e9 --- /dev/null +++ b/docs/examples/guide/reactivity/world_clock01.py @@ -0,0 +1,52 @@ +from datetime import datetime + +from pytz import timezone + +from textual.app import App, ComposeResult +from textual.reactive import reactive +from textual.widget import Widget +from textual.widgets import Digits, Label + + +class WorldClock(Widget): + + time: reactive[datetime] = reactive(datetime.now) + + def __init__(self, timezone: str) -> None: + self.timezone = timezone + super().__init__() + + def compose(self) -> ComposeResult: + yield Label(self.timezone) + yield Digits() + + def watch_time(self, time: datetime) -> None: + localized_time = time.astimezone(timezone(self.timezone)) + self.query_one(Digits).update(localized_time.strftime("%H:%M:%S")) + + +class WorldClockApp(App): + CSS_PATH = "world_clock01.tcss" + + time: reactive[datetime] = reactive(datetime.now) + + def compose(self) -> ComposeResult: + yield WorldClock("Europe/London") + yield WorldClock("Europe/Paris") + yield WorldClock("Asia/Tokyo") + + def update_time(self) -> None: + self.time = datetime.now() + + def watch_time(self, time: datetime) -> None: + for world_clock in self.query(WorldClock): # (1)! + world_clock.time = time + + def on_mount(self) -> None: + self.update_time() + self.set_interval(1, self.update_time) + + +if __name__ == "__main__": + app = WorldClockApp() + app.run() diff --git a/docs/examples/guide/reactivity/world_clock01.tcss b/docs/examples/guide/reactivity/world_clock01.tcss new file mode 100644 index 0000000000..d0b4f22695 --- /dev/null +++ b/docs/examples/guide/reactivity/world_clock01.tcss @@ -0,0 +1,16 @@ +Screen { + align: center middle; +} + +WorldClock { + width: auto; + height: auto; + padding: 1 2; + background: $panel; + border: wide $background; + + & Digits { + width: auto; + color: $secondary; + } +} diff --git a/docs/examples/guide/reactivity/world_clock02.py b/docs/examples/guide/reactivity/world_clock02.py new file mode 100644 index 0000000000..8988539193 --- /dev/null +++ b/docs/examples/guide/reactivity/world_clock02.py @@ -0,0 +1,47 @@ +from datetime import datetime + +from pytz import timezone + +from textual.app import App, ComposeResult +from textual.reactive import reactive +from textual.widget import Widget +from textual.widgets import Digits, Label + + +class WorldClock(Widget): + + time: reactive[datetime] = reactive(datetime.now) + + def __init__(self, timezone: str) -> None: + self.timezone = timezone + super().__init__() + + def compose(self) -> ComposeResult: + yield Label(self.timezone) + yield Digits() + + def watch_time(self, time: datetime) -> None: + localized_time = time.astimezone(timezone(self.timezone)) + self.query_one(Digits).update(localized_time.strftime("%H:%M:%S")) + + +class WorldClockApp(App): + CSS_PATH = "world_clock01.tcss" + + time: reactive[datetime] = reactive(datetime.now) + + def compose(self) -> ComposeResult: + yield WorldClock("Europe/London").data_bind(WorldClockApp.time) # (1)! + yield WorldClock("Europe/Paris").data_bind(WorldClockApp.time) + yield WorldClock("Asia/Tokyo").data_bind(WorldClockApp.time) + + def update_time(self) -> None: + self.time = datetime.now() + + def on_mount(self) -> None: + self.update_time() + self.set_interval(1, self.update_time) + + +if __name__ == "__main__": + WorldClockApp().run() diff --git a/docs/examples/guide/reactivity/world_clock03.py b/docs/examples/guide/reactivity/world_clock03.py new file mode 100644 index 0000000000..6d5c6dbb07 --- /dev/null +++ b/docs/examples/guide/reactivity/world_clock03.py @@ -0,0 +1,49 @@ +from datetime import datetime + +from pytz import timezone + +from textual.app import App, ComposeResult +from textual.reactive import reactive +from textual.widget import Widget +from textual.widgets import Digits, Label + + +class WorldClock(Widget): + + clock_time: reactive[datetime] = reactive(datetime.now) + + def __init__(self, timezone: str) -> None: + self.timezone = timezone + super().__init__() + + def compose(self) -> ComposeResult: + yield Label(self.timezone) + yield Digits() + + def watch_clock_time(self, time: datetime) -> None: + localized_time = time.astimezone(timezone(self.timezone)) + self.query_one(Digits).update(localized_time.strftime("%H:%M:%S")) + + +class WorldClockApp(App): + CSS_PATH = "world_clock01.tcss" + + time: reactive[datetime] = reactive(datetime.now) + + def compose(self) -> ComposeResult: + yield WorldClock("Europe/London").data_bind( + clock_time=WorldClockApp.time # (1)! + ) + yield WorldClock("Europe/Paris").data_bind(clock_time=WorldClockApp.time) + yield WorldClock("Asia/Tokyo").data_bind(clock_time=WorldClockApp.time) + + def update_time(self) -> None: + self.time = datetime.now() + + def on_mount(self) -> None: + self.update_time() + self.set_interval(1, self.update_time) + + +if __name__ == "__main__": + WorldClockApp().run() diff --git a/docs/examples/widgets/text_area_custom_language.py b/docs/examples/widgets/text_area_custom_language.py index 70ee7e16b9..1fba664032 100644 --- a/docs/examples/widgets/text_area_custom_language.py +++ b/docs/examples/widgets/text_area_custom_language.py @@ -18,7 +18,7 @@ class HelloWorld { class TextAreaCustomLanguage(App): def compose(self) -> ComposeResult: - text_area = TextArea(text=java_code) + text_area = TextArea.code_editor(text=java_code) text_area.cursor_blink = False # Register the Java language and highlight query diff --git a/docs/examples/widgets/text_area_example.py b/docs/examples/widgets/text_area_example.py index 2e0e31c060..f7534a449f 100644 --- a/docs/examples/widgets/text_area_example.py +++ b/docs/examples/widgets/text_area_example.py @@ -12,7 +12,7 @@ def goodbye(name): class TextAreaExample(App): def compose(self) -> ComposeResult: - yield TextArea(TEXT, language="python") + yield TextArea.code_editor(TEXT, language="python") app = TextAreaExample() diff --git a/docs/examples/widgets/text_area_extended.py b/docs/examples/widgets/text_area_extended.py index 8ac237db88..26d29ceadb 100644 --- a/docs/examples/widgets/text_area_extended.py +++ b/docs/examples/widgets/text_area_extended.py @@ -15,7 +15,7 @@ def _on_key(self, event: events.Key) -> None: class TextAreaKeyPressHook(App): def compose(self) -> ComposeResult: - yield ExtendedTextArea(language="python") + yield ExtendedTextArea.code_editor(language="python") app = TextAreaKeyPressHook() diff --git a/docs/examples/widgets/text_area_selection.py b/docs/examples/widgets/text_area_selection.py index 4165eb2d2d..980f597e1f 100644 --- a/docs/examples/widgets/text_area_selection.py +++ b/docs/examples/widgets/text_area_selection.py @@ -13,7 +13,7 @@ def goodbye(name): class TextAreaSelection(App): def compose(self) -> ComposeResult: - text_area = TextArea(TEXT, language="python") + text_area = TextArea.code_editor(TEXT, language="python") text_area.selection = Selection(start=(0, 0), end=(2, 0)) # (1)! yield text_area diff --git a/docs/guide/app.md b/docs/guide/app.md index ebee06748a..5a59322802 100644 --- a/docs/guide/app.md +++ b/docs/guide/app.md @@ -258,7 +258,7 @@ The following Textual app will launch [vim](https://www.vim.org/) (a text editor === "suspend.py" - ```python hl_lines="14-15" + ```python hl_lines="15-16" --8<-- "docs/examples/app/suspend.py" ``` diff --git a/docs/guide/command_palette.md b/docs/guide/command_palette.md index bdfd45b398..5fa1bebc08 100644 --- a/docs/guide/command_palette.md +++ b/docs/guide/command_palette.md @@ -68,7 +68,7 @@ The following example will display a blank screen initially, but if you bring up 5. Highlights matching letters in the search. 6. Adds our custom command provider and the default command provider. -There are three methods you can override in a command provider: [`startup`][textual.command.Provider.startup], [`search`][textual.command.Provider.search], and [`shutdown`][textual.command.Provider.shutdown]. +There are four methods you can override in a command provider: [`startup`][textual.command.Provider.startup], [`search`][textual.command.Provider.search], [`discover`][textual.command.Provider.discover] and [`shutdown`][textual.command.Provider.shutdown]. All of these methods should be coroutines (`async def`). Only `search` is required, the other methods are optional. Let's explore those methods in detail. @@ -99,7 +99,25 @@ In the example above, the callback is a lambda which calls the `open_file` metho This is a deliberate design decision taken to prevent a single broken `Provider` class from making the command palette unusable. Errors in command providers will be logged to the [console](./devtools.md). -### Shutdown method +### discover method + +The [`discover`][textual.command.Provider.discover] method is responsible for providing results (or *discovery hits*) that should be shown to the user when the command palette input is empty; +this is to aid in command discoverability. + +!!! note + + Because `discover` hits are shown the moment the command palette is opened, these should ideally be quick to generate; + commands that might take time to generate are best left for `search` -- use `discover` to help the user easily find the most important commands. + +`discover` is similar to `search` but with these differences: + +- `discover` accepts no parameters (instead of the search value) +- `discover` yields instances of [`DiscoveryHit`][textual.command.DiscoveryHit] (instead of instances of [`Hit`][textual.command.Hit]) +- discovery hits are sorted in ascending alphabetical order because there is no matching and no match score is generated + +Instances of [`DiscoveryHit`][textual.command.DiscoveryHit] contain information about how the hit should be displayed, an optional help string, and a callback which will be run if the user selects that command. + +### shutdown method The [`shutdown`][textual.command.Provider.shutdown] method is called when the command palette is closed. You can use this as a hook to gracefully close any objects you created in [`startup`][textual.command.Provider.startup]. diff --git a/docs/guide/events.md b/docs/guide/events.md index da18527f0c..bd526341c8 100644 --- a/docs/guide/events.md +++ b/docs/guide/events.md @@ -210,6 +210,12 @@ In the following example we have three buttons, each of which does something dif 1. The message handler is called when any button is pressed +=== "on_decorator.tcss" + + ```python title="on_decorator.tcss" + --8<-- "docs/examples/events/on_decorator.tcss" + ``` + === "Output" ```{.textual path="docs/examples/events/on_decorator01.py"} @@ -233,6 +239,12 @@ The following example uses the decorator approach to write individual message ha 2. Matches the button with class names "toggle" *and* "dark" 3. Matches the button with an id of "quit" +=== "on_decorator.tcss" + + ```python title="on_decorator.tcss" + --8<-- "docs/examples/events/on_decorator.tcss" + ``` + === "Output" ```{.textual path="docs/examples/events/on_decorator02.py"} diff --git a/docs/guide/queries.md b/docs/guide/queries.md index 0b1e5fc105..d87a78a399 100644 --- a/docs/guide/queries.md +++ b/docs/guide/queries.md @@ -161,10 +161,12 @@ for widget in self.query("Button"): Here are the other loop-free methods on query objects: -- [set_class][textual.css.query.DOMQuery.set_class] Sets a CSS class (or classes) on matched widgets. - [add_class][textual.css.query.DOMQuery.add_class] Adds a CSS class (or classes) to matched widgets. +- [blur][textual.css.query.DOMQuery.focus] Blurs (removes focus) from matching widgets. +- [focus][textual.css.query.DOMQuery.focus] Focuses the first matching widgets. +- [refresh][textual.css.query.DOMQuery.refresh] Refreshes matched widgets. - [remove_class][textual.css.query.DOMQuery.remove_class] Removes a CSS class (or classes) from matched widgets. -- [toggle_class][textual.css.query.DOMQuery.toggle_class] Sets a CSS class (or classes) if it is not set, or removes the class (or classes) if they are set on the matched widgets. - [remove][textual.css.query.DOMQuery.remove] Removes matched widgets from the DOM. -- [refresh][textual.css.query.DOMQuery.refresh] Refreshes matched widgets. - +- [set_class][textual.css.query.DOMQuery.set_class] Sets a CSS class (or classes) on matched widgets. +- [set][textual.css.query.DOMQuery.set] Sets common attributes on a widget. +- [toggle_class][textual.css.query.DOMQuery.toggle_class] Sets a CSS class (or classes) if it is not set, or removes the class (or classes) if they are set on the matched widgets. diff --git a/docs/guide/reactivity.md b/docs/guide/reactivity.md index c84050ad40..40eacb6923 100644 --- a/docs/guide/reactivity.md +++ b/docs/guide/reactivity.md @@ -266,3 +266,140 @@ When the result of `compute_color` changes, Textual will also call `watch_color` !!! note It is best to avoid doing anything slow or CPU-intensive in a compute method. Textual calls compute methods on an object when _any_ reactive attribute changes. + +## Setting reactives without superpowers + +You may find yourself in a situation where you want to set a reactive value, but you *don't* want to invoke watchers or the other super powers. +This is fairly common in constructors which run prior to mounting; any watcher which queries the DOM may break if the widget has not yet been mounted. + +To work around this issue, you can call [set_reactive][textual.dom.DOMNode.set_reactive] as an alternative to setting the attribute. +The `set_reactive` method accepts the reactive attribute (as a class variable) and the new value. + +Let's look at an example. +The following app is intended to cycle through various greeting when you press ++space++, however it contains a bug. + +```python title="set_reactive01.py" +--8<-- "docs/examples/guide/reactivity/set_reactive01.py" +``` + +1. Setting this reactive attribute invokes a watcher. +2. The watcher attempts to update a label before it is mounted. + +If you run this app, you will find Textual raises a `NoMatches` error in `watch_greeting`. +This is because the constructor has assigned the reactive before the widget has fully mounted. + +The following app contains a fix for this issue: + +=== "set_reactive02.py" + + ```python hl_lines="33 34" + --8<-- "docs/examples/guide/reactivity/set_reactive02.py" + ``` + + 1. The attribute is set via `set_reactive`, which avoids calling the watcher. + +=== "Output" + + ```{.textual path="docs/examples/guide/reactivity/set_reactive02.py"} + ``` + +The line `self.set_reactive(Greeter.greeting, greeting)` sets the `greeting` attribute but doesn't immediately invoke the watcher. + +## Data binding + +Reactive attributes from one widget may be *bound* (connected) to another widget, so that changes to a single reactive will automatically update another widget (potentially more than one). + +To bind reactive attributes, call [data_bind][textual.dom.DOMNode.data_bind] on a widget. +This method accepts reactives (as class attributes) in positional arguments or keyword arguments. + +Let's look at an app that could benefit from data binding. +In the following code we have a `WorldClock` widget which displays the time in any given timezone. + + +!!! note + + This example uses the [pytz](https://pypi.org/project/pytz/) library for working with timezones. + You can install pytz with `pip install pytz`. + + +=== "world_clock01.py" + + ```python + --8<-- "docs/examples/guide/reactivity/world_clock01.py" + ``` + + 1. Update the `time` reactive attribute of every `WorldClock`. + +=== "world_clock01.tcss" + + ```css + --8<-- "docs/examples/guide/reactivity/world_clock01.tcss" + ``` + +=== "Output" + + ```{.textual path="docs/examples/guide/reactivity/world_clock01.py"} + ``` + +We've added three world clocks for London, Paris, and Tokyo. +The clocks are kept up-to-date by watching the app's `time` reactive, and updating the clocks in a loop. + +While this approach works fine, it does require we take care to update every `WorldClock` we mount. +Let's see how data binding can simplify this. + +The following app calls `data_bind` on the world clock widgets to connect the app's `time` with the widget's `time` attribute: + +=== "world_clock02.py" + + ```python hl_lines="34-36" + --8<-- "docs/examples/guide/reactivity/world_clock02.py" + ``` + + 1. Bind the `time` attribute, so that changes to `time` will also change the `time` attribute on the `WorldClock` widgets. The `data_bind` method also returns the widget, so we can yield its return value. + +=== "world_clock01.tcss" + + ```css + --8<-- "docs/examples/guide/reactivity/world_clock01.tcss" + ``` + +=== "Output" + + ```{.textual path="docs/examples/guide/reactivity/world_clock02.py"} + ``` + +Note how the addition of the `data_bind` methods negates the need for the watcher in `world_clock01.py`. + + +!!! note + + Data binding works in a single direction. + Setting `time` on the app updates the clocks. + But setting `time` on the clocks will *not* update `time` on the app. + + +In the previous example app, the call to `data_bind(WorldClockApp.time)` worked because both reactive attributes were named `time`. +If you want to bind a reactive attribute which has a different name, you can use keyword arguments. + +In the following app we have changed the attribute name on `WorldClock` from `time` to `clock_time`. +We can make the app continue to work by changing the `data_bind` call to `data_bind(clock_time=WorldClockApp.time)`: + + +=== "world_clock03.py" + + ```python hl_lines="34-38" + --8<-- "docs/examples/guide/reactivity/world_clock03.py" + ``` + + 1. Uses keyword arguments to bind the `time` attribute of `WorldClockApp` to `clock_time` on `WorldClock`. + +=== "world_clock01.tcss" + + ```css + --8<-- "docs/examples/guide/reactivity/world_clock01.tcss" + ``` + +=== "Output" + + ```{.textual path="docs/examples/guide/reactivity/world_clock02.py"} + ``` diff --git a/docs/widgets/text_area.md b/docs/widgets/text_area.md index 0dca1339ec..c0a95c265a 100644 --- a/docs/widgets/text_area.md +++ b/docs/widgets/text_area.md @@ -1,7 +1,6 @@ - # TextArea -!!! tip +!!! tip Added in version 0.38.0. Soft wrapping added in version 0.48.0. @@ -18,7 +17,7 @@ and a variety of keybindings. By default, the `TextArea` widget is a standard multi-line input box with soft-wrapping enabled. -If you're interested in editing code, you may wish to use the [`TextArea.code_editor`] convenience constructor. +If you're interested in editing code, you may wish to use the [`TextArea.code_editor`][textual.widgets._text_area.TextArea.code_editor] convenience constructor. This is a method which, by default, returns a new `TextArea` with soft-wrapping disabled, line numbers enabled, and the tab key behavior configured to insert `\t`. ### Syntax highlighting dependencies @@ -68,7 +67,6 @@ text_area.language = "markdown" !!! note More built-in languages will be added in the future. For now, you can [add your own](#adding-support-for-custom-languages). - ### Reading content from `TextArea` There are a number of ways to retrieve content from the `TextArea`: @@ -136,7 +134,7 @@ There are a number of additional utility methods available for interacting with ##### Location information -A number of properties exist on `TextArea` which give information about the current cursor location. +Many properties exist on `TextArea` which give information about the current cursor location. These properties begin with `cursor_at_`, and return booleans. For example, [`cursor_at_start_of_line`][textual.widgets._text_area.TextArea.cursor_at_start_of_line] tells us if the cursor is at a start of line. @@ -175,12 +173,14 @@ the cursor, selection, gutter, and more. #### Default theme -The default `TextArea` theme is called `css`. -This a theme which takes values entirely from CSS. +The default `TextArea` theme is called `css`, which takes it's values entirely from CSS. This means that the default appearance of the widget fits nicely into a standard Textual application, and looks right on both dark and light mode. -More complex applications such as code editors will likely want to use pre-defined themes such as `monokai`. +When using the `css` theme, you can make use of [component classes][textual.widgets.TextArea.COMPONENT_CLASSES] to style elements of the `TextArea`. +For example, the CSS code `TextArea .text-area--cursor { background: green; }` will make the cursor `green`. + +More complex applications such as code editors may want to use pre-defined themes such as `monokai`. This involves using a `TextAreaTheme` object, which we cover in detail below. This allows full customization of the `TextArea`, including syntax highlighting, at the code level. @@ -190,7 +190,7 @@ The initial theme of the `TextArea` is determined by the `theme` parameter. ```python # Create a TextArea with the 'dracula' theme. -yield TextArea("print(123)", language="python", theme="dracula") +yield TextArea.code_editor("print(123)", language="python", theme="dracula") ``` You can check which themes are available using the [`available_themes`][textual.widgets._text_area.TextArea.available_themes] property. @@ -198,7 +198,7 @@ You can check which themes are available using the [`available_themes`][textual. ```python >>> text_area = TextArea() >>> print(text_area.available_themes) -{'dracula', 'github_light', 'monokai', 'vscode_dark'} +{'css', 'dracula', 'github_light', 'monokai', 'vscode_dark'} ``` After creating a `TextArea`, you can change the theme by setting the [`theme`][textual.widgets._text_area.TextArea.theme] @@ -212,7 +212,12 @@ On setting this attribute the `TextArea` will immediately refresh to display the #### Custom themes -Using custom (non-builtin) themes is two-step process: +!!! note + + Custom themes are only relevant for people who are looking to customize syntax highlighting. + If you're only editing plain text, and wish to recolor aspects of the `TextArea`, you should use the [provided component classes][textual.widgets.TextArea.COMPONENT_CLASSES]. + +Using custom (non-builtin) themes is a two-step process: 1. Create an instance of [`TextAreaTheme`][textual.widgets.text_area.TextAreaTheme]. 2. Register it using [`TextArea.register_theme`][textual.widgets._text_area.TextArea.register_theme]. @@ -283,11 +288,13 @@ This immediately updates the appearance of the `TextArea`: ```{.textual path="docs/examples/widgets/text_area_custom_theme.py" columns="42" lines="8"} ``` -### Tab behaviour +### Tab and Escape behaviour Pressing the ++tab++ key will shift focus to the next widget in your application by default. This matches how other widgets work in Textual. -To have ++tab++ insert a `\t` character, set the `tab_behaviour` attribute to the string value `"indent"`. + +To have ++tab++ insert a `\t` character, set the `tab_behavior` attribute to the string value `"indent"`. +While in this mode, you can shift focus by pressing the ++escape++ key. ### Indentation @@ -295,6 +302,27 @@ The character(s) inserted when you press tab is controlled by setting the `inden If `indent_type == "spaces"`, pressing ++tab++ will insert up to `indent_width` spaces in order to align with the next tab stop. +### Undo and redo + +`TextArea` offers `undo` and `redo` methods. +By default, `undo` is bound to Ctrl+Z and `redo` to Ctrl+Y. + +The `TextArea` uses a heuristic to place _checkpoints_ after certain types of edit. +When you call `undo`, all of the edits between now and the most recent checkpoint are reverted. +You can manually add a checkpoint by calling the [`TextArea.history.checkpoint()`][textual.widgets.text_area.EditHistory.checkpoint] instance method. + +The undo and redo history uses a stack-based system, where a single item on the stack represents a single checkpoint. +In memory-constrained environments, you may wish to reduce the maximum number of checkpoints that can exist. +You can do this by passing the `max_checkpoints` argument to the `TextArea` constructor. + +### Read-only mode + +`TextArea.read_only` is a boolean reactive attribute which, if `True`, will prevent users from modifying content in the `TextArea`. + +While `read_only=True`, you can still modify the content programmatically. + +While this mode is active, the `TextArea` receives the `-read-only` CSS class, which you can use to supply custom styles for read-only mode. + ### Line separators When content is loaded into `TextArea`, the content is scanned from beginning to end @@ -457,7 +485,6 @@ If you notice some highlights are missing after registering a language, the issu The names assigned in tree-sitter highlight queries are often reused across multiple languages. For example, `@string` is used in many languages to highlight strings. - #### Navigation and wrapping information If you're building functionality on top of `TextArea`, it may be useful to inspect the `navigator` and `wrapped_document` attributes. @@ -471,14 +498,15 @@ A detailed view of these classes is out of scope, but do note that a lot of the | Name | Type | Default | Description | |------------------------|--------------------------|---------------|------------------------------------------------------------------| -| `language` | `str | None` | `None` | The language to use for syntax highlighting. | -| `theme` | `str | None` | `TextAreaTheme.default()` | The theme to use for syntax highlighting. | +| `language` | `str | None` | `None` | The language to use for syntax highlighting. | +| `theme` | `str` | `"css"` | The theme to use. | | `selection` | `Selection` | `Selection()` | The current selection. | | `show_line_numbers` | `bool` | `False` | Show or hide line numbers. | | `indent_width` | `int` | `4` | The number of spaces to indent and width of tabs. | | `match_cursor_bracket` | `bool` | `True` | Enable/disable highlighting matching brackets under cursor. | | `cursor_blink` | `bool` | `True` | Enable/disable blinking of the cursor when the widget has focus. | | `soft_wrap` | `bool` | `True` | Enable/disable soft wrapping. | +| `read_only` | `bool` | `False` | Enable/disable read-only mode. | ## Messages @@ -494,7 +522,6 @@ The `TextArea` widget defines the following bindings: show_root_heading: false show_root_toc_entry: false - ## Component classes The `TextArea` defines component classes that can style various aspects of the widget. @@ -511,11 +538,11 @@ Styles from the `theme` attribute take priority. - [`TextAreaTheme`][textual.widgets.text_area.TextAreaTheme] - theming the `TextArea` - [`DocumentNavigator`][textual.widgets.text_area.DocumentNavigator] - guides cursor movement - [`WrappedDocument`][textual.widgets.text_area.WrappedDocument] - manages wrapping the document +- [`EditHistory`][textual.widgets.text_area.EditHistory] - manages the undo stack - The tree-sitter documentation [website](https://tree-sitter.github.io/tree-sitter/). - The tree-sitter Python bindings [repository](https://github.com/tree-sitter/py-tree-sitter). - `py-tree-sitter-languages` [repository](https://github.com/grantjenks/py-tree-sitter-languages) (provides binary wheels for a large variety of tree-sitter languages). - ## Additional notes - To remove the outline effect when the `TextArea` is focused, you can set `border: none; padding: 0;` in your CSS. diff --git a/poetry.lock b/poetry.lock index f248950165..73ec9fbf67 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,88 +1,88 @@ -# This file is automatically @generated by Poetry 1.6.1 and should not be changed by hand. +# This file is automatically @generated by Poetry 1.7.1 and should not be changed by hand. [[package]] name = "aiohttp" -version = "3.9.2" +version = "3.9.3" description = "Async http client/server framework (asyncio)" optional = false python-versions = ">=3.8" files = [ - {file = "aiohttp-3.9.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:772fbe371788e61c58d6d3d904268e48a594ba866804d08c995ad71b144f94cb"}, - {file = "aiohttp-3.9.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:edd4f1af2253f227ae311ab3d403d0c506c9b4410c7fc8d9573dec6d9740369f"}, - {file = "aiohttp-3.9.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:cfee9287778399fdef6f8a11c9e425e1cb13cc9920fd3a3df8f122500978292b"}, - {file = "aiohttp-3.9.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3cc158466f6a980a6095ee55174d1de5730ad7dec251be655d9a6a9dd7ea1ff9"}, - {file = "aiohttp-3.9.2-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:54ec82f45d57c9a65a1ead3953b51c704f9587440e6682f689da97f3e8defa35"}, - {file = "aiohttp-3.9.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:abeb813a18eb387f0d835ef51f88568540ad0325807a77a6e501fed4610f864e"}, - {file = "aiohttp-3.9.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cc91d07280d7d169f3a0f9179d8babd0ee05c79d4d891447629ff0d7d8089ec2"}, - {file = "aiohttp-3.9.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b65e861f4bebfb660f7f0f40fa3eb9f2ab9af10647d05dac824390e7af8f75b7"}, - {file = "aiohttp-3.9.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:04fd8ffd2be73d42bcf55fd78cde7958eeee6d4d8f73c3846b7cba491ecdb570"}, - {file = "aiohttp-3.9.2-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:3d8d962b439a859b3ded9a1e111a4615357b01620a546bc601f25b0211f2da81"}, - {file = "aiohttp-3.9.2-cp310-cp310-musllinux_1_1_ppc64le.whl", hash = "sha256:8ceb658afd12b27552597cf9a65d9807d58aef45adbb58616cdd5ad4c258c39e"}, - {file = "aiohttp-3.9.2-cp310-cp310-musllinux_1_1_s390x.whl", hash = "sha256:0e4ee4df741670560b1bc393672035418bf9063718fee05e1796bf867e995fad"}, - {file = "aiohttp-3.9.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:2dec87a556f300d3211decf018bfd263424f0690fcca00de94a837949fbcea02"}, - {file = "aiohttp-3.9.2-cp310-cp310-win32.whl", hash = "sha256:3e1a800f988ce7c4917f34096f81585a73dbf65b5c39618b37926b1238cf9bc4"}, - {file = "aiohttp-3.9.2-cp310-cp310-win_amd64.whl", hash = "sha256:ea510718a41b95c236c992b89fdfc3d04cc7ca60281f93aaada497c2b4e05c46"}, - {file = "aiohttp-3.9.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:6aaa6f99256dd1b5756a50891a20f0d252bd7bdb0854c5d440edab4495c9f973"}, - {file = "aiohttp-3.9.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:a27d8c70ad87bcfce2e97488652075a9bdd5b70093f50b10ae051dfe5e6baf37"}, - {file = "aiohttp-3.9.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:54287bcb74d21715ac8382e9de146d9442b5f133d9babb7e5d9e453faadd005e"}, - {file = "aiohttp-3.9.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5bb3d05569aa83011fcb346b5266e00b04180105fcacc63743fc2e4a1862a891"}, - {file = "aiohttp-3.9.2-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c8534e7d69bb8e8d134fe2be9890d1b863518582f30c9874ed7ed12e48abe3c4"}, - {file = "aiohttp-3.9.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4bd9d5b989d57b41e4ff56ab250c5ddf259f32db17159cce630fd543376bd96b"}, - {file = "aiohttp-3.9.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fa6904088e6642609981f919ba775838ebf7df7fe64998b1a954fb411ffb4663"}, - {file = "aiohttp-3.9.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:bda42eb410be91b349fb4ee3a23a30ee301c391e503996a638d05659d76ea4c2"}, - {file = "aiohttp-3.9.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:193cc1ccd69d819562cc7f345c815a6fc51d223b2ef22f23c1a0f67a88de9a72"}, - {file = "aiohttp-3.9.2-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:b9f1cb839b621f84a5b006848e336cf1496688059d2408e617af33e3470ba204"}, - {file = "aiohttp-3.9.2-cp311-cp311-musllinux_1_1_ppc64le.whl", hash = "sha256:d22a0931848b8c7a023c695fa2057c6aaac19085f257d48baa24455e67df97ec"}, - {file = "aiohttp-3.9.2-cp311-cp311-musllinux_1_1_s390x.whl", hash = "sha256:4112d8ba61fbd0abd5d43a9cb312214565b446d926e282a6d7da3f5a5aa71d36"}, - {file = "aiohttp-3.9.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:c4ad4241b52bb2eb7a4d2bde060d31c2b255b8c6597dd8deac2f039168d14fd7"}, - {file = "aiohttp-3.9.2-cp311-cp311-win32.whl", hash = "sha256:ee2661a3f5b529f4fc8a8ffee9f736ae054adfb353a0d2f78218be90617194b3"}, - {file = "aiohttp-3.9.2-cp311-cp311-win_amd64.whl", hash = "sha256:4deae2c165a5db1ed97df2868ef31ca3cc999988812e82386d22937d9d6fed52"}, - {file = "aiohttp-3.9.2-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:6f4cdba12539215aaecf3c310ce9d067b0081a0795dd8a8805fdb67a65c0572a"}, - {file = "aiohttp-3.9.2-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:84e843b33d5460a5c501c05539809ff3aee07436296ff9fbc4d327e32aa3a326"}, - {file = "aiohttp-3.9.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8008d0f451d66140a5aa1c17e3eedc9d56e14207568cd42072c9d6b92bf19b52"}, - {file = "aiohttp-3.9.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:61c47ab8ef629793c086378b1df93d18438612d3ed60dca76c3422f4fbafa792"}, - {file = "aiohttp-3.9.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:bc71f748e12284312f140eaa6599a520389273174b42c345d13c7e07792f4f57"}, - {file = "aiohttp-3.9.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a1c3a4d0ab2f75f22ec80bca62385db2e8810ee12efa8c9e92efea45c1849133"}, - {file = "aiohttp-3.9.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9a87aa0b13bbee025faa59fa58861303c2b064b9855d4c0e45ec70182bbeba1b"}, - {file = "aiohttp-3.9.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e2cc0d04688b9f4a7854c56c18aa7af9e5b0a87a28f934e2e596ba7e14783192"}, - {file = "aiohttp-3.9.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:1956e3ac376b1711c1533266dec4efd485f821d84c13ce1217d53e42c9e65f08"}, - {file = "aiohttp-3.9.2-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:114da29f39eccd71b93a0fcacff178749a5c3559009b4a4498c2c173a6d74dff"}, - {file = "aiohttp-3.9.2-cp312-cp312-musllinux_1_1_ppc64le.whl", hash = "sha256:3f17999ae3927d8a9a823a1283b201344a0627272f92d4f3e3a4efe276972fe8"}, - {file = "aiohttp-3.9.2-cp312-cp312-musllinux_1_1_s390x.whl", hash = "sha256:f31df6a32217a34ae2f813b152a6f348154f948c83213b690e59d9e84020925c"}, - {file = "aiohttp-3.9.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:7a75307ffe31329928a8d47eae0692192327c599113d41b278d4c12b54e1bd11"}, - {file = "aiohttp-3.9.2-cp312-cp312-win32.whl", hash = "sha256:972b63d589ff8f305463593050a31b5ce91638918da38139b9d8deaba9e0fed7"}, - {file = "aiohttp-3.9.2-cp312-cp312-win_amd64.whl", hash = "sha256:200dc0246f0cb5405c80d18ac905c8350179c063ea1587580e3335bfc243ba6a"}, - {file = "aiohttp-3.9.2-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:158564d0d1020e0d3fe919a81d97aadad35171e13e7b425b244ad4337fc6793a"}, - {file = "aiohttp-3.9.2-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:da1346cd0ccb395f0ed16b113ebb626fa43b7b07fd7344fce33e7a4f04a8897a"}, - {file = "aiohttp-3.9.2-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:eaa9256de26ea0334ffa25f1913ae15a51e35c529a1ed9af8e6286dd44312554"}, - {file = "aiohttp-3.9.2-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1543e7fb00214fb4ccead42e6a7d86f3bb7c34751ec7c605cca7388e525fd0b4"}, - {file = "aiohttp-3.9.2-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:186e94570433a004e05f31f632726ae0f2c9dee4762a9ce915769ce9c0a23d89"}, - {file = "aiohttp-3.9.2-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d52d20832ac1560f4510d68e7ba8befbc801a2b77df12bd0cd2bcf3b049e52a4"}, - {file = "aiohttp-3.9.2-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1c45e4e815ac6af3b72ca2bde9b608d2571737bb1e2d42299fc1ffdf60f6f9a1"}, - {file = "aiohttp-3.9.2-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:aa906b9bdfd4a7972dd0628dbbd6413d2062df5b431194486a78f0d2ae87bd55"}, - {file = "aiohttp-3.9.2-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:68bbee9e17d66f17bb0010aa15a22c6eb28583edcc8b3212e2b8e3f77f3ebe2a"}, - {file = "aiohttp-3.9.2-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:4c189b64bd6d9a403a1a3f86a3ab3acbc3dc41a68f73a268a4f683f89a4dec1f"}, - {file = "aiohttp-3.9.2-cp38-cp38-musllinux_1_1_ppc64le.whl", hash = "sha256:8a7876f794523123bca6d44bfecd89c9fec9ec897a25f3dd202ee7fc5c6525b7"}, - {file = "aiohttp-3.9.2-cp38-cp38-musllinux_1_1_s390x.whl", hash = "sha256:d23fba734e3dd7b1d679b9473129cd52e4ec0e65a4512b488981a56420e708db"}, - {file = "aiohttp-3.9.2-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:b141753be581fab842a25cb319f79536d19c2a51995d7d8b29ee290169868eab"}, - {file = "aiohttp-3.9.2-cp38-cp38-win32.whl", hash = "sha256:103daf41ff3b53ba6fa09ad410793e2e76c9d0269151812e5aba4b9dd674a7e8"}, - {file = "aiohttp-3.9.2-cp38-cp38-win_amd64.whl", hash = "sha256:328918a6c2835861ff7afa8c6d2c70c35fdaf996205d5932351bdd952f33fa2f"}, - {file = "aiohttp-3.9.2-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:5264d7327c9464786f74e4ec9342afbbb6ee70dfbb2ec9e3dfce7a54c8043aa3"}, - {file = "aiohttp-3.9.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:07205ae0015e05c78b3288c1517afa000823a678a41594b3fdc870878d645305"}, - {file = "aiohttp-3.9.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:ae0a1e638cffc3ec4d4784b8b4fd1cf28968febc4bd2718ffa25b99b96a741bd"}, - {file = "aiohttp-3.9.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d43302a30ba1166325974858e6ef31727a23bdd12db40e725bec0f759abce505"}, - {file = "aiohttp-3.9.2-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:16a967685907003765855999af11a79b24e70b34dc710f77a38d21cd9fc4f5fe"}, - {file = "aiohttp-3.9.2-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6fa3ee92cd441d5c2d07ca88d7a9cef50f7ec975f0117cd0c62018022a184308"}, - {file = "aiohttp-3.9.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0b500c5ad9c07639d48615a770f49618130e61be36608fc9bc2d9bae31732b8f"}, - {file = "aiohttp-3.9.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c07327b368745b1ce2393ae9e1aafed7073d9199e1dcba14e035cc646c7941bf"}, - {file = "aiohttp-3.9.2-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:cc7d6502c23a0ec109687bf31909b3fb7b196faf198f8cff68c81b49eb316ea9"}, - {file = "aiohttp-3.9.2-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:07be2be7071723c3509ab5c08108d3a74f2181d4964e869f2504aaab68f8d3e8"}, - {file = "aiohttp-3.9.2-cp39-cp39-musllinux_1_1_ppc64le.whl", hash = "sha256:122468f6fee5fcbe67cb07014a08c195b3d4c41ff71e7b5160a7bcc41d585a5f"}, - {file = "aiohttp-3.9.2-cp39-cp39-musllinux_1_1_s390x.whl", hash = "sha256:00a9abcea793c81e7f8778ca195a1714a64f6d7436c4c0bb168ad2a212627000"}, - {file = "aiohttp-3.9.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:7a9825fdd64ecac5c670234d80bb52bdcaa4139d1f839165f548208b3779c6c6"}, - {file = "aiohttp-3.9.2-cp39-cp39-win32.whl", hash = "sha256:5422cd9a4a00f24c7244e1b15aa9b87935c85fb6a00c8ac9b2527b38627a9211"}, - {file = "aiohttp-3.9.2-cp39-cp39-win_amd64.whl", hash = "sha256:7d579dcd5d82a86a46f725458418458fa43686f6a7b252f2966d359033ffc8ab"}, - {file = "aiohttp-3.9.2.tar.gz", hash = "sha256:b0ad0a5e86ce73f5368a164c10ada10504bf91869c05ab75d982c6048217fbf7"}, + {file = "aiohttp-3.9.3-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:939677b61f9d72a4fa2a042a5eee2a99a24001a67c13da113b2e30396567db54"}, + {file = "aiohttp-3.9.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:1f5cd333fcf7590a18334c90f8c9147c837a6ec8a178e88d90a9b96ea03194cc"}, + {file = "aiohttp-3.9.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:82e6aa28dd46374f72093eda8bcd142f7771ee1eb9d1e223ff0fa7177a96b4a5"}, + {file = "aiohttp-3.9.3-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f56455b0c2c7cc3b0c584815264461d07b177f903a04481dfc33e08a89f0c26b"}, + {file = "aiohttp-3.9.3-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:bca77a198bb6e69795ef2f09a5f4c12758487f83f33d63acde5f0d4919815768"}, + {file = "aiohttp-3.9.3-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e083c285857b78ee21a96ba1eb1b5339733c3563f72980728ca2b08b53826ca5"}, + {file = "aiohttp-3.9.3-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ab40e6251c3873d86ea9b30a1ac6d7478c09277b32e14745d0d3c6e76e3c7e29"}, + {file = "aiohttp-3.9.3-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:df822ee7feaaeffb99c1a9e5e608800bd8eda6e5f18f5cfb0dc7eeb2eaa6bbec"}, + {file = "aiohttp-3.9.3-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:acef0899fea7492145d2bbaaaec7b345c87753168589cc7faf0afec9afe9b747"}, + {file = "aiohttp-3.9.3-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:cd73265a9e5ea618014802ab01babf1940cecb90c9762d8b9e7d2cc1e1969ec6"}, + {file = "aiohttp-3.9.3-cp310-cp310-musllinux_1_1_ppc64le.whl", hash = "sha256:a78ed8a53a1221393d9637c01870248a6f4ea5b214a59a92a36f18151739452c"}, + {file = "aiohttp-3.9.3-cp310-cp310-musllinux_1_1_s390x.whl", hash = "sha256:6b0e029353361f1746bac2e4cc19b32f972ec03f0f943b390c4ab3371840aabf"}, + {file = "aiohttp-3.9.3-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:7cf5c9458e1e90e3c390c2639f1017a0379a99a94fdfad3a1fd966a2874bba52"}, + {file = "aiohttp-3.9.3-cp310-cp310-win32.whl", hash = "sha256:3e59c23c52765951b69ec45ddbbc9403a8761ee6f57253250c6e1536cacc758b"}, + {file = "aiohttp-3.9.3-cp310-cp310-win_amd64.whl", hash = "sha256:055ce4f74b82551678291473f66dc9fb9048a50d8324278751926ff0ae7715e5"}, + {file = "aiohttp-3.9.3-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:6b88f9386ff1ad91ace19d2a1c0225896e28815ee09fc6a8932fded8cda97c3d"}, + {file = "aiohttp-3.9.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:c46956ed82961e31557b6857a5ca153c67e5476972e5f7190015018760938da2"}, + {file = "aiohttp-3.9.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:07b837ef0d2f252f96009e9b8435ec1fef68ef8b1461933253d318748ec1acdc"}, + {file = "aiohttp-3.9.3-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dad46e6f620574b3b4801c68255492e0159d1712271cc99d8bdf35f2043ec266"}, + {file = "aiohttp-3.9.3-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5ed3e046ea7b14938112ccd53d91c1539af3e6679b222f9469981e3dac7ba1ce"}, + {file = "aiohttp-3.9.3-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:039df344b45ae0b34ac885ab5b53940b174530d4dd8a14ed8b0e2155b9dddccb"}, + {file = "aiohttp-3.9.3-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7943c414d3a8d9235f5f15c22ace69787c140c80b718dcd57caaade95f7cd93b"}, + {file = "aiohttp-3.9.3-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:84871a243359bb42c12728f04d181a389718710129b36b6aad0fc4655a7647d4"}, + {file = "aiohttp-3.9.3-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:5eafe2c065df5401ba06821b9a054d9cb2848867f3c59801b5d07a0be3a380ae"}, + {file = "aiohttp-3.9.3-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:9d3c9b50f19704552f23b4eaea1fc082fdd82c63429a6506446cbd8737823da3"}, + {file = "aiohttp-3.9.3-cp311-cp311-musllinux_1_1_ppc64le.whl", hash = "sha256:f033d80bc6283092613882dfe40419c6a6a1527e04fc69350e87a9df02bbc283"}, + {file = "aiohttp-3.9.3-cp311-cp311-musllinux_1_1_s390x.whl", hash = "sha256:2c895a656dd7e061b2fd6bb77d971cc38f2afc277229ce7dd3552de8313a483e"}, + {file = "aiohttp-3.9.3-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:1f5a71d25cd8106eab05f8704cd9167b6e5187bcdf8f090a66c6d88b634802b4"}, + {file = "aiohttp-3.9.3-cp311-cp311-win32.whl", hash = "sha256:50fca156d718f8ced687a373f9e140c1bb765ca16e3d6f4fe116e3df7c05b2c5"}, + {file = "aiohttp-3.9.3-cp311-cp311-win_amd64.whl", hash = "sha256:5fe9ce6c09668063b8447f85d43b8d1c4e5d3d7e92c63173e6180b2ac5d46dd8"}, + {file = "aiohttp-3.9.3-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:38a19bc3b686ad55804ae931012f78f7a534cce165d089a2059f658f6c91fa60"}, + {file = "aiohttp-3.9.3-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:770d015888c2a598b377bd2f663adfd947d78c0124cfe7b959e1ef39f5b13869"}, + {file = "aiohttp-3.9.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ee43080e75fc92bf36219926c8e6de497f9b247301bbf88c5c7593d931426679"}, + {file = "aiohttp-3.9.3-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:52df73f14ed99cee84865b95a3d9e044f226320a87af208f068ecc33e0c35b96"}, + {file = "aiohttp-3.9.3-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:dc9b311743a78043b26ffaeeb9715dc360335e5517832f5a8e339f8a43581e4d"}, + {file = "aiohttp-3.9.3-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b955ed993491f1a5da7f92e98d5dad3c1e14dc175f74517c4e610b1f2456fb11"}, + {file = "aiohttp-3.9.3-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:504b6981675ace64c28bf4a05a508af5cde526e36492c98916127f5a02354d53"}, + {file = "aiohttp-3.9.3-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a6fe5571784af92b6bc2fda8d1925cccdf24642d49546d3144948a6a1ed58ca5"}, + {file = "aiohttp-3.9.3-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:ba39e9c8627edc56544c8628cc180d88605df3892beeb2b94c9bc857774848ca"}, + {file = "aiohttp-3.9.3-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:e5e46b578c0e9db71d04c4b506a2121c0cb371dd89af17a0586ff6769d4c58c1"}, + {file = "aiohttp-3.9.3-cp312-cp312-musllinux_1_1_ppc64le.whl", hash = "sha256:938a9653e1e0c592053f815f7028e41a3062e902095e5a7dc84617c87267ebd5"}, + {file = "aiohttp-3.9.3-cp312-cp312-musllinux_1_1_s390x.whl", hash = "sha256:c3452ea726c76e92f3b9fae4b34a151981a9ec0a4847a627c43d71a15ac32aa6"}, + {file = "aiohttp-3.9.3-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:ff30218887e62209942f91ac1be902cc80cddb86bf00fbc6783b7a43b2bea26f"}, + {file = "aiohttp-3.9.3-cp312-cp312-win32.whl", hash = "sha256:38f307b41e0bea3294a9a2a87833191e4bcf89bb0365e83a8be3a58b31fb7f38"}, + {file = "aiohttp-3.9.3-cp312-cp312-win_amd64.whl", hash = "sha256:b791a3143681a520c0a17e26ae7465f1b6f99461a28019d1a2f425236e6eedb5"}, + {file = "aiohttp-3.9.3-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:0ed621426d961df79aa3b963ac7af0d40392956ffa9be022024cd16297b30c8c"}, + {file = "aiohttp-3.9.3-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:7f46acd6a194287b7e41e87957bfe2ad1ad88318d447caf5b090012f2c5bb528"}, + {file = "aiohttp-3.9.3-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:feeb18a801aacb098220e2c3eea59a512362eb408d4afd0c242044c33ad6d542"}, + {file = "aiohttp-3.9.3-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f734e38fd8666f53da904c52a23ce517f1b07722118d750405af7e4123933511"}, + {file = "aiohttp-3.9.3-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b40670ec7e2156d8e57f70aec34a7216407848dfe6c693ef131ddf6e76feb672"}, + {file = "aiohttp-3.9.3-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:fdd215b7b7fd4a53994f238d0f46b7ba4ac4c0adb12452beee724ddd0743ae5d"}, + {file = "aiohttp-3.9.3-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:017a21b0df49039c8f46ca0971b3a7fdc1f56741ab1240cb90ca408049766168"}, + {file = "aiohttp-3.9.3-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e99abf0bba688259a496f966211c49a514e65afa9b3073a1fcee08856e04425b"}, + {file = "aiohttp-3.9.3-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:648056db9a9fa565d3fa851880f99f45e3f9a771dd3ff3bb0c048ea83fb28194"}, + {file = "aiohttp-3.9.3-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:8aacb477dc26797ee089721536a292a664846489c49d3ef9725f992449eda5a8"}, + {file = "aiohttp-3.9.3-cp38-cp38-musllinux_1_1_ppc64le.whl", hash = "sha256:522a11c934ea660ff8953eda090dcd2154d367dec1ae3c540aff9f8a5c109ab4"}, + {file = "aiohttp-3.9.3-cp38-cp38-musllinux_1_1_s390x.whl", hash = "sha256:5bce0dc147ca85caa5d33debc4f4d65e8e8b5c97c7f9f660f215fa74fc49a321"}, + {file = "aiohttp-3.9.3-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:4b4af9f25b49a7be47c0972139e59ec0e8285c371049df1a63b6ca81fdd216a2"}, + {file = "aiohttp-3.9.3-cp38-cp38-win32.whl", hash = "sha256:298abd678033b8571995650ccee753d9458dfa0377be4dba91e4491da3f2be63"}, + {file = "aiohttp-3.9.3-cp38-cp38-win_amd64.whl", hash = "sha256:69361bfdca5468c0488d7017b9b1e5ce769d40b46a9f4a2eed26b78619e9396c"}, + {file = "aiohttp-3.9.3-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:0fa43c32d1643f518491d9d3a730f85f5bbaedcbd7fbcae27435bb8b7a061b29"}, + {file = "aiohttp-3.9.3-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:835a55b7ca49468aaaac0b217092dfdff370e6c215c9224c52f30daaa735c1c1"}, + {file = "aiohttp-3.9.3-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:06a9b2c8837d9a94fae16c6223acc14b4dfdff216ab9b7202e07a9a09541168f"}, + {file = "aiohttp-3.9.3-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:abf151955990d23f84205286938796c55ff11bbfb4ccfada8c9c83ae6b3c89a3"}, + {file = "aiohttp-3.9.3-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:59c26c95975f26e662ca78fdf543d4eeaef70e533a672b4113dd888bd2423caa"}, + {file = "aiohttp-3.9.3-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f95511dd5d0e05fd9728bac4096319f80615aaef4acbecb35a990afebe953b0e"}, + {file = "aiohttp-3.9.3-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:595f105710293e76b9dc09f52e0dd896bd064a79346234b521f6b968ffdd8e58"}, + {file = "aiohttp-3.9.3-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c7c8b816c2b5af5c8a436df44ca08258fc1a13b449393a91484225fcb7545533"}, + {file = "aiohttp-3.9.3-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:f1088fa100bf46e7b398ffd9904f4808a0612e1d966b4aa43baa535d1b6341eb"}, + {file = "aiohttp-3.9.3-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:f59dfe57bb1ec82ac0698ebfcdb7bcd0e99c255bd637ff613760d5f33e7c81b3"}, + {file = "aiohttp-3.9.3-cp39-cp39-musllinux_1_1_ppc64le.whl", hash = "sha256:361a1026c9dd4aba0109e4040e2aecf9884f5cfe1b1b1bd3d09419c205e2e53d"}, + {file = "aiohttp-3.9.3-cp39-cp39-musllinux_1_1_s390x.whl", hash = "sha256:363afe77cfcbe3a36353d8ea133e904b108feea505aa4792dad6585a8192c55a"}, + {file = "aiohttp-3.9.3-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:8e2c45c208c62e955e8256949eb225bd8b66a4c9b6865729a786f2aa79b72e9d"}, + {file = "aiohttp-3.9.3-cp39-cp39-win32.whl", hash = "sha256:f7217af2e14da0856e082e96ff637f14ae45c10a5714b63c77f26d8884cf1051"}, + {file = "aiohttp-3.9.3-cp39-cp39-win_amd64.whl", hash = "sha256:27468897f628c627230dba07ec65dc8d0db566923c48f29e084ce382119802bc"}, + {file = "aiohttp-3.9.3.tar.gz", hash = "sha256:90842933e5d1ff760fae6caca4b2b3edba53ba8f4b71e95dacf2818a2aca06f7"}, ] [package.dependencies] @@ -227,13 +227,13 @@ uvloop = ["uvloop (>=0.15.2)"] [[package]] name = "certifi" -version = "2023.11.17" +version = "2024.2.2" description = "Python package for providing Mozilla's CA Bundle." optional = false python-versions = ">=3.6" files = [ - {file = "certifi-2023.11.17-py3-none-any.whl", hash = "sha256:e036ab49d5b79556f99cfc2d9320b34cfbe5be05c5871b51de9329f0603b0474"}, - {file = "certifi-2023.11.17.tar.gz", hash = "sha256:9b469f3a900bf28dc19b8cfbf8019bf47f7fdd1a65a1d4ffb98fc14166beb4d1"}, + {file = "certifi-2024.2.2-py3-none-any.whl", hash = "sha256:dc383c07b76109f368f6106eee2b593b04a011ea4d55f652c6ca24a754d1cdd1"}, + {file = "certifi-2024.2.2.tar.gz", hash = "sha256:0569859f95fc761b18b45ef421b1290a0f65f147e92a1e5eb3e635f9a5e4e66f"}, ] [[package]] @@ -763,13 +763,13 @@ i18n = ["Babel (>=2.7)"] [[package]] name = "linkify-it-py" -version = "2.0.2" +version = "2.0.3" description = "Links recognition library with FULL unicode support." optional = false python-versions = ">=3.7" files = [ - {file = "linkify-it-py-2.0.2.tar.gz", hash = "sha256:19f3060727842c254c808e99d465c80c49d2c7306788140987a1a7a29b0d6ad2"}, - {file = "linkify_it_py-2.0.2-py3-none-any.whl", hash = "sha256:a3a24428f6c96f27370d7fe61d2ac0be09017be5190d68d8658233171f1b6541"}, + {file = "linkify-it-py-2.0.3.tar.gz", hash = "sha256:68cda27e162e9215c17d786649d1da0021a451bdc436ef9e0fa0ba5234b9b048"}, + {file = "linkify_it_py-2.0.3-py3-none-any.whl", hash = "sha256:6bcbc417b0ac14323382aef5c5192c0075bf8a9d6b41820a2b66371eac6b6d79"}, ] [package.dependencies] @@ -827,71 +827,71 @@ testing = ["coverage", "pytest", "pytest-cov", "pytest-regressions"] [[package]] name = "markupsafe" -version = "2.1.4" +version = "2.1.5" description = "Safely add untrusted strings to HTML/XML markup." optional = false python-versions = ">=3.7" files = [ - {file = "MarkupSafe-2.1.4-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:de8153a7aae3835484ac168a9a9bdaa0c5eee4e0bc595503c95d53b942879c84"}, - {file = "MarkupSafe-2.1.4-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:e888ff76ceb39601c59e219f281466c6d7e66bd375b4ec1ce83bcdc68306796b"}, - {file = "MarkupSafe-2.1.4-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a0b838c37ba596fcbfca71651a104a611543077156cb0a26fe0c475e1f152ee8"}, - {file = "MarkupSafe-2.1.4-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dac1ebf6983148b45b5fa48593950f90ed6d1d26300604f321c74a9ca1609f8e"}, - {file = "MarkupSafe-2.1.4-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0fbad3d346df8f9d72622ac71b69565e621ada2ce6572f37c2eae8dacd60385d"}, - {file = "MarkupSafe-2.1.4-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:d5291d98cd3ad9a562883468c690a2a238c4a6388ab3bd155b0c75dd55ece858"}, - {file = "MarkupSafe-2.1.4-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:a7cc49ef48a3c7a0005a949f3c04f8baa5409d3f663a1b36f0eba9bfe2a0396e"}, - {file = "MarkupSafe-2.1.4-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:b83041cda633871572f0d3c41dddd5582ad7d22f65a72eacd8d3d6d00291df26"}, - {file = "MarkupSafe-2.1.4-cp310-cp310-win32.whl", hash = "sha256:0c26f67b3fe27302d3a412b85ef696792c4a2386293c53ba683a89562f9399b0"}, - {file = "MarkupSafe-2.1.4-cp310-cp310-win_amd64.whl", hash = "sha256:a76055d5cb1c23485d7ddae533229039b850db711c554a12ea64a0fd8a0129e2"}, - {file = "MarkupSafe-2.1.4-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:9e9e3c4020aa2dc62d5dd6743a69e399ce3de58320522948af6140ac959ab863"}, - {file = "MarkupSafe-2.1.4-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:0042d6a9880b38e1dd9ff83146cc3c9c18a059b9360ceae207805567aacccc69"}, - {file = "MarkupSafe-2.1.4-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:55d03fea4c4e9fd0ad75dc2e7e2b6757b80c152c032ea1d1de487461d8140efc"}, - {file = "MarkupSafe-2.1.4-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3ab3a886a237f6e9c9f4f7d272067e712cdb4efa774bef494dccad08f39d8ae6"}, - {file = "MarkupSafe-2.1.4-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:abf5ebbec056817057bfafc0445916bb688a255a5146f900445d081db08cbabb"}, - {file = "MarkupSafe-2.1.4-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:e1a0d1924a5013d4f294087e00024ad25668234569289650929ab871231668e7"}, - {file = "MarkupSafe-2.1.4-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:e7902211afd0af05fbadcc9a312e4cf10f27b779cf1323e78d52377ae4b72bea"}, - {file = "MarkupSafe-2.1.4-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:c669391319973e49a7c6230c218a1e3044710bc1ce4c8e6eb71f7e6d43a2c131"}, - {file = "MarkupSafe-2.1.4-cp311-cp311-win32.whl", hash = "sha256:31f57d64c336b8ccb1966d156932f3daa4fee74176b0fdc48ef580be774aae74"}, - {file = "MarkupSafe-2.1.4-cp311-cp311-win_amd64.whl", hash = "sha256:54a7e1380dfece8847c71bf7e33da5d084e9b889c75eca19100ef98027bd9f56"}, - {file = "MarkupSafe-2.1.4-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:a76cd37d229fc385738bd1ce4cba2a121cf26b53864c1772694ad0ad348e509e"}, - {file = "MarkupSafe-2.1.4-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:987d13fe1d23e12a66ca2073b8d2e2a75cec2ecb8eab43ff5624ba0ad42764bc"}, - {file = "MarkupSafe-2.1.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5244324676254697fe5c181fc762284e2c5fceeb1c4e3e7f6aca2b6f107e60dc"}, - {file = "MarkupSafe-2.1.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:78bc995e004681246e85e28e068111a4c3f35f34e6c62da1471e844ee1446250"}, - {file = "MarkupSafe-2.1.4-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a4d176cfdfde84f732c4a53109b293d05883e952bbba68b857ae446fa3119b4f"}, - {file = "MarkupSafe-2.1.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:f9917691f410a2e0897d1ef99619fd3f7dd503647c8ff2475bf90c3cf222ad74"}, - {file = "MarkupSafe-2.1.4-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:f06e5a9e99b7df44640767842f414ed5d7bedaaa78cd817ce04bbd6fd86e2dd6"}, - {file = "MarkupSafe-2.1.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:396549cea79e8ca4ba65525470d534e8a41070e6b3500ce2414921099cb73e8d"}, - {file = "MarkupSafe-2.1.4-cp312-cp312-win32.whl", hash = "sha256:f6be2d708a9d0e9b0054856f07ac7070fbe1754be40ca8525d5adccdbda8f475"}, - {file = "MarkupSafe-2.1.4-cp312-cp312-win_amd64.whl", hash = "sha256:5045e892cfdaecc5b4c01822f353cf2c8feb88a6ec1c0adef2a2e705eef0f656"}, - {file = "MarkupSafe-2.1.4-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:7a07f40ef8f0fbc5ef1000d0c78771f4d5ca03b4953fc162749772916b298fc4"}, - {file = "MarkupSafe-2.1.4-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d18b66fe626ac412d96c2ab536306c736c66cf2a31c243a45025156cc190dc8a"}, - {file = "MarkupSafe-2.1.4-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:698e84142f3f884114ea8cf83e7a67ca8f4ace8454e78fe960646c6c91c63bfa"}, - {file = "MarkupSafe-2.1.4-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:49a3b78a5af63ec10d8604180380c13dcd870aba7928c1fe04e881d5c792dc4e"}, - {file = "MarkupSafe-2.1.4-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:15866d7f2dc60cfdde12ebb4e75e41be862348b4728300c36cdf405e258415ec"}, - {file = "MarkupSafe-2.1.4-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:6aa5e2e7fc9bc042ae82d8b79d795b9a62bd8f15ba1e7594e3db243f158b5565"}, - {file = "MarkupSafe-2.1.4-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:54635102ba3cf5da26eb6f96c4b8c53af8a9c0d97b64bdcb592596a6255d8518"}, - {file = "MarkupSafe-2.1.4-cp37-cp37m-win32.whl", hash = "sha256:3583a3a3ab7958e354dc1d25be74aee6228938312ee875a22330c4dc2e41beb0"}, - {file = "MarkupSafe-2.1.4-cp37-cp37m-win_amd64.whl", hash = "sha256:d6e427c7378c7f1b2bef6a344c925b8b63623d3321c09a237b7cc0e77dd98ceb"}, - {file = "MarkupSafe-2.1.4-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:bf1196dcc239e608605b716e7b166eb5faf4bc192f8a44b81e85251e62584bd2"}, - {file = "MarkupSafe-2.1.4-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:4df98d4a9cd6a88d6a585852f56f2155c9cdb6aec78361a19f938810aa020954"}, - {file = "MarkupSafe-2.1.4-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b835aba863195269ea358cecc21b400276747cc977492319fd7682b8cd2c253d"}, - {file = "MarkupSafe-2.1.4-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:23984d1bdae01bee794267424af55eef4dfc038dc5d1272860669b2aa025c9e3"}, - {file = "MarkupSafe-2.1.4-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1c98c33ffe20e9a489145d97070a435ea0679fddaabcafe19982fe9c971987d5"}, - {file = "MarkupSafe-2.1.4-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:9896fca4a8eb246defc8b2a7ac77ef7553b638e04fbf170bff78a40fa8a91474"}, - {file = "MarkupSafe-2.1.4-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:b0fe73bac2fed83839dbdbe6da84ae2a31c11cfc1c777a40dbd8ac8a6ed1560f"}, - {file = "MarkupSafe-2.1.4-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:c7556bafeaa0a50e2fe7dc86e0382dea349ebcad8f010d5a7dc6ba568eaaa789"}, - {file = "MarkupSafe-2.1.4-cp38-cp38-win32.whl", hash = "sha256:fc1a75aa8f11b87910ffd98de62b29d6520b6d6e8a3de69a70ca34dea85d2a8a"}, - {file = "MarkupSafe-2.1.4-cp38-cp38-win_amd64.whl", hash = "sha256:3a66c36a3864df95e4f62f9167c734b3b1192cb0851b43d7cc08040c074c6279"}, - {file = "MarkupSafe-2.1.4-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:765f036a3d00395a326df2835d8f86b637dbaf9832f90f5d196c3b8a7a5080cb"}, - {file = "MarkupSafe-2.1.4-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:21e7af8091007bf4bebf4521184f4880a6acab8df0df52ef9e513d8e5db23411"}, - {file = "MarkupSafe-2.1.4-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d5c31fe855c77cad679b302aabc42d724ed87c043b1432d457f4976add1c2c3e"}, - {file = "MarkupSafe-2.1.4-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7653fa39578957bc42e5ebc15cf4361d9e0ee4b702d7d5ec96cdac860953c5b4"}, - {file = "MarkupSafe-2.1.4-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:47bb5f0142b8b64ed1399b6b60f700a580335c8e1c57f2f15587bd072012decc"}, - {file = "MarkupSafe-2.1.4-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:fe8512ed897d5daf089e5bd010c3dc03bb1bdae00b35588c49b98268d4a01e00"}, - {file = "MarkupSafe-2.1.4-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:36d7626a8cca4d34216875aee5a1d3d654bb3dac201c1c003d182283e3205949"}, - {file = "MarkupSafe-2.1.4-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:b6f14a9cd50c3cb100eb94b3273131c80d102e19bb20253ac7bd7336118a673a"}, - {file = "MarkupSafe-2.1.4-cp39-cp39-win32.whl", hash = "sha256:c8f253a84dbd2c63c19590fa86a032ef3d8cc18923b8049d91bcdeeb2581fbf6"}, - {file = "MarkupSafe-2.1.4-cp39-cp39-win_amd64.whl", hash = "sha256:8b570a1537367b52396e53325769608f2a687ec9a4363647af1cded8928af959"}, - {file = "MarkupSafe-2.1.4.tar.gz", hash = "sha256:3aae9af4cac263007fd6309c64c6ab4506dd2b79382d9d19a1994f9240b8db4f"}, + {file = "MarkupSafe-2.1.5-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:a17a92de5231666cfbe003f0e4b9b3a7ae3afb1ec2845aadc2bacc93ff85febc"}, + {file = "MarkupSafe-2.1.5-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:72b6be590cc35924b02c78ef34b467da4ba07e4e0f0454a2c5907f473fc50ce5"}, + {file = "MarkupSafe-2.1.5-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e61659ba32cf2cf1481e575d0462554625196a1f2fc06a1c777d3f48e8865d46"}, + {file = "MarkupSafe-2.1.5-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2174c595a0d73a3080ca3257b40096db99799265e1c27cc5a610743acd86d62f"}, + {file = "MarkupSafe-2.1.5-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ae2ad8ae6ebee9d2d94b17fb62763125f3f374c25618198f40cbb8b525411900"}, + {file = "MarkupSafe-2.1.5-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:075202fa5b72c86ad32dc7d0b56024ebdbcf2048c0ba09f1cde31bfdd57bcfff"}, + {file = "MarkupSafe-2.1.5-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:598e3276b64aff0e7b3451b72e94fa3c238d452e7ddcd893c3ab324717456bad"}, + {file = "MarkupSafe-2.1.5-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:fce659a462a1be54d2ffcacea5e3ba2d74daa74f30f5f143fe0c58636e355fdd"}, + {file = "MarkupSafe-2.1.5-cp310-cp310-win32.whl", hash = "sha256:d9fad5155d72433c921b782e58892377c44bd6252b5af2f67f16b194987338a4"}, + {file = "MarkupSafe-2.1.5-cp310-cp310-win_amd64.whl", hash = "sha256:bf50cd79a75d181c9181df03572cdce0fbb75cc353bc350712073108cba98de5"}, + {file = "MarkupSafe-2.1.5-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:629ddd2ca402ae6dbedfceeba9c46d5f7b2a61d9749597d4307f943ef198fc1f"}, + {file = "MarkupSafe-2.1.5-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:5b7b716f97b52c5a14bffdf688f971b2d5ef4029127f1ad7a513973cfd818df2"}, + {file = "MarkupSafe-2.1.5-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6ec585f69cec0aa07d945b20805be741395e28ac1627333b1c5b0105962ffced"}, + {file = "MarkupSafe-2.1.5-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b91c037585eba9095565a3556f611e3cbfaa42ca1e865f7b8015fe5c7336d5a5"}, + {file = "MarkupSafe-2.1.5-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7502934a33b54030eaf1194c21c692a534196063db72176b0c4028e140f8f32c"}, + {file = "MarkupSafe-2.1.5-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:0e397ac966fdf721b2c528cf028494e86172b4feba51d65f81ffd65c63798f3f"}, + {file = "MarkupSafe-2.1.5-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:c061bb86a71b42465156a3ee7bd58c8c2ceacdbeb95d05a99893e08b8467359a"}, + {file = "MarkupSafe-2.1.5-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:3a57fdd7ce31c7ff06cdfbf31dafa96cc533c21e443d57f5b1ecc6cdc668ec7f"}, + {file = "MarkupSafe-2.1.5-cp311-cp311-win32.whl", hash = "sha256:397081c1a0bfb5124355710fe79478cdbeb39626492b15d399526ae53422b906"}, + {file = "MarkupSafe-2.1.5-cp311-cp311-win_amd64.whl", hash = "sha256:2b7c57a4dfc4f16f7142221afe5ba4e093e09e728ca65c51f5620c9aaeb9a617"}, + {file = "MarkupSafe-2.1.5-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:8dec4936e9c3100156f8a2dc89c4b88d5c435175ff03413b443469c7c8c5f4d1"}, + {file = "MarkupSafe-2.1.5-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:3c6b973f22eb18a789b1460b4b91bf04ae3f0c4234a0a6aa6b0a92f6f7b951d4"}, + {file = "MarkupSafe-2.1.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ac07bad82163452a6884fe8fa0963fb98c2346ba78d779ec06bd7a6262132aee"}, + {file = "MarkupSafe-2.1.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f5dfb42c4604dddc8e4305050aa6deb084540643ed5804d7455b5df8fe16f5e5"}, + {file = "MarkupSafe-2.1.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ea3d8a3d18833cf4304cd2fc9cbb1efe188ca9b5efef2bdac7adc20594a0e46b"}, + {file = "MarkupSafe-2.1.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:d050b3361367a06d752db6ead6e7edeb0009be66bc3bae0ee9d97fb326badc2a"}, + {file = "MarkupSafe-2.1.5-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:bec0a414d016ac1a18862a519e54b2fd0fc8bbfd6890376898a6c0891dd82e9f"}, + {file = "MarkupSafe-2.1.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:58c98fee265677f63a4385256a6d7683ab1832f3ddd1e66fe948d5880c21a169"}, + {file = "MarkupSafe-2.1.5-cp312-cp312-win32.whl", hash = "sha256:8590b4ae07a35970728874632fed7bd57b26b0102df2d2b233b6d9d82f6c62ad"}, + {file = "MarkupSafe-2.1.5-cp312-cp312-win_amd64.whl", hash = "sha256:823b65d8706e32ad2df51ed89496147a42a2a6e01c13cfb6ffb8b1e92bc910bb"}, + {file = "MarkupSafe-2.1.5-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:c8b29db45f8fe46ad280a7294f5c3ec36dbac9491f2d1c17345be8e69cc5928f"}, + {file = "MarkupSafe-2.1.5-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ec6a563cff360b50eed26f13adc43e61bc0c04d94b8be985e6fb24b81f6dcfdf"}, + {file = "MarkupSafe-2.1.5-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a549b9c31bec33820e885335b451286e2969a2d9e24879f83fe904a5ce59d70a"}, + {file = "MarkupSafe-2.1.5-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4f11aa001c540f62c6166c7726f71f7573b52c68c31f014c25cc7901deea0b52"}, + {file = "MarkupSafe-2.1.5-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:7b2e5a267c855eea6b4283940daa6e88a285f5f2a67f2220203786dfa59b37e9"}, + {file = "MarkupSafe-2.1.5-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:2d2d793e36e230fd32babe143b04cec8a8b3eb8a3122d2aceb4a371e6b09b8df"}, + {file = "MarkupSafe-2.1.5-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:ce409136744f6521e39fd8e2a24c53fa18ad67aa5bc7c2cf83645cce5b5c4e50"}, + {file = "MarkupSafe-2.1.5-cp37-cp37m-win32.whl", hash = "sha256:4096e9de5c6fdf43fb4f04c26fb114f61ef0bf2e5604b6ee3019d51b69e8c371"}, + {file = "MarkupSafe-2.1.5-cp37-cp37m-win_amd64.whl", hash = "sha256:4275d846e41ecefa46e2015117a9f491e57a71ddd59bbead77e904dc02b1bed2"}, + {file = "MarkupSafe-2.1.5-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:656f7526c69fac7f600bd1f400991cc282b417d17539a1b228617081106feb4a"}, + {file = "MarkupSafe-2.1.5-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:97cafb1f3cbcd3fd2b6fbfb99ae11cdb14deea0736fc2b0952ee177f2b813a46"}, + {file = "MarkupSafe-2.1.5-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1f3fbcb7ef1f16e48246f704ab79d79da8a46891e2da03f8783a5b6fa41a9532"}, + {file = "MarkupSafe-2.1.5-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fa9db3f79de01457b03d4f01b34cf91bc0048eb2c3846ff26f66687c2f6d16ab"}, + {file = "MarkupSafe-2.1.5-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ffee1f21e5ef0d712f9033568f8344d5da8cc2869dbd08d87c84656e6a2d2f68"}, + {file = "MarkupSafe-2.1.5-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:5dedb4db619ba5a2787a94d877bc8ffc0566f92a01c0ef214865e54ecc9ee5e0"}, + {file = "MarkupSafe-2.1.5-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:30b600cf0a7ac9234b2638fbc0fb6158ba5bdcdf46aeb631ead21248b9affbc4"}, + {file = "MarkupSafe-2.1.5-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:8dd717634f5a044f860435c1d8c16a270ddf0ef8588d4887037c5028b859b0c3"}, + {file = "MarkupSafe-2.1.5-cp38-cp38-win32.whl", hash = "sha256:daa4ee5a243f0f20d528d939d06670a298dd39b1ad5f8a72a4275124a7819eff"}, + {file = "MarkupSafe-2.1.5-cp38-cp38-win_amd64.whl", hash = "sha256:619bc166c4f2de5caa5a633b8b7326fbe98e0ccbfacabd87268a2b15ff73a029"}, + {file = "MarkupSafe-2.1.5-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:7a68b554d356a91cce1236aa7682dc01df0edba8d043fd1ce607c49dd3c1edcf"}, + {file = "MarkupSafe-2.1.5-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:db0b55e0f3cc0be60c1f19efdde9a637c32740486004f20d1cff53c3c0ece4d2"}, + {file = "MarkupSafe-2.1.5-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3e53af139f8579a6d5f7b76549125f0d94d7e630761a2111bc431fd820e163b8"}, + {file = "MarkupSafe-2.1.5-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:17b950fccb810b3293638215058e432159d2b71005c74371d784862b7e4683f3"}, + {file = "MarkupSafe-2.1.5-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4c31f53cdae6ecfa91a77820e8b151dba54ab528ba65dfd235c80b086d68a465"}, + {file = "MarkupSafe-2.1.5-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:bff1b4290a66b490a2f4719358c0cdcd9bafb6b8f061e45c7a2460866bf50c2e"}, + {file = "MarkupSafe-2.1.5-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:bc1667f8b83f48511b94671e0e441401371dfd0f0a795c7daa4a3cd1dde55bea"}, + {file = "MarkupSafe-2.1.5-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:5049256f536511ee3f7e1b3f87d1d1209d327e818e6ae1365e8653d7e3abb6a6"}, + {file = "MarkupSafe-2.1.5-cp39-cp39-win32.whl", hash = "sha256:00e046b6dd71aa03a41079792f8473dc494d564611a8f89bbbd7cb93295ebdcf"}, + {file = "MarkupSafe-2.1.5-cp39-cp39-win_amd64.whl", hash = "sha256:fa173ec60341d6bb97a89f5ea19c85c5643c1e7dedebc22f5181eb73573142c5"}, + {file = "MarkupSafe-2.1.5.tar.gz", hash = "sha256:d283d37a890ba4c1ae73ffadf8046435c76e7bc2247bbb63c00bd1a709c6544b"}, ] [[package]] @@ -996,13 +996,13 @@ mkdocs = "*" [[package]] name = "mkdocs-material" -version = "9.5.6" +version = "9.5.8" description = "Documentation that simply works" optional = false python-versions = ">=3.8" files = [ - {file = "mkdocs_material-9.5.6-py3-none-any.whl", hash = "sha256:e115b90fccf5cd7f5d15b0c2f8e6246b21041628b8f590630e7fca66ed7fcf6c"}, - {file = "mkdocs_material-9.5.6.tar.gz", hash = "sha256:5b24df36d8ac6cecd611241ce6f6423ccde3e1ad89f8360c3f76d5565fc2d82a"}, + {file = "mkdocs_material-9.5.8-py3-none-any.whl", hash = "sha256:14563314bbf97da4bfafc69053772341babfaeb3329cde01d3e63cec03997af8"}, + {file = "mkdocs_material-9.5.8.tar.gz", hash = "sha256:2a429213e83f84eda7a588e2b186316d806aac602b7f93990042f7a1f3d3cf65"}, ] [package.dependencies] @@ -1019,7 +1019,7 @@ regex = ">=2022.4" requests = ">=2.26,<3.0" [package.extras] -git = ["mkdocs-git-committers-plugin-2 (>=1.1,<2.0)", "mkdocs-git-revision-date-localized-plugin (>=1.2,<2.0)"] +git = ["mkdocs-git-committers-plugin-2 (>=1.1,<2.0)", "mkdocs-git-revision-date-localized-plugin (>=1.2.4,<2.0)"] imaging = ["cairosvg (>=2.6,<3.0)", "pillow (>=10.2,<11.0)"] recommended = ["mkdocs-minify-plugin (>=0.7,<1.0)", "mkdocs-redirects (>=1.2,<2.0)", "mkdocs-rss-plugin (>=1.6,<2.0)"] @@ -1163,85 +1163,101 @@ files = [ [[package]] name = "multidict" -version = "6.0.4" +version = "6.0.5" description = "multidict implementation" optional = false python-versions = ">=3.7" files = [ - {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"}, + {file = "multidict-6.0.5-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:228b644ae063c10e7f324ab1ab6b548bdf6f8b47f3ec234fef1093bc2735e5f9"}, + {file = "multidict-6.0.5-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:896ebdcf62683551312c30e20614305f53125750803b614e9e6ce74a96232604"}, + {file = "multidict-6.0.5-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:411bf8515f3be9813d06004cac41ccf7d1cd46dfe233705933dd163b60e37600"}, + {file = "multidict-6.0.5-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1d147090048129ce3c453f0292e7697d333db95e52616b3793922945804a433c"}, + {file = "multidict-6.0.5-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:215ed703caf15f578dca76ee6f6b21b7603791ae090fbf1ef9d865571039ade5"}, + {file = "multidict-6.0.5-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7c6390cf87ff6234643428991b7359b5f59cc15155695deb4eda5c777d2b880f"}, + {file = "multidict-6.0.5-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:21fd81c4ebdb4f214161be351eb5bcf385426bf023041da2fd9e60681f3cebae"}, + {file = "multidict-6.0.5-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3cc2ad10255f903656017363cd59436f2111443a76f996584d1077e43ee51182"}, + {file = "multidict-6.0.5-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:6939c95381e003f54cd4c5516740faba40cf5ad3eeff460c3ad1d3e0ea2549bf"}, + {file = "multidict-6.0.5-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:220dd781e3f7af2c2c1053da9fa96d9cf3072ca58f057f4c5adaaa1cab8fc442"}, + {file = "multidict-6.0.5-cp310-cp310-musllinux_1_1_ppc64le.whl", hash = "sha256:766c8f7511df26d9f11cd3a8be623e59cca73d44643abab3f8c8c07620524e4a"}, + {file = "multidict-6.0.5-cp310-cp310-musllinux_1_1_s390x.whl", hash = "sha256:fe5d7785250541f7f5019ab9cba2c71169dc7d74d0f45253f8313f436458a4ef"}, + {file = "multidict-6.0.5-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:c1c1496e73051918fcd4f58ff2e0f2f3066d1c76a0c6aeffd9b45d53243702cc"}, + {file = "multidict-6.0.5-cp310-cp310-win32.whl", hash = "sha256:7afcdd1fc07befad18ec4523a782cde4e93e0a2bf71239894b8d61ee578c1319"}, + {file = "multidict-6.0.5-cp310-cp310-win_amd64.whl", hash = "sha256:99f60d34c048c5c2fabc766108c103612344c46e35d4ed9ae0673d33c8fb26e8"}, + {file = "multidict-6.0.5-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:f285e862d2f153a70586579c15c44656f888806ed0e5b56b64489afe4a2dbfba"}, + {file = "multidict-6.0.5-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:53689bb4e102200a4fafa9de9c7c3c212ab40a7ab2c8e474491914d2305f187e"}, + {file = "multidict-6.0.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:612d1156111ae11d14afaf3a0669ebf6c170dbb735e510a7438ffe2369a847fd"}, + {file = "multidict-6.0.5-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7be7047bd08accdb7487737631d25735c9a04327911de89ff1b26b81745bd4e3"}, + {file = "multidict-6.0.5-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:de170c7b4fe6859beb8926e84f7d7d6c693dfe8e27372ce3b76f01c46e489fcf"}, + {file = "multidict-6.0.5-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:04bde7a7b3de05732a4eb39c94574db1ec99abb56162d6c520ad26f83267de29"}, + {file = "multidict-6.0.5-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:85f67aed7bb647f93e7520633d8f51d3cbc6ab96957c71272b286b2f30dc70ed"}, + {file = "multidict-6.0.5-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:425bf820055005bfc8aa9a0b99ccb52cc2f4070153e34b701acc98d201693733"}, + {file = "multidict-6.0.5-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:d3eb1ceec286eba8220c26f3b0096cf189aea7057b6e7b7a2e60ed36b373b77f"}, + {file = "multidict-6.0.5-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:7901c05ead4b3fb75113fb1dd33eb1253c6d3ee37ce93305acd9d38e0b5f21a4"}, + {file = "multidict-6.0.5-cp311-cp311-musllinux_1_1_ppc64le.whl", hash = "sha256:e0e79d91e71b9867c73323a3444724d496c037e578a0e1755ae159ba14f4f3d1"}, + {file = "multidict-6.0.5-cp311-cp311-musllinux_1_1_s390x.whl", hash = "sha256:29bfeb0dff5cb5fdab2023a7a9947b3b4af63e9c47cae2a10ad58394b517fddc"}, + {file = "multidict-6.0.5-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:e030047e85cbcedbfc073f71836d62dd5dadfbe7531cae27789ff66bc551bd5e"}, + {file = "multidict-6.0.5-cp311-cp311-win32.whl", hash = "sha256:2f4848aa3baa109e6ab81fe2006c77ed4d3cd1e0ac2c1fbddb7b1277c168788c"}, + {file = "multidict-6.0.5-cp311-cp311-win_amd64.whl", hash = "sha256:2faa5ae9376faba05f630d7e5e6be05be22913782b927b19d12b8145968a85ea"}, + {file = "multidict-6.0.5-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:51d035609b86722963404f711db441cf7134f1889107fb171a970c9701f92e1e"}, + {file = "multidict-6.0.5-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:cbebcd5bcaf1eaf302617c114aa67569dd3f090dd0ce8ba9e35e9985b41ac35b"}, + {file = "multidict-6.0.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2ffc42c922dbfddb4a4c3b438eb056828719f07608af27d163191cb3e3aa6cc5"}, + {file = "multidict-6.0.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ceb3b7e6a0135e092de86110c5a74e46bda4bd4fbfeeb3a3bcec79c0f861e450"}, + {file = "multidict-6.0.5-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:79660376075cfd4b2c80f295528aa6beb2058fd289f4c9252f986751a4cd0496"}, + {file = "multidict-6.0.5-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e4428b29611e989719874670fd152b6625500ad6c686d464e99f5aaeeaca175a"}, + {file = "multidict-6.0.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d84a5c3a5f7ce6db1f999fb9438f686bc2e09d38143f2d93d8406ed2dd6b9226"}, + {file = "multidict-6.0.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:76c0de87358b192de7ea9649beb392f107dcad9ad27276324c24c91774ca5271"}, + {file = "multidict-6.0.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:79a6d2ba910adb2cbafc95dad936f8b9386e77c84c35bc0add315b856d7c3abb"}, + {file = "multidict-6.0.5-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:92d16a3e275e38293623ebf639c471d3e03bb20b8ebb845237e0d3664914caef"}, + {file = "multidict-6.0.5-cp312-cp312-musllinux_1_1_ppc64le.whl", hash = "sha256:fb616be3538599e797a2017cccca78e354c767165e8858ab5116813146041a24"}, + {file = "multidict-6.0.5-cp312-cp312-musllinux_1_1_s390x.whl", hash = "sha256:14c2976aa9038c2629efa2c148022ed5eb4cb939e15ec7aace7ca932f48f9ba6"}, + {file = "multidict-6.0.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:435a0984199d81ca178b9ae2c26ec3d49692d20ee29bc4c11a2a8d4514c67eda"}, + {file = "multidict-6.0.5-cp312-cp312-win32.whl", hash = "sha256:9fe7b0653ba3d9d65cbe7698cca585bf0f8c83dbbcc710db9c90f478e175f2d5"}, + {file = "multidict-6.0.5-cp312-cp312-win_amd64.whl", hash = "sha256:01265f5e40f5a17f8241d52656ed27192be03bfa8764d88e8220141d1e4b3556"}, + {file = "multidict-6.0.5-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:19fe01cea168585ba0f678cad6f58133db2aa14eccaf22f88e4a6dccadfad8b3"}, + {file = "multidict-6.0.5-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6bf7a982604375a8d49b6cc1b781c1747f243d91b81035a9b43a2126c04766f5"}, + {file = "multidict-6.0.5-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:107c0cdefe028703fb5dafe640a409cb146d44a6ae201e55b35a4af8e95457dd"}, + {file = "multidict-6.0.5-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:403c0911cd5d5791605808b942c88a8155c2592e05332d2bf78f18697a5fa15e"}, + {file = "multidict-6.0.5-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:aeaf541ddbad8311a87dd695ed9642401131ea39ad7bc8cf3ef3967fd093b626"}, + {file = "multidict-6.0.5-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e4972624066095e52b569e02b5ca97dbd7a7ddd4294bf4e7247d52635630dd83"}, + {file = "multidict-6.0.5-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:d946b0a9eb8aaa590df1fe082cee553ceab173e6cb5b03239716338629c50c7a"}, + {file = "multidict-6.0.5-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:b55358304d7a73d7bdf5de62494aaf70bd33015831ffd98bc498b433dfe5b10c"}, + {file = "multidict-6.0.5-cp37-cp37m-musllinux_1_1_ppc64le.whl", hash = "sha256:a3145cb08d8625b2d3fee1b2d596a8766352979c9bffe5d7833e0503d0f0b5e5"}, + {file = "multidict-6.0.5-cp37-cp37m-musllinux_1_1_s390x.whl", hash = "sha256:d65f25da8e248202bd47445cec78e0025c0fe7582b23ec69c3b27a640dd7a8e3"}, + {file = "multidict-6.0.5-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:c9bf56195c6bbd293340ea82eafd0071cb3d450c703d2c93afb89f93b8386ccc"}, + {file = "multidict-6.0.5-cp37-cp37m-win32.whl", hash = "sha256:69db76c09796b313331bb7048229e3bee7928eb62bab5e071e9f7fcc4879caee"}, + {file = "multidict-6.0.5-cp37-cp37m-win_amd64.whl", hash = "sha256:fce28b3c8a81b6b36dfac9feb1de115bab619b3c13905b419ec71d03a3fc1423"}, + {file = "multidict-6.0.5-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:76f067f5121dcecf0d63a67f29080b26c43c71a98b10c701b0677e4a065fbd54"}, + {file = "multidict-6.0.5-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:b82cc8ace10ab5bd93235dfaab2021c70637005e1ac787031f4d1da63d493c1d"}, + {file = "multidict-6.0.5-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:5cb241881eefd96b46f89b1a056187ea8e9ba14ab88ba632e68d7a2ecb7aadf7"}, + {file = "multidict-6.0.5-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e8e94e6912639a02ce173341ff62cc1201232ab86b8a8fcc05572741a5dc7d93"}, + {file = "multidict-6.0.5-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:09a892e4a9fb47331da06948690ae38eaa2426de97b4ccbfafbdcbe5c8f37ff8"}, + {file = "multidict-6.0.5-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:55205d03e8a598cfc688c71ca8ea5f66447164efff8869517f175ea632c7cb7b"}, + {file = "multidict-6.0.5-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:37b15024f864916b4951adb95d3a80c9431299080341ab9544ed148091b53f50"}, + {file = "multidict-6.0.5-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f2a1dee728b52b33eebff5072817176c172050d44d67befd681609b4746e1c2e"}, + {file = "multidict-6.0.5-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:edd08e6f2f1a390bf137080507e44ccc086353c8e98c657e666c017718561b89"}, + {file = "multidict-6.0.5-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:60d698e8179a42ec85172d12f50b1668254628425a6bd611aba022257cac1386"}, + {file = "multidict-6.0.5-cp38-cp38-musllinux_1_1_ppc64le.whl", hash = "sha256:3d25f19500588cbc47dc19081d78131c32637c25804df8414463ec908631e453"}, + {file = "multidict-6.0.5-cp38-cp38-musllinux_1_1_s390x.whl", hash = "sha256:4cc0ef8b962ac7a5e62b9e826bd0cd5040e7d401bc45a6835910ed699037a461"}, + {file = "multidict-6.0.5-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:eca2e9d0cc5a889850e9bbd68e98314ada174ff6ccd1129500103df7a94a7a44"}, + {file = "multidict-6.0.5-cp38-cp38-win32.whl", hash = "sha256:4a6a4f196f08c58c59e0b8ef8ec441d12aee4125a7d4f4fef000ccb22f8d7241"}, + {file = "multidict-6.0.5-cp38-cp38-win_amd64.whl", hash = "sha256:0275e35209c27a3f7951e1ce7aaf93ce0d163b28948444bec61dd7badc6d3f8c"}, + {file = "multidict-6.0.5-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:e7be68734bd8c9a513f2b0cfd508802d6609da068f40dc57d4e3494cefc92929"}, + {file = "multidict-6.0.5-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:1d9ea7a7e779d7a3561aade7d596649fbecfa5c08a7674b11b423783217933f9"}, + {file = "multidict-6.0.5-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:ea1456df2a27c73ce51120fa2f519f1bea2f4a03a917f4a43c8707cf4cbbae1a"}, + {file = "multidict-6.0.5-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cf590b134eb70629e350691ecca88eac3e3b8b3c86992042fb82e3cb1830d5e1"}, + {file = "multidict-6.0.5-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5c0631926c4f58e9a5ccce555ad7747d9a9f8b10619621f22f9635f069f6233e"}, + {file = "multidict-6.0.5-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:dce1c6912ab9ff5f179eaf6efe7365c1f425ed690b03341911bf4939ef2f3046"}, + {file = "multidict-6.0.5-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c0868d64af83169e4d4152ec612637a543f7a336e4a307b119e98042e852ad9c"}, + {file = "multidict-6.0.5-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:141b43360bfd3bdd75f15ed811850763555a251e38b2405967f8e25fb43f7d40"}, + {file = "multidict-6.0.5-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:7df704ca8cf4a073334e0427ae2345323613e4df18cc224f647f251e5e75a527"}, + {file = "multidict-6.0.5-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:6214c5a5571802c33f80e6c84713b2c79e024995b9c5897f794b43e714daeec9"}, + {file = "multidict-6.0.5-cp39-cp39-musllinux_1_1_ppc64le.whl", hash = "sha256:cd6c8fca38178e12c00418de737aef1261576bd1b6e8c6134d3e729a4e858b38"}, + {file = "multidict-6.0.5-cp39-cp39-musllinux_1_1_s390x.whl", hash = "sha256:e02021f87a5b6932fa6ce916ca004c4d441509d33bbdbeca70d05dff5e9d2479"}, + {file = "multidict-6.0.5-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:ebd8d160f91a764652d3e51ce0d2956b38efe37c9231cd82cfc0bed2e40b581c"}, + {file = "multidict-6.0.5-cp39-cp39-win32.whl", hash = "sha256:04da1bb8c8dbadf2a18a452639771951c662c5ad03aefe4884775454be322c9b"}, + {file = "multidict-6.0.5-cp39-cp39-win_amd64.whl", hash = "sha256:d6f6d4f185481c9669b9447bf9d9cf3b95a0e9df9d169bbc17e363b7d5487755"}, + {file = "multidict-6.0.5-py3-none-any.whl", hash = "sha256:0d63c74e3d7ab26de115c49bffc92cc77ed23395303d496eae515d4204a625e7"}, + {file = "multidict-6.0.5.tar.gz", hash = "sha256:f7e301075edaf50500f0b341543c41194d8df3ae5caf4702f2095f3ca73dd8da"}, ] [[package]] @@ -1350,18 +1366,18 @@ files = [ [[package]] name = "platformdirs" -version = "4.1.0" +version = "4.2.0" description = "A small Python package for determining appropriate platform-specific dirs, e.g. a \"user data dir\"." optional = false python-versions = ">=3.8" files = [ - {file = "platformdirs-4.1.0-py3-none-any.whl", hash = "sha256:11c8f37bcca40db96d8144522d925583bdb7a31f7b0e37e3ed4318400a8e2380"}, - {file = "platformdirs-4.1.0.tar.gz", hash = "sha256:906d548203468492d432bcb294d4bc2fff751bf84971fbb2c10918cc206ee420"}, + {file = "platformdirs-4.2.0-py3-none-any.whl", hash = "sha256:0614df2a2f37e1a662acbd8e2b25b92ccf8632929bc6d43467e17fe89c75e068"}, + {file = "platformdirs-4.2.0.tar.gz", hash = "sha256:ef0cc731df711022c174543cb70a9b5bd22e5a9337c8624ef2c2ceb8ddad8768"}, ] [package.extras] -docs = ["furo (>=2023.7.26)", "proselint (>=0.13)", "sphinx (>=7.1.1)", "sphinx-autodoc-typehints (>=1.24)"] -test = ["appdirs (==1.4.4)", "covdefaults (>=2.3)", "pytest (>=7.4)", "pytest-cov (>=4.1)", "pytest-mock (>=3.11.1)"] +docs = ["furo (>=2023.9.10)", "proselint (>=0.13)", "sphinx (>=7.2.6)", "sphinx-autodoc-typehints (>=1.25.2)"] +test = ["appdirs (==1.4.4)", "covdefaults (>=2.3)", "pytest (>=7.4.3)", "pytest-cov (>=4.1)", "pytest-mock (>=3.12)"] [[package]] name = "pluggy" @@ -1543,6 +1559,7 @@ files = [ {file = "PyYAML-6.0.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:69b023b2b4daa7548bcfbd4aa3da05b3a74b772db9e23b982788168117739938"}, {file = "PyYAML-6.0.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:81e0b275a9ecc9c0c0c07b4b90ba548307583c125f54d5b6946cfee6360c733d"}, {file = "PyYAML-6.0.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ba336e390cd8e4d1739f42dfe9bb83a3cc2e80f567d8805e11b46f4a943f5515"}, + {file = "PyYAML-6.0.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:326c013efe8048858a6d312ddd31d56e468118ad4cdeda36c719bf5bb6192290"}, {file = "PyYAML-6.0.1-cp310-cp310-win32.whl", hash = "sha256:bd4af7373a854424dabd882decdc5579653d7868b8fb26dc7d0e99f823aa5924"}, {file = "PyYAML-6.0.1-cp310-cp310-win_amd64.whl", hash = "sha256:fd1592b3fdf65fff2ad0004b5e363300ef59ced41c2e6b3a99d4089fa8c5435d"}, {file = "PyYAML-6.0.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:6965a7bc3cf88e5a1c3bd2e0b5c22f8d677dc88a455344035f03399034eb3007"}, @@ -1550,8 +1567,15 @@ files = [ {file = "PyYAML-6.0.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:42f8152b8dbc4fe7d96729ec2b99c7097d656dc1213a3229ca5383f973a5ed6d"}, {file = "PyYAML-6.0.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:062582fca9fabdd2c8b54a3ef1c978d786e0f6b3a1510e0ac93ef59e0ddae2bc"}, {file = "PyYAML-6.0.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d2b04aac4d386b172d5b9692e2d2da8de7bfb6c387fa4f801fbf6fb2e6ba4673"}, + {file = "PyYAML-6.0.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:e7d73685e87afe9f3b36c799222440d6cf362062f78be1013661b00c5c6f678b"}, {file = "PyYAML-6.0.1-cp311-cp311-win32.whl", hash = "sha256:1635fd110e8d85d55237ab316b5b011de701ea0f29d07611174a1b42f1444741"}, {file = "PyYAML-6.0.1-cp311-cp311-win_amd64.whl", hash = "sha256:bf07ee2fef7014951eeb99f56f39c9bb4af143d8aa3c21b1677805985307da34"}, + {file = "PyYAML-6.0.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:855fb52b0dc35af121542a76b9a84f8d1cd886ea97c84703eaa6d88e37a2ad28"}, + {file = "PyYAML-6.0.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:40df9b996c2b73138957fe23a16a4f0ba614f4c0efce1e9406a184b6d07fa3a9"}, + {file = "PyYAML-6.0.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6c22bec3fbe2524cde73d7ada88f6566758a8f7227bfbf93a408a9d86bcc12a0"}, + {file = "PyYAML-6.0.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:8d4e9c88387b0f5c7d5f281e55304de64cf7f9c0021a3525bd3b1c542da3b0e4"}, + {file = "PyYAML-6.0.1-cp312-cp312-win32.whl", hash = "sha256:d483d2cdf104e7c9fa60c544d92981f12ad66a457afae824d146093b8c294c54"}, + {file = "PyYAML-6.0.1-cp312-cp312-win_amd64.whl", hash = "sha256:0d3304d8c0adc42be59c5f8a4d9e3d7379e6955ad754aa9d6ab7a398b59dd1df"}, {file = "PyYAML-6.0.1-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:50550eb667afee136e9a77d6dc71ae76a44df8b3e51e41b77f6de2932bfe0f47"}, {file = "PyYAML-6.0.1-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1fe35611261b29bd1de0070f0b2f47cb6ff71fa6595c077e42bd0c419fa27b98"}, {file = "PyYAML-6.0.1-cp36-cp36m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:704219a11b772aea0d8ecd7058d0082713c3562b4e271b849ad7dc4a5c90c13c"}, @@ -1568,6 +1592,7 @@ files = [ {file = "PyYAML-6.0.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a0cd17c15d3bb3fa06978b4e8958dcdc6e0174ccea823003a106c7d4d7899ac5"}, {file = "PyYAML-6.0.1-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:28c119d996beec18c05208a8bd78cbe4007878c6dd15091efb73a30e90539696"}, {file = "PyYAML-6.0.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7e07cbde391ba96ab58e532ff4803f79c4129397514e1413a7dc761ccd755735"}, + {file = "PyYAML-6.0.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:49a183be227561de579b4a36efbb21b3eab9651dd81b1858589f796549873dd6"}, {file = "PyYAML-6.0.1-cp38-cp38-win32.whl", hash = "sha256:184c5108a2aca3c5b3d3bf9395d50893a7ab82a38004c8f61c258d4428e80206"}, {file = "PyYAML-6.0.1-cp38-cp38-win_amd64.whl", hash = "sha256:1e2722cc9fbb45d9b87631ac70924c11d3a401b2d7f410cc0e3bbf249f2dca62"}, {file = "PyYAML-6.0.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:9eb6caa9a297fc2c2fb8862bc5370d0303ddba53ba97e71f08023b6cd73d16a8"}, @@ -1575,6 +1600,7 @@ files = [ {file = "PyYAML-6.0.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5773183b6446b2c99bb77e77595dd486303b4faab2b086e7b17bc6bef28865f6"}, {file = "PyYAML-6.0.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b786eecbdf8499b9ca1d697215862083bd6d2a99965554781d0d8d1ad31e13a0"}, {file = "PyYAML-6.0.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bc1bf2925a1ecd43da378f4db9e4f799775d6367bdb94671027b73b393a7c42c"}, + {file = "PyYAML-6.0.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:04ac92ad1925b2cff1db0cfebffb6ffc43457495c9b3c39d3fcae417d7125dc5"}, {file = "PyYAML-6.0.1-cp39-cp39-win32.whl", hash = "sha256:faca3bdcf85b2fc05d06ff3fbc1f83e1391b3e724afa3feba7d13eeab355484c"}, {file = "PyYAML-6.0.1-cp39-cp39-win_amd64.whl", hash = "sha256:510c9deebc5c0225e8c96813043e62b680ba2f9c50a08d3724c7f28a747d1486"}, {file = "PyYAML-6.0.1.tar.gz", hash = "sha256:bfdf460b1736c775f2ba9f6a92bca30bc2095067b8a9d77876d1fad6cc3b4a43"}, @@ -1835,74 +1861,6 @@ msgpack = ">=1.0.3" textual = ">=0.36.0" typing-extensions = ">=4.4.0,<5.0.0" -[[package]] -name = "time-machine" -version = "2.13.0" -description = "Travel through time in your tests." -optional = false -python-versions = ">=3.8" -files = [ - {file = "time_machine-2.13.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:685d98593f13649ad5e7ce3e58efe689feca1badcf618ba397d3ab877ee59326"}, - {file = "time_machine-2.13.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:ccbce292380ebf63fb9a52e6b03d91677f6a003e0c11f77473efe3913a75f289"}, - {file = "time_machine-2.13.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:679cbf9b15bfde1654cf48124128d3fbe52f821fa158a98fcee5fe7e05db1917"}, - {file = "time_machine-2.13.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a26bdf3462d5f12a4c1009fdbe54366c6ef22c7b6f6808705b51dedaaeba8296"}, - {file = "time_machine-2.13.0-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dabb3b155819811b4602f7e9be936e2024e20dc99a90f103e36b45768badf9c3"}, - {file = "time_machine-2.13.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:0db97f92be3efe0ac62fd3f933c91a78438cef13f283b6dfc2ee11123bfd7d8a"}, - {file = "time_machine-2.13.0-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:12eed2e9171c85b703d75c985dab2ecad4fe7025b7d2f842596fce1576238ece"}, - {file = "time_machine-2.13.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:bdfe4a7f033e6783c3e9a7f8d8fc0b115367330762e00a03ff35fedf663994f3"}, - {file = "time_machine-2.13.0-cp310-cp310-win32.whl", hash = "sha256:3a7a0a49ce50d9c306c4343a7d6a3baa11092d4399a4af4355c615ccc321a9d3"}, - {file = "time_machine-2.13.0-cp310-cp310-win_amd64.whl", hash = "sha256:1812e48c6c58707db9988445a219a908a710ea065b2cc808d9a50636291f27d4"}, - {file = "time_machine-2.13.0-cp310-cp310-win_arm64.whl", hash = "sha256:5aee23cd046abf9caeddc982113e81ba9097a01f3972e9560f5ed64e3495f66d"}, - {file = "time_machine-2.13.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:e9a9d150e098be3daee5c9f10859ab1bd14a61abebaed86e6d71f7f18c05b9d7"}, - {file = "time_machine-2.13.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:2bd4169b808745d219a69094b3cb86006938d45e7293249694e6b7366225a186"}, - {file = "time_machine-2.13.0-cp311-cp311-macosx_13_0_arm64.whl", hash = "sha256:8d526cdcaca06a496877cfe61cc6608df2c3a6fce210e076761964ebac7f77cc"}, - {file = "time_machine-2.13.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cfef4ebfb4f055ce3ebc7b6c1c4d0dbfcffdca0e783ad8c6986c992915a57ed3"}, - {file = "time_machine-2.13.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9f128db8997c3339f04f7f3946dd9bb2a83d15e0a40d35529774da1e9e501511"}, - {file = "time_machine-2.13.0-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:21bef5854d49b62e2c33848b5c3e8acf22a3b46af803ef6ff19529949cb7cf9f"}, - {file = "time_machine-2.13.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:32b71e50b07f86916ac04bd1eefc2bd2c93706b81393748b08394509ee6585dc"}, - {file = "time_machine-2.13.0-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:1ac8ff145c63cd0dcfd9590fe694b5269aacbc130298dc7209b095d101f8cdde"}, - {file = "time_machine-2.13.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:19a3b10161c91ca8e0fd79348665cca711fd2eac6ce336ff9e6b447783817f93"}, - {file = "time_machine-2.13.0-cp311-cp311-win32.whl", hash = "sha256:5f87787d562e42bf1006a87eb689814105b98c4d5545874a281280d0f8b9a2d9"}, - {file = "time_machine-2.13.0-cp311-cp311-win_amd64.whl", hash = "sha256:62fd14a80b8b71726e07018628daaee0a2e00937625083f96f69ed6b8e3304c0"}, - {file = "time_machine-2.13.0-cp311-cp311-win_arm64.whl", hash = "sha256:e9935aff447f5400a2665ab10ed2da972591713080e1befe1bb8954e7c0c7806"}, - {file = "time_machine-2.13.0-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:34dcdbbd25c1e124e17fe58050452960fd16a11f9d3476aaa87260e28ecca0fd"}, - {file = "time_machine-2.13.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:e58d82fe0e59d6e096ada3281d647a2e7420f7da5453b433b43880e1c2e8e0c5"}, - {file = "time_machine-2.13.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:71acbc1febbe87532c7355eca3308c073d6e502ee4ce272b5028967847c8e063"}, - {file = "time_machine-2.13.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:dec0ec2135a4e2a59623e40c31d6e8a8ae73305ade2634380e4263d815855750"}, - {file = "time_machine-2.13.0-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4e3a2611f8788608ebbcb060a5e36b45911bc3b8adc421b1dc29d2c81786ce4d"}, - {file = "time_machine-2.13.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:42ef5349135626ad6cd889a0a81400137e5c6928502b0817ea9e90bb10702000"}, - {file = "time_machine-2.13.0-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:6c16d90a597a8c2d3ce22d6be2eb3e3f14786974c11b01886e51b3cf0d5edaf7"}, - {file = "time_machine-2.13.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:4f2ae8d0e359b216b695f1e7e7256f208c390db0480601a439c5dd1e1e4e16ce"}, - {file = "time_machine-2.13.0-cp312-cp312-win32.whl", hash = "sha256:f5fa9610f7e73fff42806a2ed8b06d862aa59ce4d178a52181771d6939c3e237"}, - {file = "time_machine-2.13.0-cp312-cp312-win_amd64.whl", hash = "sha256:02b33a8c19768c94f7ffd6aa6f9f64818e88afce23250016b28583929d20fb12"}, - {file = "time_machine-2.13.0-cp312-cp312-win_arm64.whl", hash = "sha256:0cc116056a8a2a917a4eec85661dfadd411e0d8faae604ef6a0e19fe5cd57ef1"}, - {file = "time_machine-2.13.0-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:de01f33aa53da37530ad97dcd17e9affa25a8df4ab822506bb08101bab0c2673"}, - {file = "time_machine-2.13.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:67fa45cd813821e4f5bec0ac0820869e8e37430b15509d3f5fad74ba34b53852"}, - {file = "time_machine-2.13.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d4a2d3db2c3b8e519d5ef436cd405abd33542a7b7761fb05ef5a5f782a8ce0b1"}, - {file = "time_machine-2.13.0-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7558622a62243be866a7e7c41da48eacd82c874b015ecf67d18ebf65ca3f7436"}, - {file = "time_machine-2.13.0-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ab04cf4e56e1ee65bee2adaa26a04695e92eb1ed1ccc65fbdafd0d114399595a"}, - {file = "time_machine-2.13.0-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:b0c8f24ae611a58782773af34dd356f1f26756272c04be2be7ea73b47e5da37d"}, - {file = "time_machine-2.13.0-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:4ca20f85a973a4ca8b00cf466cd72c27ccc72372549b138fd48d7e70e5a190ab"}, - {file = "time_machine-2.13.0-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:9fad549521c4c13bdb1e889b2855a86ec835780d534ffd8f091c2647863243be"}, - {file = "time_machine-2.13.0-cp38-cp38-win32.whl", hash = "sha256:20205422fcf2caf9a7488394587df86e5b54fdb315c1152094fbb63eec4e9304"}, - {file = "time_machine-2.13.0-cp38-cp38-win_amd64.whl", hash = "sha256:2dc76ee55a7d915a55960a726ceaca7b9097f67e4b4e681ef89871bcf98f00be"}, - {file = "time_machine-2.13.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:7693704c0f2f6b9beed912ff609781edf5fcf5d63aff30c92be4093e09d94b8e"}, - {file = "time_machine-2.13.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:918f8389de29b4f41317d121f1150176fae2cdb5fa41f68b2aee0b9dc88df5c3"}, - {file = "time_machine-2.13.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5fe3fda5fa73fec74278912e438fce1612a79c36fd0cc323ea3dc2d5ce629f31"}, - {file = "time_machine-2.13.0-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5c6245db573863b335d9ca64b3230f623caf0988594ae554c0c794e7f80e3e66"}, - {file = "time_machine-2.13.0-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e433827eccd6700a34a2ab28fd9361ff6e4d4923f718d2d1dac6d1dcd9d54da6"}, - {file = "time_machine-2.13.0-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:924377d398b1c48e519ad86a71903f9f36117f69e68242c99fb762a2465f5ad2"}, - {file = "time_machine-2.13.0-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:66fb3877014dca0b9286b0f06fa74062357bd23f2d9d102d10e31e0f8fa9b324"}, - {file = "time_machine-2.13.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:0c9829b2edfcf6b5d72a6ff330d4380f36a937088314c675531b43d3423dd8af"}, - {file = "time_machine-2.13.0-cp39-cp39-win32.whl", hash = "sha256:1a22be4df364f49a507af4ac9ea38108a0105f39da3f9c60dce62d6c6ea4ccdc"}, - {file = "time_machine-2.13.0-cp39-cp39-win_amd64.whl", hash = "sha256:88601de1da06c7cab3d5ed3d5c3801ef683366e769e829e96383fdab6ae2fe42"}, - {file = "time_machine-2.13.0-cp39-cp39-win_arm64.whl", hash = "sha256:3c87856105dcb25b5bbff031d99f06ef4d1c8380d096222e1bc63b496b5258e6"}, - {file = "time_machine-2.13.0.tar.gz", hash = "sha256:c23b2408e3adcedec84ea1131e238f0124a5bc0e491f60d1137ad7239b37c01a"}, -] - -[package.dependencies] -python-dateutil = "*" - [[package]] name = "toml" version = "0.10.2" @@ -2015,79 +1973,70 @@ setuptools = {version = ">=60.0.0", markers = "python_version >= \"3.12\""} [[package]] name = "tree-sitter-languages" -version = "1.9.1" +version = "1.10.2" description = "Binary Python wheels for all tree sitter languages." optional = true python-versions = "*" files = [ - {file = "tree_sitter_languages-1.9.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:5dee458cf1bd1e725470949124e24db842dc789039ea7ff5ba46b338e5f0dc60"}, - {file = "tree_sitter_languages-1.9.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:81921135fa15469586b1528088f78553e60a900d3045f4f37021ad3836219216"}, - {file = "tree_sitter_languages-1.9.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:edd60780d14c727179acb7bb48fbe4f79da9b830abdeb0d12c06a9f2c37928c7"}, - {file = "tree_sitter_languages-1.9.1-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a28da3f60a6bc23195d6850836e477c149d4aaf58cdb0eb662741dca4f6401e2"}, - {file = "tree_sitter_languages-1.9.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a9778c00a58ee77006abc5af905b591551b158ce106c8cc6c3b4148d624ccabf"}, - {file = "tree_sitter_languages-1.9.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:6f68cfec0d74d6344db9c83414f401dcfc753916e71fac7d37f3a5e35b79e5ec"}, - {file = "tree_sitter_languages-1.9.1-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:02142d81b2cd759b5fe246d403e4fba80b70268d108bd2b108301e64a84437a6"}, - {file = "tree_sitter_languages-1.9.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:ca4e0041c2ead2a8b354b9c229faee152bfd4617480c85cf2b352acf459db3cc"}, - {file = "tree_sitter_languages-1.9.1-cp310-cp310-win32.whl", hash = "sha256:506ff5c3646e7b3a533f9e925221d4fe63b88dad0b7ffc1fb96db4c271994606"}, - {file = "tree_sitter_languages-1.9.1-cp310-cp310-win_amd64.whl", hash = "sha256:3ac3899e05f2bf0a7c8da70ef5a077ab3dbd442f99eb7452aabbe67bc7b29ddf"}, - {file = "tree_sitter_languages-1.9.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:823426c3768eea88b6a4fd70dc668b72de90cc9f44d041a579c76d024d7d0697"}, - {file = "tree_sitter_languages-1.9.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:51f64b11f30cef3c5c9741e06221a46948f7c82d53ea2468139028eaf4858cca"}, - {file = "tree_sitter_languages-1.9.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f7e1c384bcd2695ebf873bc63eccfa0b9e1c3c944cd6a6ebdd1139a2528d2d6f"}, - {file = "tree_sitter_languages-1.9.1-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:fecf8553645fc1ad84921e97b03615d84aca22c35d020f629bb44cb6a28a302e"}, - {file = "tree_sitter_languages-1.9.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f1a499004189bf9f338f3412d4c1c05a643e86d4619a60ba4b3ae56bc4bf5db9"}, - {file = "tree_sitter_languages-1.9.1-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:634ef22744b4af2ed9a43fea8309ec1171b062e37c609c3463364c790a08dae3"}, - {file = "tree_sitter_languages-1.9.1-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:9394eb34208abcfa9c26ece39778037a8d97da3ef59501185303fef0ab850290"}, - {file = "tree_sitter_languages-1.9.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:221c367be0129da540fbb84170e18c5b8c56c09fd2f6143e116eebbef72c780e"}, - {file = "tree_sitter_languages-1.9.1-cp311-cp311-win32.whl", hash = "sha256:15d03f54f913f47ac36277d8a521cd425415a25b020e0845d7b8843f5f5e1209"}, - {file = "tree_sitter_languages-1.9.1-cp311-cp311-win_amd64.whl", hash = "sha256:7c565c18cebc72417ebc8f0f4cd5cb91dda51874164045cc274f47c913b194aa"}, - {file = "tree_sitter_languages-1.9.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:cde380cdc37594e7fcbade6a4b396dbeab52a1cecfe884cd814e1a1541ca6b93"}, - {file = "tree_sitter_languages-1.9.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:9c4f2409e5460bdec5921ee445f748ea7c319469e347a13373e3c7086dbf0315"}, - {file = "tree_sitter_languages-1.9.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a17bbe91a78a29a9c14ab8bb07ed3761bb2708b58815bafc02d0965b15cb99e5"}, - {file = "tree_sitter_languages-1.9.1-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:369402e2b395de2655d769e515401fe7c7df247a83aa28a6362e808b8a017fae"}, - {file = "tree_sitter_languages-1.9.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f4a382d1e463e6ae60bbbd0c1f3db48e83b3c1a3af98d652af11de4c0e6171fc"}, - {file = "tree_sitter_languages-1.9.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:bc60fb35f377143b30f4319fbaac0503b12cfb49de34082a479c7f0cc28927f1"}, - {file = "tree_sitter_languages-1.9.1-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:9e953fb43767e327bf5c1d0585ee39236eaff47683cbda2811cbe0227fd41ad7"}, - {file = "tree_sitter_languages-1.9.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:c5a6df25eae23a5e2d448218b130207476cb8a613ac40570d49008243b0915bb"}, - {file = "tree_sitter_languages-1.9.1-cp312-cp312-win32.whl", hash = "sha256:2720f9a639f5d5c17692135f3f2d60506c240699d0c1becdb895546e553f2339"}, - {file = "tree_sitter_languages-1.9.1-cp312-cp312-win_amd64.whl", hash = "sha256:f19157c33ddc1e75ae7843b813e65575ed2040e1638643251bd603bb0f52046b"}, - {file = "tree_sitter_languages-1.9.1-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:40880b5e774c3d5759b726273c36f83042d39c600c3aeefaf39248c3adec92d0"}, - {file = "tree_sitter_languages-1.9.1-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ad71366ee2458bda6df5a7476fc0e465a1e1579f53335ce901935efc5c67fdeb"}, - {file = "tree_sitter_languages-1.9.1-cp36-cp36m-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a8000c6bf889e35e8b75407ea2d56153534b3f80c3b768378f4ca5a6fe286c0f"}, - {file = "tree_sitter_languages-1.9.1-cp36-cp36m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fc7e20ead363d70b3f0f0b04cf6da30257d22a166700fa39e06c9f263b527688"}, - {file = "tree_sitter_languages-1.9.1-cp36-cp36m-musllinux_1_1_aarch64.whl", hash = "sha256:444d2662912bc439c54c1b0ffe38354ae648f1f1ac8d1254b14fa768aa1a8587"}, - {file = "tree_sitter_languages-1.9.1-cp36-cp36m-musllinux_1_1_i686.whl", hash = "sha256:cceac9018359310fee46204b452860bfdcb3da00f4518d430790f909cbbf6b4c"}, - {file = "tree_sitter_languages-1.9.1-cp36-cp36m-musllinux_1_1_x86_64.whl", hash = "sha256:332c182afbd9f7601e268426470e8c453740769a6227e7d1a9636d905cd7d707"}, - {file = "tree_sitter_languages-1.9.1-cp36-cp36m-win32.whl", hash = "sha256:25e993a41ad11fc433cb18ce0cc1d51eb7a285560c5cdddf781139312dac1881"}, - {file = "tree_sitter_languages-1.9.1-cp36-cp36m-win_amd64.whl", hash = "sha256:57419c215092ba9ba1964e07620dd386fc88ebb075b981fbb80f68f58004d4b4"}, - {file = "tree_sitter_languages-1.9.1-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:06747cac4789c436affa7c6b3483f68cc234e6a75b508a0f8369c77eb1faa04b"}, - {file = "tree_sitter_languages-1.9.1-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b40bc82005543309c9cd4059f362c9d0d51277c942c71a5fdbed118389e5543a"}, - {file = "tree_sitter_languages-1.9.1-cp37-cp37m-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:44920c9654ae03e94baa45c6e8c4b36a5f7bdd0c93877c72931bd77e862adaf1"}, - {file = "tree_sitter_languages-1.9.1-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:82e44f63a5449a41c5de3e9350967dc1c9183d9375881af5efb970c58c3fcfd8"}, - {file = "tree_sitter_languages-1.9.1-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:df177fa87b655f6234e4dae540ba3917cf8e87c3646423b809415711e926765e"}, - {file = "tree_sitter_languages-1.9.1-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:abdc8793328aa13fbd1cef3a0dff1c2e057a430fe2a64251628bbc97c4774eba"}, - {file = "tree_sitter_languages-1.9.1-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:8b3f319f95f4464c35381755422f6dc0a518ad7d295d3cfe57bbaa564d225f3f"}, - {file = "tree_sitter_languages-1.9.1-cp37-cp37m-win32.whl", hash = "sha256:9f3a59bb4e8ec0a598566e02b7900eb8142236bda6c8b1069c4f3cdaf641950d"}, - {file = "tree_sitter_languages-1.9.1-cp37-cp37m-win_amd64.whl", hash = "sha256:517bdfe34bf24a05a496d441bee836fa77a6864f256508b82457ac28a9ac36bc"}, - {file = "tree_sitter_languages-1.9.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:9383331026f736bcbdf6b67f9b45417fe8fbb47225fe2517a1e4f974c319d9a8"}, - {file = "tree_sitter_languages-1.9.1-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:bba45ff3715e20e6e9a9b402f1ec2f2fc5ce11ce7b223584d0b5be5a4f8c60bb"}, - {file = "tree_sitter_languages-1.9.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:03558927c6e731d81706e3a8b26276eaa4fadba17e2fd83a5e0bc2a32b261975"}, - {file = "tree_sitter_languages-1.9.1-cp38-cp38-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6f0231140e2d29fcf987216277483c93bc7ce4c2f88b8af77756d796e17a2957"}, - {file = "tree_sitter_languages-1.9.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8ead59b416f03da262df26e282cd40eb487f15384c90290f5105451e9a8ecfea"}, - {file = "tree_sitter_languages-1.9.1-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:fd27b7bdb95a2b35b730069d7dea60d0f6cc37e5ab2e900d2940a82d1db608bd"}, - {file = "tree_sitter_languages-1.9.1-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:d8b65a5fafd774a6c6dcacd9ac8b4c258c9f1efe2bfdca0a63818c83e591b949"}, - {file = "tree_sitter_languages-1.9.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:f32f7a7b8fd9952f82e2b881c1c8701a467b27db209590e0effb2fb4d71fe3d3"}, - {file = "tree_sitter_languages-1.9.1-cp38-cp38-win32.whl", hash = "sha256:b52321e2a3a7cd1660cd7dadea16d7c7b9c981e177e0f77f9735e04cd89de015"}, - {file = "tree_sitter_languages-1.9.1-cp38-cp38-win_amd64.whl", hash = "sha256:e8752bec9372937094a2557d9bfff357f30f5aa398e41e76e656baf53b4939d3"}, - {file = "tree_sitter_languages-1.9.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:119f32cfc7c561e252e8958259ef997f2adfd4587ae43e82819b56f2810b8b42"}, - {file = "tree_sitter_languages-1.9.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:582b04e11c67706b0a5ea64fd53ce4910fe11ad29d74ec7680c4014a02d09d4a"}, - {file = "tree_sitter_languages-1.9.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a816f76c52f6c9fb3316c5d44195f8de48e09f2214b7fdb5f9232395033c789c"}, - {file = "tree_sitter_languages-1.9.1-cp39-cp39-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3a099b2f69cf22ab77de811b148de7d2d8ba8c51176a64bc56304cf42a627dd4"}, - {file = "tree_sitter_languages-1.9.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:447b6c62c59255c89341ec0968e467e8c59c60fc5c2c3dc1f7dfe159a820dd3c"}, - {file = "tree_sitter_languages-1.9.1-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:41f4fee9b7de9646ef9711b6dbcdd5a4e7079e3d175089c8ef3f2c68b5adb5f4"}, - {file = "tree_sitter_languages-1.9.1-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:ee3b70594b79ff1155d5d9fea64e3af240d9327a52526d446e6bd792ac5b43cf"}, - {file = "tree_sitter_languages-1.9.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:087b82cc3943fc5ffac30dc1b4192936a27c3c06fbd8718935a269e30dedc83b"}, - {file = "tree_sitter_languages-1.9.1-cp39-cp39-win32.whl", hash = "sha256:155483058dc11de302f47922d31feec5e1bb9888e661aed7be0dad6f70bfe691"}, - {file = "tree_sitter_languages-1.9.1-cp39-cp39-win_amd64.whl", hash = "sha256:5335405a937f788a2608d1b25c654461dddddbc6a1341672c833d2c8943397a8"}, + {file = "tree_sitter_languages-1.10.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:5580348f0b20233b1d5431fa178ccd3d07423ca4a3275df02a44608fd72344b9"}, + {file = "tree_sitter_languages-1.10.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:103c7466644486b1e9e03850df46fc6aa12f13ca636c74f173270276220ac80b"}, + {file = "tree_sitter_languages-1.10.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d13db84511c6f1a7dc40383b66deafa74dabd8b877e3d65ab253f3719eccafd6"}, + {file = "tree_sitter_languages-1.10.2-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:57adfa32be7e465b54aa72f915f6c78a2b66b227df4f656b5d4fbd1ca7a92b3f"}, + {file = "tree_sitter_languages-1.10.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1c6385e033e460ceb8f33f3f940335f422ef2b763700a04f0089391a68b56153"}, + {file = "tree_sitter_languages-1.10.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:dfa3f38cc5381c5aba01dd7494f59b8a9050e82ff6e06e1233e3a0cbae297e3c"}, + {file = "tree_sitter_languages-1.10.2-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:9f195155acf47f8bc5de7cee46ecd07b2f5697f007ba89435b51ef4c0b953ea5"}, + {file = "tree_sitter_languages-1.10.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:2de330e2ac6d7426ca025a3ec0f10d5640c3682c1d0c7702e812dcfb44b58120"}, + {file = "tree_sitter_languages-1.10.2-cp310-cp310-win32.whl", hash = "sha256:c9731cf745f135d9770eeba9bb4e2ff4dabc107b5ae9b8211e919f6b9100ea6d"}, + {file = "tree_sitter_languages-1.10.2-cp310-cp310-win_amd64.whl", hash = "sha256:6dd75851c41d0c3c4987a9b7692d90fa8848706c23115669d8224ffd6571e357"}, + {file = "tree_sitter_languages-1.10.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:7eb7d7542b2091c875fe52719209631fca36f8c10fa66970d2c576ae6a1b8289"}, + {file = "tree_sitter_languages-1.10.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:6b41bcb00974b1c8a1800c7f1bb476a1d15a0463e760ee24872f2d53b08ee424"}, + {file = "tree_sitter_languages-1.10.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6f370cd7845c6c81df05680d5bd96db8a99d32b56f4728c5d05978911130a853"}, + {file = "tree_sitter_languages-1.10.2-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a1dc195c88ef4c72607e112a809a69190e096a2e5ebc6201548b3e05fdd169ad"}, + {file = "tree_sitter_languages-1.10.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9ae34ac314a7170be24998a0f994c1ac80761d8d4bd126af27ee53a023d3b849"}, + {file = "tree_sitter_languages-1.10.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:01b5742d5f5bd675489486b582bd482215880b26dde042c067f8265a6e925d9c"}, + {file = "tree_sitter_languages-1.10.2-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:ab1cbc46244d34fd16f21edaa20231b2a57f09f092a06ee3d469f3117e6eb954"}, + {file = "tree_sitter_languages-1.10.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:0b1149e7467a4e92b8a70e6005fe762f880f493cf811fc003554b29f04f5e7c8"}, + {file = "tree_sitter_languages-1.10.2-cp311-cp311-win32.whl", hash = "sha256:049276343962f4696390ee555acc2c1a65873270c66a6cbe5cb0bca83bcdf3c6"}, + {file = "tree_sitter_languages-1.10.2-cp311-cp311-win_amd64.whl", hash = "sha256:7f3fdd468a577f04db3b63454d939e26e360229b53c80361920aa1ebf2cd7491"}, + {file = "tree_sitter_languages-1.10.2-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:c0f4c8b2734c45859edc7fcaaeaab97a074114111b5ba51ab4ec7ed52104763c"}, + {file = "tree_sitter_languages-1.10.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:eecd3c1244ac3425b7a82ba9125b4ddb45d953bbe61de114c0334fd89b7fe782"}, + {file = "tree_sitter_languages-1.10.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:15db3c8510bc39a80147ee7421bf4782c15c09581c1dc2237ea89cefbd95b846"}, + {file = "tree_sitter_languages-1.10.2-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:92c6487a6feea683154d3e06e6db68c30e0ae749a7ce4ce90b9e4e46b78c85c7"}, + {file = "tree_sitter_languages-1.10.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6d2f1cd1d1bdd65332f9c2b67d49dcf148cf1ded752851d159ac3e5ee4f4d260"}, + {file = "tree_sitter_languages-1.10.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:976c8039165b8e12f17a01ddee9f4e23ec6e352b165ad29b44d2bf04e2fbe77e"}, + {file = "tree_sitter_languages-1.10.2-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:dafbbdf16bf668a580902e1620f4baa1913e79438abcce721a50647564c687b9"}, + {file = "tree_sitter_languages-1.10.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:1aeabd3d60d6d276b73cd8f3739d595b1299d123cc079a317f1a5b3c5461e2ca"}, + {file = "tree_sitter_languages-1.10.2-cp312-cp312-win32.whl", hash = "sha256:fab8ee641914098e8933b87ea3d657bea4dd00723c1ee7038b847b12eeeef4f5"}, + {file = "tree_sitter_languages-1.10.2-cp312-cp312-win_amd64.whl", hash = "sha256:5e606430d736367e5787fa5a7a0c5a1ec9b85eded0b3596bbc0d83532a40810b"}, + {file = "tree_sitter_languages-1.10.2-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:838d5b48a7ed7a17658721952c77fda4570d2a069f933502653b17e15a9c39c9"}, + {file = "tree_sitter_languages-1.10.2-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:987b3c71b1d278c2889e018ee77b8ee05c384e2e3334dec798f8b611c4ab2d1e"}, + {file = "tree_sitter_languages-1.10.2-cp37-cp37m-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:faa00abcb2c819027df58472da055d22fa7dfcb77c77413d8500c32ebe24d38b"}, + {file = "tree_sitter_languages-1.10.2-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0e102fbbf02322d9201a86a814e79a9734ac80679fdb9682144479044f401a73"}, + {file = "tree_sitter_languages-1.10.2-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:8f0b87cf1a7b03174ba18dfd81582be82bfed26803aebfe222bd20e444aba003"}, + {file = "tree_sitter_languages-1.10.2-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:c0f1b9af9cb67f0b942b020da9fdd000aad5e92f2383ae0ba7a330b318d31912"}, + {file = "tree_sitter_languages-1.10.2-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:5a4076c921f7a4d31e643843de7dfe040b65b63a238a5aa8d31d93aabe6572aa"}, + {file = "tree_sitter_languages-1.10.2-cp37-cp37m-win32.whl", hash = "sha256:fa6391a3a5d83d32db80815161237b67d70576f090ce5f38339206e917a6f8bd"}, + {file = "tree_sitter_languages-1.10.2-cp37-cp37m-win_amd64.whl", hash = "sha256:55649d3f254585a064121513627cf9788c1cfdadbc5f097f33d5ba750685a4c0"}, + {file = "tree_sitter_languages-1.10.2-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:6f85d1edaa2d22d80d4ea5b6d12b95cf3644017b6c227d0d42854439e02e8893"}, + {file = "tree_sitter_languages-1.10.2-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:d78feed4a764ef3141cb54bf00fe94d514d8b6e26e09423e23b4c616fcb7938c"}, + {file = "tree_sitter_languages-1.10.2-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:da1aca27531f9dd5308637d76643372856f0f65d0d28677d1bcf4211e8ed1ad0"}, + {file = "tree_sitter_languages-1.10.2-cp38-cp38-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1031ea440dafb72237437d754eff8940153a3b051e3d18932ac25e75ce060a15"}, + {file = "tree_sitter_languages-1.10.2-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:99d3249beaef2c9fe558ecc9a97853c260433a849dcc68266d9770d196c2e102"}, + {file = "tree_sitter_languages-1.10.2-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:59a4450f262a55148fb7e68681522f0c2a2f6b7d89666312a2b32708d8f416e1"}, + {file = "tree_sitter_languages-1.10.2-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:ce74eab0e430370d5e15a96b6c6205f93405c177a8b2e71e1526643b2fb9bab1"}, + {file = "tree_sitter_languages-1.10.2-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:9b4dd2b6b3d24c85dffe33d6c343448869eaf4f41c19ddba662eb5d65d8808f4"}, + {file = "tree_sitter_languages-1.10.2-cp38-cp38-win32.whl", hash = "sha256:92d734fb968fe3927a7596d9f0459f81a8fa7b07e16569476b28e27d0d753348"}, + {file = "tree_sitter_languages-1.10.2-cp38-cp38-win_amd64.whl", hash = "sha256:46a13f7d38f2eeb75f7cf127d1201346093748c270d686131f0cbc50e42870a1"}, + {file = "tree_sitter_languages-1.10.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:f8c6a936ae99fdd8857e91f86c11c2f5e507ff30631d141d98132bb7ab2c8638"}, + {file = "tree_sitter_languages-1.10.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:c283a61423f49cdfa7b5a5dfbb39221e3bd126fca33479cd80749d4d7a6b7349"}, + {file = "tree_sitter_languages-1.10.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:76e60be6bdcff923386a54a5edcb6ff33fc38ab0118636a762024fa2bc98de55"}, + {file = "tree_sitter_languages-1.10.2-cp39-cp39-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c00069f9575bd831eabcce2cdfab158dde1ed151e7e5614c2d985ff7d78a7de1"}, + {file = "tree_sitter_languages-1.10.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:475ff53203d8a43ccb19bb322fa2fb200d764001cc037793f1fadd714bb343da"}, + {file = "tree_sitter_languages-1.10.2-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:26fe7c9c412e4141dea87ea4b3592fd12e385465b5bdab106b0d5125754d4f60"}, + {file = "tree_sitter_languages-1.10.2-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:8fed27319957458340f24fe14daad467cd45021da034eef583519f83113a8c5e"}, + {file = "tree_sitter_languages-1.10.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:3657a491a7f96cc75a3568ddd062d25f3be82b6a942c68801a7b226ff7130181"}, + {file = "tree_sitter_languages-1.10.2-cp39-cp39-win32.whl", hash = "sha256:33f7d584d01a7a3c893072f34cfc64ec031f3cfe57eebc32da2f8ac046e101a7"}, + {file = "tree_sitter_languages-1.10.2-cp39-cp39-win_amd64.whl", hash = "sha256:1b944af3ee729fa70fc8ae82224a9ff597cdb63addea084e0ea2fa2b0ec39bb7"}, ] [package.dependencies] @@ -2117,13 +2066,13 @@ files = [ [[package]] name = "types-tree-sitter-languages" -version = "1.8.0.0" +version = "1.10.0.20240201" description = "Typing stubs for tree-sitter-languages" optional = false -python-versions = ">=3.7" +python-versions = ">=3.8" files = [ - {file = "types-tree-sitter-languages-1.8.0.0.tar.gz", hash = "sha256:a066d1c91d5fe8b8fce08669816d9e8c41bbe348085b3cb9799fa74070a30604"}, - {file = "types_tree_sitter_languages-1.8.0.0-py3-none-any.whl", hash = "sha256:9d4a8e2a435a4a0d356e643fb53993e3c491749ce0b7a628c22cb87904c6daca"}, + {file = "types-tree-sitter-languages-1.10.0.20240201.tar.gz", hash = "sha256:10822bc9d2b98f7e8019a97f0233c68555d5c447ba4ef24284e93fd866ec73de"}, + {file = "types_tree_sitter_languages-1.10.0.20240201-py3-none-any.whl", hash = "sha256:3cb72f9df4c9b92a8710f0c1966de2d2295584b7cbea3194d0ba577fa50be56c"}, ] [package.dependencies] @@ -2167,17 +2116,18 @@ test = ["coverage", "pytest", "pytest-cov"] [[package]] name = "urllib3" -version = "2.1.0" +version = "2.2.0" description = "HTTP library with thread-safe connection pooling, file post, and more." optional = false python-versions = ">=3.8" files = [ - {file = "urllib3-2.1.0-py3-none-any.whl", hash = "sha256:55901e917a5896a349ff771be919f8bd99aff50b79fe58fec595eb37bbc56bb3"}, - {file = "urllib3-2.1.0.tar.gz", hash = "sha256:df7aa8afb0148fa78488e7899b2c59b5f4ffcfa82e6c54ccb9dd37c1d7b52d54"}, + {file = "urllib3-2.2.0-py3-none-any.whl", hash = "sha256:ce3711610ddce217e6d113a2732fafad960a03fd0318c91faa79481e35c11224"}, + {file = "urllib3-2.2.0.tar.gz", hash = "sha256:051d961ad0c62a94e50ecf1af379c3aba230c66c710493493560c0c223c49f20"}, ] [package.extras] brotli = ["brotli (>=1.0.9)", "brotlicffi (>=0.8.0)"] +h2 = ["h2 (>=4,<5)"] socks = ["pysocks (>=1.5.6,!=1.5.7,<2.0)"] zstd = ["zstandard (>=0.18.0)"] @@ -2203,38 +2153,40 @@ test = ["covdefaults (>=2.3)", "coverage (>=7.2.7)", "coverage-enable-subprocess [[package]] name = "watchdog" -version = "3.0.0" +version = "4.0.0" description = "Filesystem events monitoring" optional = false -python-versions = ">=3.7" +python-versions = ">=3.8" files = [ - {file = "watchdog-3.0.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:336adfc6f5cc4e037d52db31194f7581ff744b67382eb6021c868322e32eef41"}, - {file = "watchdog-3.0.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:a70a8dcde91be523c35b2bf96196edc5730edb347e374c7de7cd20c43ed95397"}, - {file = "watchdog-3.0.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:adfdeab2da79ea2f76f87eb42a3ab1966a5313e5a69a0213a3cc06ef692b0e96"}, - {file = "watchdog-3.0.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:2b57a1e730af3156d13b7fdddfc23dea6487fceca29fc75c5a868beed29177ae"}, - {file = "watchdog-3.0.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:7ade88d0d778b1b222adebcc0927428f883db07017618a5e684fd03b83342bd9"}, - {file = "watchdog-3.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:7e447d172af52ad204d19982739aa2346245cc5ba6f579d16dac4bfec226d2e7"}, - {file = "watchdog-3.0.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:9fac43a7466eb73e64a9940ac9ed6369baa39b3bf221ae23493a9ec4d0022674"}, - {file = "watchdog-3.0.0-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:8ae9cda41fa114e28faf86cb137d751a17ffd0316d1c34ccf2235e8a84365c7f"}, - {file = "watchdog-3.0.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:25f70b4aa53bd743729c7475d7ec41093a580528b100e9a8c5b5efe8899592fc"}, - {file = "watchdog-3.0.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:4f94069eb16657d2c6faada4624c39464f65c05606af50bb7902e036e3219be3"}, - {file = "watchdog-3.0.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:7c5f84b5194c24dd573fa6472685b2a27cc5a17fe5f7b6fd40345378ca6812e3"}, - {file = "watchdog-3.0.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:3aa7f6a12e831ddfe78cdd4f8996af9cf334fd6346531b16cec61c3b3c0d8da0"}, - {file = "watchdog-3.0.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:233b5817932685d39a7896b1090353fc8efc1ef99c9c054e46c8002561252fb8"}, - {file = "watchdog-3.0.0-pp37-pypy37_pp73-macosx_10_9_x86_64.whl", hash = "sha256:13bbbb462ee42ec3c5723e1205be8ced776f05b100e4737518c67c8325cf6100"}, - {file = "watchdog-3.0.0-pp38-pypy38_pp73-macosx_10_9_x86_64.whl", hash = "sha256:8f3ceecd20d71067c7fd4c9e832d4e22584318983cabc013dbf3f70ea95de346"}, - {file = "watchdog-3.0.0-pp39-pypy39_pp73-macosx_10_9_x86_64.whl", hash = "sha256:c9d8c8ec7efb887333cf71e328e39cffbf771d8f8f95d308ea4125bf5f90ba64"}, - {file = "watchdog-3.0.0-py3-none-manylinux2014_aarch64.whl", hash = "sha256:0e06ab8858a76e1219e68c7573dfeba9dd1c0219476c5a44d5333b01d7e1743a"}, - {file = "watchdog-3.0.0-py3-none-manylinux2014_armv7l.whl", hash = "sha256:d00e6be486affb5781468457b21a6cbe848c33ef43f9ea4a73b4882e5f188a44"}, - {file = "watchdog-3.0.0-py3-none-manylinux2014_i686.whl", hash = "sha256:c07253088265c363d1ddf4b3cdb808d59a0468ecd017770ed716991620b8f77a"}, - {file = "watchdog-3.0.0-py3-none-manylinux2014_ppc64.whl", hash = "sha256:5113334cf8cf0ac8cd45e1f8309a603291b614191c9add34d33075727a967709"}, - {file = "watchdog-3.0.0-py3-none-manylinux2014_ppc64le.whl", hash = "sha256:51f90f73b4697bac9c9a78394c3acbbd331ccd3655c11be1a15ae6fe289a8c83"}, - {file = "watchdog-3.0.0-py3-none-manylinux2014_s390x.whl", hash = "sha256:ba07e92756c97e3aca0912b5cbc4e5ad802f4557212788e72a72a47ff376950d"}, - {file = "watchdog-3.0.0-py3-none-manylinux2014_x86_64.whl", hash = "sha256:d429c2430c93b7903914e4db9a966c7f2b068dd2ebdd2fa9b9ce094c7d459f33"}, - {file = "watchdog-3.0.0-py3-none-win32.whl", hash = "sha256:3ed7c71a9dccfe838c2f0b6314ed0d9b22e77d268c67e015450a29036a81f60f"}, - {file = "watchdog-3.0.0-py3-none-win_amd64.whl", hash = "sha256:4c9956d27be0bb08fc5f30d9d0179a855436e655f046d288e2bcc11adfae893c"}, - {file = "watchdog-3.0.0-py3-none-win_ia64.whl", hash = "sha256:5d9f3a10e02d7371cd929b5d8f11e87d4bad890212ed3901f9b4d68767bee759"}, - {file = "watchdog-3.0.0.tar.gz", hash = "sha256:4d98a320595da7a7c5a18fc48cb633c2e73cda78f93cac2ef42d42bf609a33f9"}, + {file = "watchdog-4.0.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:39cb34b1f1afbf23e9562501673e7146777efe95da24fab5707b88f7fb11649b"}, + {file = "watchdog-4.0.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:c522392acc5e962bcac3b22b9592493ffd06d1fc5d755954e6be9f4990de932b"}, + {file = "watchdog-4.0.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:6c47bdd680009b11c9ac382163e05ca43baf4127954c5f6d0250e7d772d2b80c"}, + {file = "watchdog-4.0.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:8350d4055505412a426b6ad8c521bc7d367d1637a762c70fdd93a3a0d595990b"}, + {file = "watchdog-4.0.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:c17d98799f32e3f55f181f19dd2021d762eb38fdd381b4a748b9f5a36738e935"}, + {file = "watchdog-4.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4986db5e8880b0e6b7cd52ba36255d4793bf5cdc95bd6264806c233173b1ec0b"}, + {file = "watchdog-4.0.0-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:11e12fafb13372e18ca1bbf12d50f593e7280646687463dd47730fd4f4d5d257"}, + {file = "watchdog-4.0.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:5369136a6474678e02426bd984466343924d1df8e2fd94a9b443cb7e3aa20d19"}, + {file = "watchdog-4.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:76ad8484379695f3fe46228962017a7e1337e9acadafed67eb20aabb175df98b"}, + {file = "watchdog-4.0.0-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:45cc09cc4c3b43fb10b59ef4d07318d9a3ecdbff03abd2e36e77b6dd9f9a5c85"}, + {file = "watchdog-4.0.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:eed82cdf79cd7f0232e2fdc1ad05b06a5e102a43e331f7d041e5f0e0a34a51c4"}, + {file = "watchdog-4.0.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:ba30a896166f0fee83183cec913298151b73164160d965af2e93a20bbd2ab605"}, + {file = "watchdog-4.0.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:d18d7f18a47de6863cd480734613502904611730f8def45fc52a5d97503e5101"}, + {file = "watchdog-4.0.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:2895bf0518361a9728773083908801a376743bcc37dfa252b801af8fd281b1ca"}, + {file = "watchdog-4.0.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:87e9df830022488e235dd601478c15ad73a0389628588ba0b028cb74eb72fed8"}, + {file = "watchdog-4.0.0-pp310-pypy310_pp73-macosx_10_9_x86_64.whl", hash = "sha256:6e949a8a94186bced05b6508faa61b7adacc911115664ccb1923b9ad1f1ccf7b"}, + {file = "watchdog-4.0.0-pp38-pypy38_pp73-macosx_10_9_x86_64.whl", hash = "sha256:6a4db54edea37d1058b08947c789a2354ee02972ed5d1e0dca9b0b820f4c7f92"}, + {file = "watchdog-4.0.0-pp39-pypy39_pp73-macosx_10_9_x86_64.whl", hash = "sha256:d31481ccf4694a8416b681544c23bd271f5a123162ab603c7d7d2dd7dd901a07"}, + {file = "watchdog-4.0.0-py3-none-manylinux2014_aarch64.whl", hash = "sha256:8fec441f5adcf81dd240a5fe78e3d83767999771630b5ddfc5867827a34fa3d3"}, + {file = "watchdog-4.0.0-py3-none-manylinux2014_armv7l.whl", hash = "sha256:6a9c71a0b02985b4b0b6d14b875a6c86ddea2fdbebd0c9a720a806a8bbffc69f"}, + {file = "watchdog-4.0.0-py3-none-manylinux2014_i686.whl", hash = "sha256:557ba04c816d23ce98a06e70af6abaa0485f6d94994ec78a42b05d1c03dcbd50"}, + {file = "watchdog-4.0.0-py3-none-manylinux2014_ppc64.whl", hash = "sha256:d0f9bd1fd919134d459d8abf954f63886745f4660ef66480b9d753a7c9d40927"}, + {file = "watchdog-4.0.0-py3-none-manylinux2014_ppc64le.whl", hash = "sha256:f9b2fdca47dc855516b2d66eef3c39f2672cbf7e7a42e7e67ad2cbfcd6ba107d"}, + {file = "watchdog-4.0.0-py3-none-manylinux2014_s390x.whl", hash = "sha256:73c7a935e62033bd5e8f0da33a4dcb763da2361921a69a5a95aaf6c93aa03a87"}, + {file = "watchdog-4.0.0-py3-none-manylinux2014_x86_64.whl", hash = "sha256:6a80d5cae8c265842c7419c560b9961561556c4361b297b4c431903f8c33b269"}, + {file = "watchdog-4.0.0-py3-none-win32.whl", hash = "sha256:8f9a542c979df62098ae9c58b19e03ad3df1c9d8c6895d96c0d51da17b243b1c"}, + {file = "watchdog-4.0.0-py3-none-win_amd64.whl", hash = "sha256:f970663fa4f7e80401a7b0cbeec00fa801bf0287d93d48368fc3e6fa32716245"}, + {file = "watchdog-4.0.0-py3-none-win_ia64.whl", hash = "sha256:9a03e16e55465177d416699331b0f3564138f1807ecc5f2de9d55d8f188d08c7"}, + {file = "watchdog-4.0.0.tar.gz", hash = "sha256:e3e7065cbdabe6183ab82199d7a4f6b3ba0a438c5a512a68559846ccb76a78ec"}, ] [package.extras] @@ -2364,4 +2316,4 @@ syntax = ["tree-sitter", "tree_sitter_languages"] [metadata] lock-version = "2.0" python-versions = "^3.8" -content-hash = "e82a92dbbd8916df2ee09402ca7c4bc4c8dddb713e2723dc6eb144b373c99d1c" +content-hash = "014186a223d4236fb0ace86bef24fb030d6921cf714a8b4d2b889ba066a26375" diff --git a/pyproject.toml b/pyproject.toml index 94b4b1d92d..429bfa777a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "textual" -version = "0.48.0" +version = "0.51.0" homepage = "https://github.com/Textualize/textual" repository = "https://github.com/Textualize/textual" documentation = "https://textual.textualize.io/" @@ -62,7 +62,6 @@ mkdocstrings-python = "0.10.1" mkdocs-material = "^9.0.11" mkdocs-exclude = "^1.0.2" pre-commit = "^2.13.0" -time-machine = "^2.6.0" mkdocs-rss-plugin = "^1.5.0" httpx = "^0.23.1" types-setuptools = "^67.2.0.1" @@ -73,9 +72,6 @@ types-tree-sitter = "^0.20.1.4" types-tree-sitter-languages = "^1.7.0.1" griffe = "0.32.3" -[tool.black] -includes = "src" - [tool.pytest.ini_options] asyncio_mode = "auto" testpaths = ["tests"] diff --git a/src/textual/_system_commands.py b/src/textual/_system_commands.py index 8cbb6ef017..ffa73b263a 100644 --- a/src/textual/_system_commands.py +++ b/src/textual/_system_commands.py @@ -4,7 +4,10 @@ actions available via the [command palette][textual.command.CommandPalette]. """ -from .command import Hit, Hits, Provider +from __future__ import annotations + +from .command import DiscoveryHit, Hit, Hits, Provider +from .types import IgnoreReturnCallbackType class SystemCommands(Provider): @@ -13,22 +16,10 @@ class SystemCommands(Provider): Used by default in [`App.COMMANDS`][textual.app.App.COMMANDS]. """ - async def search(self, query: str) -> Hits: - """Handle a request to search for system commands that match the query. - - Args: - query: The user input to be matched. - - Yields: - Command hits for use in the command palette. - """ - # We're going to use Textual's builtin fuzzy matcher to find - # matching commands. - matcher = self.matcher(query) - - # Loop over all applicable commands, find those that match and offer - # them up to the command palette. - for name, runnable, help_text in ( + @property + def _system_commands(self) -> tuple[tuple[str, IgnoreReturnCallbackType, str], ...]: + """The system commands to reveal to the command palette.""" + return ( ( "Toggle light/dark mode", self.app.action_toggle_dark, @@ -44,9 +35,38 @@ async def search(self, query: str) -> Hits: self.app.action_bell, "Ring the terminal's 'bell'", ), - ): - match = matcher.match(name) - if match > 0: + ) + + async def discover(self) -> Hits: + """Handle a request for the discovery commands for this provider. + + Yields: + Commands that can be discovered. + """ + for name, runnable, help_text in self._system_commands: + yield DiscoveryHit( + name, + runnable, + help=help_text, + ) + + async def search(self, query: str) -> Hits: + """Handle a request to search for system commands that match the query. + + Args: + query: The user input to be matched. + + Yields: + Command hits for use in the command palette. + """ + # We're going to use Textual's builtin fuzzy matcher to find + # matching commands. + matcher = self.matcher(query) + + # Loop over all applicable commands, find those that match and offer + # them up to the command palette. + for name, runnable, help_text in self._system_commands: + if (match := matcher.match(name)) > 0: yield Hit( match, matcher.highlight(name), diff --git a/src/textual/_text_area_theme.py b/src/textual/_text_area_theme.py index 228f4bffc3..14bc6ffd37 100644 --- a/src/textual/_text_area_theme.py +++ b/src/textual/_text_area_theme.py @@ -107,7 +107,7 @@ def apply_css(self, text_area: TextArea) -> None: self.cursor_style = cursor_style else: # There's no component style either, fallback to a default. - self.cursor_style = Style( + self.cursor_style = Style.from_color( color=background_color.rich_color, bgcolor=background_color.inverse.rich_color, ) @@ -119,9 +119,6 @@ def apply_css(self, text_area: TextArea) -> None: if self.cursor_line_gutter_style is None: self.cursor_line_gutter_style = get_style("text-area--cursor-gutter") - if self.cursor_line_gutter_style is None and self.cursor_line_style is not None: - self.cursor_line_gutter_style = self.cursor_line_style.copy() - if self.bracket_matching_style is None: matching_bracket_style = get_style("text-area--matching-bracket") if matching_bracket_style: @@ -182,15 +179,6 @@ def builtin_themes(cls) -> list[TextAreaTheme]: """ return list(_BUILTIN_THEMES.values()) - @classmethod - def default(cls) -> TextAreaTheme: - """Get the default syntax theme. - - Returns: - The default TextAreaTheme (probably "monokai"). - """ - return _MONOKAI - _MONOKAI = TextAreaTheme( name="monokai", @@ -379,7 +367,7 @@ def default(cls) -> TextAreaTheme: }, ) -_CSS_THEME = TextAreaTheme(name="css") +_CSS_THEME = TextAreaTheme(name="css", syntax_styles=_DARK_VS.syntax_styles) _BUILTIN_THEMES = { "css": _CSS_THEME, @@ -388,6 +376,3 @@ def default(cls) -> TextAreaTheme: "vscode_dark": _DARK_VS, "github_light": _GITHUB_LIGHT, } - -DEFAULT_THEME = TextAreaTheme.get_builtin_theme("basic") -"""The default TextAreaTheme used by Textual.""" diff --git a/src/textual/_types.py b/src/textual/_types.py index da67f25574..603d799f05 100644 --- a/src/textual/_types.py +++ b/src/textual/_types.py @@ -31,14 +31,26 @@ class UnusedParameter: """Type used for arbitrary callables used in callbacks.""" IgnoreReturnCallbackType = Union[Callable[[], Awaitable[Any]], Callable[[], Any]] """A callback which ignores the return type.""" -WatchCallbackType = Union[ - Callable[[], Awaitable[None]], - Callable[[Any], Awaitable[None]], +WatchCallbackBothValuesType = Union[ Callable[[Any, Any], Awaitable[None]], - Callable[[], None], - Callable[[Any], None], Callable[[Any, Any], None], ] +"""Type for watch methods that accept the old and new values of reactive objects.""" +WatchCallbackNewValueType = Union[ + Callable[[Any], Awaitable[None]], + Callable[[Any], None], +] +"""Type for watch methods that accept only the new value of reactive objects.""" +WatchCallbackNoArgsType = Union[ + Callable[[], Awaitable[None]], + Callable[[], None], +] +"""Type for watch methods that do not require the explicit value of the reactive.""" +WatchCallbackType = Union[ + WatchCallbackBothValuesType, + WatchCallbackNewValueType, + WatchCallbackNoArgsType, +] """Type used for callbacks passed to the `watch` method of widgets.""" AnimationLevel = Literal["none", "basic", "full"] diff --git a/src/textual/_widget_navigation.py b/src/textual/_widget_navigation.py new file mode 100644 index 0000000000..6cd829521f --- /dev/null +++ b/src/textual/_widget_navigation.py @@ -0,0 +1,198 @@ +""" +Utilities to move index-based selections backward/forward. + +These utilities concern themselves with selections where not all options are available, +otherwise it would be enough to increment/decrement the index and use the operator `%` +to implement wrapping. +""" + +from __future__ import annotations + +from functools import partial +from itertools import count +from typing import TYPE_CHECKING, Literal, Protocol, Sequence + +from typing_extensions import TypeAlias, TypeVar + +if TYPE_CHECKING: + from .widget import Widget + + +class Disableable(Protocol): + """Non-widgets that have an enabled/disabled status.""" + + disabled: bool + + +Direction: TypeAlias = Literal[-1, 1] +"""Valid values to determine navigation direction. + +In a vertical setting, 1 points down and -1 points up. +In a horizontal setting, 1 points right and -1 points left. +""" +_DisableableType = TypeVar("_DisableableType", bound=Disableable) +_WidgetType = TypeVar("_WidgetType", bound="Widget") + + +def get_directed_distance( + index: int, start: int, direction: Direction, wrap_at: int +) -> int: + """Computes the distance going from `start` to `index` in the given direction. + + Starting at `start`, this is the number of steps you need to take in the given + `direction` to reach `index`, assuming there is wrapping at 0 and `wrap_at`. + This is also the smallest non-negative integer solution `d` to + `(start + d * direction) % wrap_at == index`. + + The diagram below illustrates the computation of `d1 = distance(2, 8, 1, 10)` and + `d2 = distance(2, 8, -1, 10)`: + + ``` + start ────────────────────┐ + index ────────┐ │ + indices 0 1 2 3 4 5 6 7 8 9 + d1 2 3 4 0 1 + > > > > > (direction == 1) + d2 6 5 4 3 2 1 0 + < < < < < < < (direction == -1) + ``` + + Args: + index: The index that we want to reach. + start: The starting point to consider when computing the distance. + direction: The direction in which we want to compute the distance. + wrap_at: Controls at what point wrapping around takes place. + + Returns: + The computed distance. + """ + return direction * (index - start) % wrap_at + + +def find_first_enabled( + candidates: Sequence[_DisableableType] | Sequence[_WidgetType], +) -> int | None: + """Find the first enabled candidate in a sequence of possibly-disabled objects. + + Args: + candidates: The sequence of candidates to consider. + + Returns: + The first enabled candidate or `None` if none were available. + """ + return next( + (index for index, candidate in enumerate(candidates) if not candidate.disabled), + None, + ) + + +def find_last_enabled( + candidates: Sequence[_DisableableType] | Sequence[_WidgetType], +) -> int | None: + """Find the last enabled candidate in a sequence of possibly-disabled objects. + + Args: + candidates: The sequence of candidates to consider. + + Returns: + The last enabled candidate or `None` if none were available. + """ + total_candidates = len(candidates) + return next( + ( + total_candidates - offset_from_end + for offset_from_end, candidate in enumerate(reversed(candidates), start=1) + if not candidate.disabled + ), + None, + ) + + +def find_next_enabled( + candidates: Sequence[_DisableableType] | Sequence[_WidgetType], + anchor: int | None, + direction: Direction, + with_anchor: bool = False, +) -> int | None: + """Find the next enabled object if we're currently at the given anchor. + + The definition of "next" depends on the given direction and this function will wrap + around the ends of the sequence of object candidates. + + Args: + candidates: The sequence of object candidates to consider. + anchor: The point of the sequence from which we'll start looking for the next + enabled object. + direction: The direction in which to traverse the candidates when looking for + the next enabled candidate. + with_anchor: Consider the anchor position as the first valid position instead of + the last one. + + Returns: + The next enabled object. If none are available, return the anchor. + """ + + if anchor is None: + if candidates: + return ( + find_first_enabled(candidates) + if direction == 1 + else find_last_enabled(candidates) + ) + return None + + start = anchor + direction if not with_anchor else anchor + key_function = partial( + get_directed_distance, + start=start, + direction=direction, + wrap_at=len(candidates), + ) + enabled_candidates = [ + index for index, candidate in enumerate(candidates) if not candidate.disabled + ] + return min(enabled_candidates, key=key_function, default=anchor) + + +def find_next_enabled_no_wrap( + candidates: Sequence[_DisableableType] | Sequence[_WidgetType], + anchor: int | None, + direction: Direction, + with_anchor: bool = False, +) -> int | None: + """Find the next enabled object starting from the given anchor (without wrapping). + + The meaning of "next" and "past" depend on the direction specified. + + Args: + candidates: The sequence of object candidates to consider. + anchor: The point of the sequence from which we'll start looking for the next + enabled object. + direction: The direction in which to traverse the candidates when looking for + the next enabled candidate. + with_anchor: Whether to consider the anchor or not. + + Returns: + The next enabled object. If none are available, return None. + """ + + if anchor is None: + if candidates: + return ( + find_first_enabled(candidates) + if direction == 1 + else find_last_enabled(candidates) + ) + return None + + start = anchor if with_anchor else anchor + direction + counter = count(start, direction) + valid_candidates = ( + candidates[start:] if direction == 1 else reversed(candidates[: start + 1]) + ) + + for idx, candidate in zip(counter, valid_candidates): + if candidate.disabled: + continue + return idx + return None diff --git a/src/textual/app.py b/src/textual/app.py index a6d9b9d3ee..b2b1a81649 100644 --- a/src/textual/app.py +++ b/src/textual/app.py @@ -95,6 +95,7 @@ from .errors import NoWidget from .features import FeatureFlag, parse_features from .file_monitor import FileMonitor +from .filter import ANSIToTruecolor, DimFilter, Monochrome from .geometry import Offset, Region, Size from .keys import ( REPLACED_KEYS, @@ -421,20 +422,17 @@ def __init__( super().__init__() self.features: frozenset[FeatureFlag] = parse_features(os.getenv("TEXTUAL", "")) - self._filters: list[LineFilter] = [] + self._filters: list[LineFilter] = [ + ANSIToTruecolor(terminal_theme.DIMMED_MONOKAI) + ] environ = dict(os.environ) no_color = environ.pop("NO_COLOR", None) if no_color is not None: - from .filter import Monochrome - self._filters.append(Monochrome()) for filter_name in constants.FILTERS.split(","): filter = filter_name.lower().strip() if filter == "dim": - from .filter import ANSIToTruecolor, DimFilter - - self._filters.append(ANSIToTruecolor(terminal_theme.DIMMED_MONOKAI)) self._filters.append(DimFilter()) self.console = Console( diff --git a/src/textual/command.py b/src/textual/command.py index 9136bf9d5b..94d3aa07dc 100644 --- a/src/textual/command.py +++ b/src/textual/command.py @@ -49,6 +49,7 @@ __all__ = [ "CommandPalette", + "DiscoveryHit", "Hit", "Hits", "Matcher", @@ -82,6 +83,11 @@ class Hit: help: str | None = None """Optional help text for the command.""" + @property + def prompt(self) -> RenderableType: + """The prompt to use when displaying the hit in the command palette.""" + return self.match_display + def __lt__(self, other: object) -> bool: if isinstance(other, Hit): return self.score < other.score @@ -105,7 +111,57 @@ def __post_init__(self) -> None: ) -Hits: TypeAlias = AsyncIterator[Hit] +@dataclass +class DiscoveryHit: + """Holds the details of a single command search hit.""" + + display: RenderableType + """A string or Rich renderable representation of the hit.""" + + command: IgnoreReturnCallbackType + """The function to call when the command is chosen.""" + + text: str | None = None + """The command text associated with the hit, as plain text. + + If `display` is not simple text, this attribute should be provided by + the [Provider][textual.command.Provider] object. + """ + + help: str | None = None + """Optional help text for the command.""" + + @property + def prompt(self) -> RenderableType: + """The prompt to use when displaying the discovery hit in the command palette.""" + return self.display + + def __lt__(self, other: object) -> bool: + if isinstance(other, DiscoveryHit): + assert self.text is not None + assert other.text is not None + return other.text < self.text + return NotImplemented + + def __eq__(self, other: object) -> bool: + if isinstance(other, Hit): + return self.text == other.text + return NotImplemented + + def __post_init__(self) -> None: + """Ensure 'text' is populated.""" + if self.text is None: + if isinstance(self.display, str): + self.text = self.display + elif isinstance(self.display, Text): + self.text = self.display.plain + else: + raise ValueError( + "A value for 'text' is required if 'display' is not a str or Text" + ) + + +Hits: TypeAlias = AsyncIterator["DiscoveryHit | Hit"] """Return type for the command provider's `search` method.""" @@ -199,9 +255,12 @@ async def _search(self, query: str) -> Hits: """ await self._wait_init() if self._init_success: - hits = self.search(query) + # An empty search string is a discovery search, anything else is + # a conventional search. + hits = self.search(query) if query else self.discover() async for hit in hits: - yield hit + if hit is not NotImplemented: + yield hit @abstractmethod async def search(self, query: str) -> Hits: @@ -215,6 +274,22 @@ async def search(self, query: str) -> Hits: """ yield NotImplemented + async def discover(self) -> Hits: + """A default collection of hits for the provider. + + Yields: + Instances of [`DiscoveryHit`][textual.command.DiscoveryHit]. + + Note: + This is different from + [`search`][textual.command.Provider.search] in that it should + yield [`DiscoveryHit`s][textual.command.DiscoveryHit] that + should be shown by default (before user input). + + It is permitted to *not* implement this method. + """ + yield NotImplemented + async def _shutdown(self) -> None: """Internal method to call shutdown and log errors.""" try: @@ -240,7 +315,7 @@ class Command(Option): def __init__( self, prompt: RenderableType, - command: Hit, + command: DiscoveryHit | Hit, id: str | None = None, disabled: bool = False, ) -> None: @@ -465,7 +540,7 @@ class CommandPalette(_SystemModalScreen[CallbackType]): def __init__(self) -> None: """Initialise the command palette.""" super().__init__(id=self._PALETTE_ID) - self._selected_command: Hit | None = None + self._selected_command: DiscoveryHit | Hit | None = None """The command that was selected by the user.""" self._busy_timer: Timer | None = None """Keeps track of if there's a busy indication timer in effect.""" @@ -561,6 +636,7 @@ def _on_mount(self, _: Mount) -> None: ] for provider in self._providers: provider._post_init() + self._gather_commands("") async def _on_unmount(self) -> None: """Shutdown providers when command palette is closed.""" @@ -598,23 +674,36 @@ def _stop_no_matches_countdown(self) -> None: _NO_MATCHES_COUNTDOWN: Final[float] = 0.5 """How many seconds to wait before showing 'No matches found'.""" - def _start_no_matches_countdown(self) -> None: + def _start_no_matches_countdown(self, search_value: str) -> None: """Start a countdown to showing that there are no matches for the query. - Adds a 'No matches found' option to the command list after `_NO_MATCHES_COUNTDOWN` seconds. + Args: + search_value: The value being searched for. + + Adds a 'No matches found' option to the command list after + `_NO_MATCHES_COUNTDOWN` seconds. """ self._stop_no_matches_countdown() def _show_no_matches() -> None: - command_list = self.query_one(CommandList) - command_list.add_option( - Option( - Align.center(Text("No matches found")), - disabled=True, - id=self._NO_MATCHES, + # If we were actually searching for something, show that we + # found no matches. + if search_value: + command_list = self.query_one(CommandList) + command_list.add_option( + Option( + Align.center(Text("No matches found")), + disabled=True, + id=self._NO_MATCHES, + ) ) - ) - self._list_visible = True + self._list_visible = True + else: + # The search value was empty, which means we were in + # discover mode; in that case it makes no sense to show that + # no matches were found. Lack of commands that can be + # discovered is a situation we don't need to highlight. + self._list_visible = False self._no_matches_timer = self.set_timer( self._NO_MATCHES_COUNTDOWN, @@ -640,7 +729,7 @@ async def _watch__show_busy(self) -> None: self.query_one(CommandList).set_class(self._show_busy, "--populating") @staticmethod - async def _consume(hits: Hits, commands: Queue[Hit]) -> None: + async def _consume(hits: Hits, commands: Queue[DiscoveryHit | Hit]) -> None: """Consume a source of matching commands, feeding the given command queue. Args: @@ -650,7 +739,9 @@ async def _consume(hits: Hits, commands: Queue[Hit]) -> None: async for hit in hits: await commands.put(hit) - async def _search_for(self, search_value: str) -> AsyncGenerator[Hit, bool]: + async def _search_for( + self, search_value: str + ) -> AsyncGenerator[DiscoveryHit | Hit, bool]: """Search for a given search value amongst all of the command providers. Args: @@ -661,7 +752,7 @@ async def _search_for(self, search_value: str) -> AsyncGenerator[Hit, bool]: """ # Set up a queue to stream in the command hits from all the providers. - commands: Queue[Hit] = Queue() + commands: Queue[DiscoveryHit | Hit] = Queue() # Fire up an instance of each command provider, inside a task, and # have them go start looking for matches. @@ -794,7 +885,7 @@ def _refresh_command_list( else None ) command_list.clear_options().add_options(sorted(commands, reverse=True)) - if highlighted is not None: + if highlighted is not None and highlighted.id: command_list.highlighted = command_list.get_option_index(highlighted.id) self._list_visible = bool(command_list.option_count) @@ -877,7 +968,7 @@ async def _gather_commands(self, search_value: str) -> None: while hit: # Turn the command into something for display, and add it to the # list of commands that have been gathered so far. - prompt = hit.match_display + prompt = hit.prompt if hit.help: prompt = Group(prompt, Text(hit.help, style=help_style)) gathered_commands.append(Command(prompt, hit, id=str(command_id))) @@ -926,7 +1017,7 @@ async def _gather_commands(self, search_value: str) -> None: # mean nothing was found. Give the user positive feedback to that # effect. if command_list.option_count == 0 and not worker.is_cancelled: - self._start_no_matches_countdown() + self._start_no_matches_countdown(search_value) def _cancel_gather_commands(self) -> None: """Cancel any operation that is gather commands.""" @@ -942,13 +1033,7 @@ def _input(self, event: Input.Changed) -> None: event.stop() self._cancel_gather_commands() self._stop_no_matches_countdown() - - search_value = event.value.strip() - if search_value: - self._gather_commands(search_value) - else: - self._list_visible = False - self.query_one(CommandList).clear_options() + self._gather_commands(event.value.strip()) @on(OptionList.OptionSelected) def _select_command(self, event: OptionList.OptionSelected) -> None: diff --git a/src/textual/css/query.py b/src/textual/css/query.py index 10a97ed54b..05aa749622 100644 --- a/src/textual/css/query.py +++ b/src/textual/css/query.py @@ -85,6 +85,7 @@ def __init__( Raises: InvalidQueryFormat: If the format of the query is invalid. """ + _rich_traceback_omit = True self._node = node self._nodes: list[QueryType] | None = None self._filters: list[tuple[SelectorSet, ...]] = ( @@ -153,16 +154,19 @@ def __getitem__(self, index: int | slice) -> QueryType | list[QueryType]: return self.nodes[index] def __rich_repr__(self) -> rich.repr.Result: - if self._filters: - yield "query", " AND ".join( - ",".join(selector.css for selector in selectors) - for selectors in self._filters - ) - if self._excludes: - yield "exclude", " OR ".join( - ",".join(selector.css for selector in selectors) - for selectors in self._excludes - ) + try: + if self._filters: + yield "query", " AND ".join( + ",".join(selector.css for selector in selectors) + for selectors in self._filters + ) + if self._excludes: + yield "exclude", " OR ".join( + ",".join(selector.css for selector in selectors) + for selectors in self._excludes + ) + except AttributeError: + pass def filter(self, selector: str) -> DOMQuery[QueryType]: """Filter this set by the given CSS selector. @@ -219,7 +223,7 @@ def first( ) return first else: - raise NoMatches(f"No nodes match {self!r}") + raise NoMatches(f"No nodes match {self!r} on {self.node!r}") @overload def only_one(self) -> QueryType: ... @@ -289,7 +293,7 @@ def last( The matching Widget. """ if not self.nodes: - raise NoMatches(f"No nodes match {self!r}") + raise NoMatches(f"No nodes match {self!r} on dom{self.node!r}") last = self.nodes[-1] if expect_type is not None and not isinstance(last, expect_type): raise WrongType( @@ -444,3 +448,32 @@ def blur(self) -> DOMQuery[QueryType]: if focused in nodes: self._node.screen._reset_focus(focused, avoiding=nodes) return self + + def set( + self, + display: bool | None = None, + visible: bool | None = None, + disabled: bool | None = None, + loading: bool | None = None, + ) -> DOMQuery[QueryType]: + """Sets common attributes on matched nodes. + + Args: + display: Set `display` attribute on nodes, or `None` for no change. + visible: Set `visible` attribute on nodes, or `None` for no change. + disabled: Set `disabled` attribute on nodes, or `None` for no change. + loading: Set `loading` attribute on nodes, or `None` for no change. + + Returns: + Query for chaining. + """ + for node in self: + if display is not None: + node.display = display + if visible is not None: + node.visible = visible + if disabled is not None: + node.disabled = disabled + if loading is not None: + node.loading = loading + return self diff --git a/src/textual/css/tokenize.py b/src/textual/css/tokenize.py index 0d0df40002..b029fad550 100644 --- a/src/textual/css/tokenize.py +++ b/src/textual/css/tokenize.py @@ -64,6 +64,7 @@ whitespace=r"\s+", comment_start=COMMENT_START, comment_line=COMMENT_LINE, + declaration_name=r"[a-zA-Z_\-]+\:", selector_start_id=r"\#" + IDENTIFIER, selector_start_class=r"\." + IDENTIFIER, selector_start_universal=r"\*", @@ -105,6 +106,7 @@ new_selector=r",", declaration_set_start=r"\{", declaration_set_end=r"\}", + nested=r"\&", ).expect_eof(True) # A rule declaration e.g. "text: red;" diff --git a/src/textual/document/_document.py b/src/textual/document/_document.py index afd4348e12..3a4e5729b2 100644 --- a/src/textual/document/_document.py +++ b/src/textual/document/_document.py @@ -52,7 +52,7 @@ def _detect_newline_style(text: str) -> Newline: text: The text to inspect. Returns: - The NewlineStyle used in the file. + The Newline used in the file. """ if "\r\n" in text: # Windows newline return "\r\n" diff --git a/src/textual/document/_document_navigator.py b/src/textual/document/_document_navigator.py index e265f03b2a..25b44e8422 100644 --- a/src/textual/document/_document_navigator.py +++ b/src/textual/document/_document_navigator.py @@ -101,11 +101,15 @@ def is_start_of_wrapped_line(self, location: Location) -> bool: def is_end_of_document_line(self, location: Location) -> bool: """True if the location is at the end of a line in the document. + Note that the "end" of a line is equal to its length (one greater + than the final index), since there is a space at the end of the line + for the cursor to rest. + Args: location: The location to examine. Returns: - True if and only if the document is on the last line of the document. + True if and only if the document is at the end of a line in the document. """ row, column = location row_length = len(self._document[row]) diff --git a/src/textual/document/_edit.py b/src/textual/document/_edit.py new file mode 100644 index 0000000000..d0bd5ad83d --- /dev/null +++ b/src/textual/document/_edit.py @@ -0,0 +1,143 @@ +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import TYPE_CHECKING + +from textual.document._document import EditResult, Location, Selection + +if TYPE_CHECKING: + from textual.widgets import TextArea + + +@dataclass +class Edit: + """Implements the Undoable protocol to replace text at some range within a document.""" + + text: str + """The text to insert. An empty string is equivalent to deletion.""" + + from_location: Location + """The start location of the insert.""" + + to_location: Location + """The end location of the insert""" + + maintain_selection_offset: bool + """If True, the selection will maintain its offset to the replacement range.""" + + _original_selection: Selection | None = field(init=False, default=None) + """The Selection when the edit was originally performed, to be restored on undo.""" + + _updated_selection: Selection | None = field(init=False, default=None) + """Where the selection should move to after the replace happens.""" + + _edit_result: EditResult | None = field(init=False, default=None) + """The result of doing the edit.""" + + def do(self, text_area: TextArea, record_selection: bool = True) -> EditResult: + """Perform the edit operation. + + Args: + text_area: The `TextArea` to perform the edit on. + record_selection: If True, record the current selection in the TextArea + so that it may be restored if this Edit is undone in the future. + + Returns: + An `EditResult` containing information about the replace operation. + """ + if record_selection: + self._original_selection = text_area.selection + + text = self.text + + # This code is mostly handling how we adjust TextArea.selection + # when an edit is made to the document programmatically. + # We want a user who is typing away to maintain their relative + # position in the document even if an insert happens before + # their cursor position. + + edit_bottom_row, edit_bottom_column = self.bottom + + selection_start, selection_end = text_area.selection + selection_start_row, selection_start_column = selection_start + selection_end_row, selection_end_column = selection_end + + edit_result = text_area.document.replace_range(self.top, self.bottom, text) + + new_edit_to_row, new_edit_to_column = edit_result.end_location + + column_offset = new_edit_to_column - edit_bottom_column + target_selection_start_column = ( + selection_start_column + column_offset + if edit_bottom_row == selection_start_row + and edit_bottom_column <= selection_start_column + else selection_start_column + ) + target_selection_end_column = ( + selection_end_column + column_offset + if edit_bottom_row == selection_end_row + and edit_bottom_column <= selection_end_column + else selection_end_column + ) + + row_offset = new_edit_to_row - edit_bottom_row + target_selection_start_row = selection_start_row + row_offset + target_selection_end_row = selection_end_row + row_offset + + if self.maintain_selection_offset: + self._updated_selection = Selection( + start=(target_selection_start_row, target_selection_start_column), + end=(target_selection_end_row, target_selection_end_column), + ) + else: + self._updated_selection = Selection.cursor(edit_result.end_location) + + self._edit_result = edit_result + return edit_result + + def undo(self, text_area: TextArea) -> EditResult: + """Undo the edit operation. + + Looks at the data stored in the edit, and performs the inverse operation of `Edit.do`. + + Args: + text_area: The `TextArea` to undo the insert operation on. + + Returns: + An `EditResult` containing information about the replace operation. + """ + replaced_text = self._edit_result.replaced_text + edit_end = self._edit_result.end_location + + # Replace the span of the edit with the text that was originally there. + undo_edit_result = text_area.document.replace_range( + self.top, edit_end, replaced_text + ) + self._updated_selection = self._original_selection + + return undo_edit_result + + def after(self, text_area: TextArea) -> None: + """Hook for running code after an Edit has been performed via `Edit.do` *and* + side effects such as re-wrapping the document and refreshing the display + have completed. + + For example, we can't record cursor visual offset until we know where the cursor will + land *after* wrapping has been performed, so we must wait until here to do it. + + Args: + text_area: The `TextArea` this operation was performed on. + """ + if self._updated_selection is not None: + text_area.selection = self._updated_selection + text_area.record_cursor_width() + + @property + def top(self) -> Location: + """The Location impacted by this edit that is nearest the start of the document.""" + return min([self.from_location, self.to_location]) + + @property + def bottom(self) -> Location: + """The Location impacted by this edit that is nearest the end of the document.""" + return max([self.from_location, self.to_location]) diff --git a/src/textual/document/_history.py b/src/textual/document/_history.py new file mode 100644 index 0000000000..c779fdd7ab --- /dev/null +++ b/src/textual/document/_history.py @@ -0,0 +1,183 @@ +from __future__ import annotations + +import time +from collections import deque +from dataclasses import dataclass, field + +from textual.document._edit import Edit + + +class HistoryException(Exception): + """Indicates misuse of the EditHistory API. + + For example, trying to undo() an Edit that has yet to be done. + """ + + +@dataclass +class EditHistory: + """Manages batching/checkpointing of Edits into groups that can be undone/redone in the TextArea.""" + + max_checkpoints: int + + checkpoint_timer: float + """Maximum number of seconds since last edit until a new batch is created.""" + + checkpoint_max_characters: int + """Maximum number of characters that can appear in a batch before a new batch is formed.""" + + _last_edit_time: float = field(init=False, default_factory=time.monotonic) + + _character_count: int = field(init=False, default=0) + """Track number of characters replaced + inserted since last batch creation.""" + + _force_end_batch: bool = field(init=False, default=False) + """Flag to force the creation of a new batch for the next recorded edit.""" + + _previously_replaced: bool = field(init=False, default=False) + """Records whether the most recent edit was a replacement or a pure insertion. + + If an edit removes any text from the document at all, it's considered a replacement. + Every other edit is considered a pure insertion. + """ + + def __post_init__(self) -> None: + self._undo_stack: deque[list[Edit]] = deque(maxlen=self.max_checkpoints) + """Batching Edit operations together (edits are simply grouped together in lists).""" + self._redo_stack: deque[list[Edit]] = deque() + """Stores batches that have been undone, allowing them to be redone.""" + + def record(self, edit: Edit) -> None: + """Record an Edit so that it may be undone and redone. + + Determines whether to batch the Edit with previous Edits, or create a new batch/checkpoint. + + This method must be called exactly once per edit, in chronological order. + + A new batch/checkpoint is created when: + + - The undo stack is empty. + - The checkpoint timer expires. + - The maximum number of characters permitted in a checkpoint is reached. + - A redo is performed (we should not add new edits to a batch that has been redone). + - The programmer has requested a new batch via a call to `force_new_batch`. + - e.g. the TextArea widget may call this method in some circumstances. + - Clicking to move the cursor elsewhere in the document should create a new batch. + - Movement of the cursor via a keyboard action that is NOT an edit. + - Blurring the TextArea creates a new checkpoint. + - The current edit involves a deletion/replacement and the previous edit did not. + - The current edit is a pure insertion and the previous edit was not. + - The edit involves insertion or deletion of one or more newline characters. + - An edit which inserts more than a single character (a paste) gets an isolated batch. + + Args: + edit: The edit to record. + """ + edit_result = edit._edit_result + if edit_result is None: + raise HistoryException( + "Cannot add an edit to history before it has been performed using `Edit.do`." + ) + + if edit.text == "" and edit_result.replaced_text == "": + return None + + is_replacement = bool(edit_result.replaced_text) + undo_stack = self._undo_stack + current_time = self._get_time() + edit_characters = len(edit.text) + contains_newline = "\n" in edit.text or "\n" in edit_result.replaced_text + + # Determine whether to create a new batch, or add to the latest batch. + if ( + not undo_stack + or self._force_end_batch + or edit_characters > 1 + or contains_newline + or is_replacement != self._previously_replaced + or current_time - self._last_edit_time > self.checkpoint_timer + or self._character_count + edit_characters > self.checkpoint_max_characters + ): + # Create a new batch (creating a "checkpoint"). + undo_stack.append([edit]) + self._character_count = edit_characters + self._last_edit_time = current_time + self._force_end_batch = False + else: + # Update the latest batch. + undo_stack[-1].append(edit) + self._character_count += edit_characters + self._last_edit_time = current_time + + self._previously_replaced = is_replacement + self._redo_stack.clear() + + # For some edits, we want to ensure the NEXT edit cannot be added to its batch, + # so enforce a checkpoint now. + if contains_newline or edit_characters > 1: + self.checkpoint() + + def _pop_undo(self) -> list[Edit] | None: + """Pop the latest batch from the undo stack and return it. + + This will also place it on the redo stack. + + Returns: + The batch of Edits from the top of the undo stack or None if it's empty. + """ + undo_stack = self._undo_stack + redo_stack = self._redo_stack + if undo_stack: + batch = undo_stack.pop() + redo_stack.append(batch) + return batch + return None + + def _pop_redo(self) -> list[Edit] | None: + """Redo the latest batch on the redo stack and return it. + + This will also place it on the undo stack (with a forced checkpoint to ensure + this undo does not get batched with other edits). + + Returns: + The batch of Edits from the top of the redo stack or None if it's empty. + """ + undo_stack = self._undo_stack + redo_stack = self._redo_stack + if redo_stack: + batch = redo_stack.pop() + undo_stack.append(batch) + # Ensure edits which follow cannot be added to the redone batch. + self.checkpoint() + return batch + return None + + def clear(self) -> None: + """Completely clear the history.""" + self._undo_stack.clear() + self._redo_stack.clear() + self._last_edit_time = time.monotonic() + self._force_end_batch = False + self._previously_replaced = False + + def checkpoint(self) -> None: + """Ensure the next recorded edit starts a new batch.""" + self._force_end_batch = True + + @property + def undo_stack(self) -> list[list[Edit]]: + """A copy of the undo stack, with references to the original Edits.""" + return list(self._undo_stack) + + @property + def redo_stack(self) -> list[list[Edit]]: + """A copy of the redo stack, with references to the original Edits.""" + return list(self._redo_stack) + + def _get_time(self) -> float: + """Get the time from the monotonic clock. + + Returns: + The result of `time.monotonic()` as a float. + """ + return time.monotonic() diff --git a/src/textual/dom.py b/src/textual/dom.py index 293923dd70..bc06272d58 100644 --- a/src/textual/dom.py +++ b/src/textual/dom.py @@ -28,7 +28,7 @@ from rich.text import Text from rich.tree import Tree -from ._context import NoActiveAppError +from ._context import NoActiveAppError, active_message_pump from ._node_list import NodeList from ._types import WatchCallbackType from ._worker_manager import WorkerManager @@ -41,7 +41,7 @@ from .css.styles import RenderStyles, Styles from .css.tokenize import IDENTIFIER from .message_pump import MessagePump -from .reactive import Reactive, _watch +from .reactive import Reactive, ReactiveError, _watch from .timer import Timer from .walk import walk_breadth_first, walk_depth_first @@ -68,6 +68,9 @@ """Valid walking methods for the [`DOMNode.walk_children` method][textual.dom.DOMNode.walk_children].""" +ReactiveType = TypeVar("ReactiveType") + + class BadIdentifier(Exception): """Exception raised if you supply a `id` attribute or class name in the wrong format.""" @@ -194,9 +197,127 @@ def __init__( ) self._has_hover_style: bool = False self._has_focus_within: bool = False + self._reactive_connect: ( + dict[str, tuple[MessagePump, Reactive | object]] | None + ) = None super().__init__() + def set_reactive( + self, reactive: Reactive[ReactiveType], value: ReactiveType + ) -> None: + """Sets a reactive value *without* invoking validators or watchers. + + Example: + ```python + self.set_reactive(App.dark_mode, True) + ``` + + Args: + name: Name of reactive attribute. + value: New value of reactive. + + Raises: + AttributeError: If the first argument is not a reactive. + """ + if not isinstance(reactive, Reactive): + raise TypeError( + "A Reactive class is required; for example: MyApp.dark_mode" + ) + if reactive.name not in self._reactives: + raise AttributeError( + "No reactive called {name!r}; Have you called super().__init__(...) in the {self.__class__.__name__} constructor?" + ) + setattr(self, f"_reactive_{reactive.name}", value) + + def data_bind( + self, + *reactives: Reactive[Any], + **bind_vars: Reactive[Any] | object, + ) -> Self: + """Bind reactive data so that changes to a reactive automatically change the reactive on another widget. + + Reactives may be given as positional arguments or keyword arguments. + See the [guide on data binding](/guide/reactivity#data-binding). + + Example: + ```python + def compose(self) -> ComposeResult: + yield WorldClock("Europe/London").data_bind(WorldClockApp.time) + yield WorldClock("Europe/Paris").data_bind(WorldClockApp.time) + yield WorldClock("Asia/Tokyo").data_bind(WorldClockApp.time) + ``` + + + Raises: + ReactiveError: If the data wasn't bound. + + Returns: + Self. + """ + _rich_traceback_omit = True + + parent = active_message_pump.get() + + if self._reactive_connect is None: + self._reactive_connect = {} + bind_vars = {**{reactive.name: reactive for reactive in reactives}, **bind_vars} + for name, reactive in bind_vars.items(): + if name not in self._reactives: + raise ReactiveError( + f"Unable to bind non-reactive attribute {name!r} on {self}" + ) + if isinstance(reactive, Reactive) and not isinstance( + parent, reactive.owner + ): + raise ReactiveError( + f"Unable to bind data; {reactive.owner.__name__} is not defined on {parent.__class__.__name__}." + ) + self._reactive_connect[name] = (parent, reactive) + self._initialize_data_bind() + return self + + def _initialize_data_bind(self) -> None: + """initialize a data binding. + + Args: + compose_parent: The node doing the binding. + """ + if not self._reactive_connect: + return + for variable_name, (compose_parent, reactive) in self._reactive_connect.items(): + + def make_setter(variable_name: str) -> Callable[[object], None]: + """Make a setter for the given variable name. + + Args: + variable_name: Name of variable being set. + + Returns: + A callable which takes the value to set. + """ + + def setter(value: object) -> None: + """Set bound data.""" + _rich_traceback_omit = True + Reactive._initialize_object(self) + setattr(self, variable_name, value) + + return setter + + assert isinstance(compose_parent, DOMNode) + setter = make_setter(variable_name) + if isinstance(reactive, Reactive): + self.watch( + compose_parent, + reactive.name, + setter, + init=self._parent is not None, + ) + else: + self.call_later(partial(setter, reactive)) + self._reactive_connect = None + def compose_add_child(self, widget: Widget) -> None: """Add a node to children. @@ -1291,3 +1412,14 @@ def has_pseudo_classes(self, class_names: set[str]) -> bool: def refresh(self, *, repaint: bool = True, layout: bool = False) -> Self: return self + + async def action_toggle(self, attribute_name: str) -> None: + """Toggle an attribute on the node. + + Assumes the attribute is a bool. + + Args: + attribute_name: Name of the attribute. + """ + value = getattr(self, attribute_name) + setattr(self, attribute_name, not value) diff --git a/src/textual/drivers/linux_driver.py b/src/textual/drivers/linux_driver.py index 7f33082d34..78628f6ae4 100644 --- a/src/textual/drivers/linux_driver.py +++ b/src/textual/drivers/linux_driver.py @@ -156,28 +156,33 @@ def _stop_again(*_) -> None: """Signal handler that will put the application back to sleep.""" os.kill(os.getpid(), signal.SIGSTOP) - # Set up handlers to ensure that, if there's a SIGTTOU or a SIGTTIN, - # we go back to sleep. - signal.signal(signal.SIGTTOU, _stop_again) - signal.signal(signal.SIGTTIN, _stop_again) - try: - # Here we perform a NOP tcsetattr. The reason for this is that, - # if we're suspended and the user has performed a `bg` in the - # shell, we'll SIGCONT *but* we won't be allowed to do terminal - # output; so rather than get into the business of spinning up - # application mode again and then finding out, we perform a - # no-consequence change and detect the problem right away. - termios.tcsetattr( - self.fileno, termios.TCSANOW, termios.tcgetattr(self.fileno) - ) - except termios.error: - # There was an error doing the tcsetattr; there is no sense in - # carrying on because we'll be doing a SIGSTOP (see above). - return - finally: - # We don't need to be hooking SIGTTOU or SIGTTIN any more. - signal.signal(signal.SIGTTOU, signal.SIG_DFL) - signal.signal(signal.SIGTTIN, signal.SIG_DFL) + # If we're working with an actual tty... + # https://github.com/Textualize/textual/issues/4104 + if os.isatty(self.fileno): + # Set up handlers to ensure that, if there's a SIGTTOU or a SIGTTIN, + # we go back to sleep. + signal.signal(signal.SIGTTOU, _stop_again) + signal.signal(signal.SIGTTIN, _stop_again) + try: + # Here we perform a NOP tcsetattr. The reason for this is + # that, if we're suspended and the user has performed a `bg` + # in the shell, we'll SIGCONT *but* we won't be allowed to + # do terminal output; so rather than get into the business + # of spinning up application mode again and then finding + # out, we perform a no-consequence change and detect the + # problem right away. + termios.tcsetattr( + self.fileno, termios.TCSANOW, termios.tcgetattr(self.fileno) + ) + except termios.error: + # There was an error doing the tcsetattr; there is no sense + # in carrying on because we'll be doing a SIGSTOP (see + # above). + return + finally: + # We don't need to be hooking SIGTTOU or SIGTTIN any more. + signal.signal(signal.SIGTTOU, signal.SIG_DFL) + signal.signal(signal.SIGTTIN, signal.SIG_DFL) loop = asyncio.get_running_loop() diff --git a/src/textual/events.py b/src/textual/events.py index a5518407f9..eb53d4a5db 100644 --- a/src/textual/events.py +++ b/src/textual/events.py @@ -475,7 +475,7 @@ class MouseUp(MouseEvent, bubble=True, verbose=True): @rich.repr.auto -class MouseScrollDown(MouseEvent, bubble=True): +class MouseScrollDown(MouseEvent, bubble=True, verbose=True): """Sent when the mouse wheel is scrolled *down*. - [X] Bubbles @@ -484,7 +484,7 @@ class MouseScrollDown(MouseEvent, bubble=True): @rich.repr.auto -class MouseScrollUp(MouseEvent, bubble=True): +class MouseScrollUp(MouseEvent, bubble=True, verbose=True): """Sent when the mouse wheel is scrolled *up*. - [X] Bubbles diff --git a/src/textual/reactive.py b/src/textual/reactive.py index d361bd0049..ec6703835f 100644 --- a/src/textual/reactive.py +++ b/src/textual/reactive.py @@ -16,13 +16,21 @@ Generic, Type, TypeVar, + cast, + overload, ) import rich.repr from . import events from ._callback import count_parameters -from ._types import MessageTarget, WatchCallbackType +from ._types import ( + MessageTarget, + WatchCallbackBothValuesType, + WatchCallbackNewValueType, + WatchCallbackNoArgsType, + WatchCallbackType, +) if TYPE_CHECKING: from .dom import DOMNode @@ -30,12 +38,54 @@ Reactable = DOMNode ReactiveType = TypeVar("ReactiveType") +ReactableType = TypeVar("ReactableType", bound="DOMNode") + + +class ReactiveError(Exception): + """Base class for reactive errors.""" -class TooManyComputesError(Exception): +class TooManyComputesError(ReactiveError): """Raised when an attribute has public and private compute methods.""" +async def await_watcher(obj: Reactable, awaitable: Awaitable[object]) -> None: + """Coroutine to await an awaitable returned from a watcher""" + _rich_traceback_omit = True + await awaitable + # Watcher may have changed the state, so run compute again + obj.post_message(events.Callback(callback=partial(Reactive._compute, obj))) + + +def invoke_watcher( + watcher_object: Reactable, + watch_function: WatchCallbackType, + old_value: object, + value: object, +) -> None: + """Invoke a watch function. + + Args: + watcher_object: The object watching for the changes. + watch_function: A watch function, which may be sync or async. + old_value: The old value of the attribute. + value: The new value of the attribute. + """ + _rich_traceback_omit = True + param_count = count_parameters(watch_function) + if param_count == 2: + watch_result = cast(WatchCallbackBothValuesType, watch_function)( + old_value, value + ) + elif param_count == 1: + watch_result = cast(WatchCallbackNewValueType, watch_function)(value) + else: + watch_result = cast(WatchCallbackNoArgsType, watch_function)() + if isawaitable(watch_result): + # Result is awaitable, so we need to await it within an async context + watcher_object.call_next(partial(await_watcher, watcher_object, watch_result)) + + @rich.repr.auto class Reactive(Generic[ReactiveType]): """Reactive descriptor. @@ -67,6 +117,7 @@ def __init__( self._init = init self._always_update = always_update self._run_compute = compute + self._owner: Type[MessageTarget] | None = None def __rich_repr__(self) -> rich.repr.Result: yield self._default @@ -76,6 +127,12 @@ def __rich_repr__(self) -> rich.repr.Result: yield "always_update", self._always_update yield "compute", self._run_compute + @property + def owner(self) -> Type[MessageTarget]: + """The owner (class) where the reactive was declared.""" + assert self._owner is not None + return self._owner + def _initialize_reactive(self, obj: Reactable, name: str) -> None: """Initialized a reactive attribute on an object. @@ -126,6 +183,7 @@ def _reset_object(cls, obj: object) -> None: def __set_name__(self, owner: Type[MessageTarget], name: str) -> None: # Check for compute method + self._owner = owner public_compute = f"compute_{name}" private_compute = f"_compute_{name}" compute_name = ( @@ -148,7 +206,29 @@ def __set_name__(self, owner: Type[MessageTarget], name: str) -> None: default = self._default setattr(owner, f"_default_{name}", default) - def __get__(self, obj: Reactable, obj_type: type[object]) -> ReactiveType: + @overload + def __get__( + self: Reactive[ReactiveType], obj: ReactableType, obj_type: type[ReactableType] + ) -> ReactiveType: ... + + @overload + def __get__( + self: Reactive[ReactiveType], obj: None, obj_type: type[ReactableType] + ) -> Reactive[ReactiveType]: ... + + def __get__( + self: Reactive[ReactiveType], + obj: Reactable | None, + obj_type: type[ReactableType], + ) -> Reactive[ReactiveType] | ReactiveType: + _rich_traceback_omit = True + if obj is None: + # obj is None means we are invoking the descriptor via the class, and not the instance + return self + if not hasattr(obj, "id"): + raise ReactiveError( + f"Node is missing data; Check you are calling super().__init__(...) in the {obj.__class__.__name__}() constructor, before getting reactives." + ) internal_name = self.internal_name if not hasattr(obj, internal_name): self._initialize_reactive(obj, self.name) @@ -156,7 +236,6 @@ def __get__(self, obj: Reactable, obj_type: type[object]) -> ReactiveType: if hasattr(obj, self.compute_name): value: ReactiveType old_value = getattr(obj, internal_name) - _rich_traceback_omit = True value = getattr(obj, self.compute_name)() setattr(obj, internal_name, value) self._check_watchers(obj, self.name, old_value) @@ -167,6 +246,11 @@ def __get__(self, obj: Reactable, obj_type: type[object]) -> ReactiveType: def __set__(self, obj: Reactable, value: ReactiveType) -> None: _rich_traceback_omit = True + if not hasattr(obj, "_id"): + raise ReactiveError( + f"Node is missing data; Check you are calling super().__init__(...) in the {obj.__class__.__name__}() constructor, before setting reactives." + ) + self._initialize_reactive(obj, self.name) if hasattr(obj, self.compute_name): @@ -199,7 +283,7 @@ def __set__(self, obj: Reactable, value: ReactiveType) -> None: obj.refresh(repaint=self._repaint, layout=self._layout) @classmethod - def _check_watchers(cls, obj: Reactable, name: str, old_value: Any): + def _check_watchers(cls, obj: Reactable, name: str, old_value: Any) -> None: """Check watchers, and call watch methods / computes Args: @@ -212,39 +296,6 @@ def _check_watchers(cls, obj: Reactable, name: str, old_value: Any): internal_name = f"_reactive_{name}" value = getattr(obj, internal_name) - async def await_watcher(awaitable: Awaitable) -> None: - """Coroutine to await an awaitable returned from a watcher""" - _rich_traceback_omit = True - await awaitable - # Watcher may have changed the state, so run compute again - obj.post_message(events.Callback(callback=partial(Reactive._compute, obj))) - - def invoke_watcher( - watcher_object: Reactable, - watch_function: Callable, - old_value: object, - value: object, - ) -> None: - """Invoke a watch function. - - Args: - watcher_object: The object watching for the changes. - watch_function: A watch function, which may be sync or async. - old_value: The old value of the attribute. - value: The new value of the attribute. - """ - _rich_traceback_omit = True - param_count = count_parameters(watch_function) - if param_count == 2: - watch_result = watch_function(old_value, value) - elif param_count == 1: - watch_result = watch_function(value) - else: - watch_result = watch_function() - if isawaitable(watch_result): - # Result is awaitable, so we need to await it within an async context - watcher_object.call_next(partial(await_watcher, watch_result)) - private_watch_function = getattr(obj, f"_watch_{name}", None) if callable(private_watch_function): invoke_watcher(obj, private_watch_function, old_value, value) @@ -254,14 +305,14 @@ def invoke_watcher( invoke_watcher(obj, public_watch_function, old_value, value) # Process "global" watchers - watchers: list[tuple[Reactable, Callable]] + watchers: list[tuple[Reactable, WatchCallbackType]] watchers = getattr(obj, "__watchers", {}).get(name, []) # Remove any watchers for reactables that have since closed if watchers: watchers[:] = [ (reactable, callback) for reactable, callback in watchers - if reactable.is_attached and not reactable._closing + if not reactable._closing ] for reactable, callback in watchers: with reactable.prevent(*obj._prevent_message_types_stack[-1]): @@ -356,6 +407,7 @@ def _watch( """Watch a reactive variable on an object. Args: + node: The node that created the watcher. obj: The parent object. attribute_name: The attribute to watch. callback: A callable to call when the attribute changes. @@ -363,11 +415,13 @@ def _watch( """ if not hasattr(obj, "__watchers"): setattr(obj, "__watchers", {}) - watchers: dict[str, list[tuple[Reactable, Callable]]] = getattr(obj, "__watchers") + watchers: dict[str, list[tuple[Reactable, WatchCallbackType]]] = getattr( + obj, "__watchers" + ) watcher_list = watchers.setdefault(attribute_name, []) - if callback in watcher_list: + if any(callback == callback_from_list for _, callback_from_list in watcher_list): return - watcher_list.append((node, callback)) if init: current_value = getattr(obj, attribute_name, None) - Reactive._check_watchers(obj, attribute_name, current_value) + invoke_watcher(obj, callback, current_value, current_value) + watcher_list.append((node, callback)) diff --git a/src/textual/renderables/background_screen.py b/src/textual/renderables/background_screen.py index 70ed79f1b1..e0167d05e7 100644 --- a/src/textual/renderables/background_screen.py +++ b/src/textual/renderables/background_screen.py @@ -49,6 +49,7 @@ def process_segments( _Segment = Segment NULL_STYLE = Style() + for segment in segments: text, style, control = segment if control: diff --git a/src/textual/renderables/tint.py b/src/textual/renderables/tint.py index ebdd38105e..358bbca3fc 100644 --- a/src/textual/renderables/tint.py +++ b/src/textual/renderables/tint.py @@ -2,11 +2,13 @@ from typing import Iterable +from rich import terminal_theme from rich.console import Console, ConsoleOptions, RenderableType, RenderResult from rich.segment import Segment from rich.style import Style from ..color import Color +from ..filter import ANSIToTruecolor class Tint: @@ -43,13 +45,15 @@ def process_segments( style_from_color = Style.from_color _Segment = Segment + truecolor_style = ANSIToTruecolor(terminal_theme.DIMMED_MONOKAI).truecolor_style + NULL_STYLE = Style() for segment in segments: text, style, control = segment if control: yield segment else: - style = style or NULL_STYLE + style = truecolor_style(style) if style is not None else NULL_STYLE yield _Segment( text, ( diff --git a/src/textual/scroll_view.py b/src/textual/scroll_view.py index de7b9b3f2e..4e418ed350 100644 --- a/src/textual/scroll_view.py +++ b/src/textual/scroll_view.py @@ -147,6 +147,21 @@ def scroll_to( level=level, ) + def refresh_line(self, y: int) -> None: + """Refresh a single line. + + Args: + y: Coordinate of line. + """ + self.refresh( + Region( + 0, + y - self.scroll_offset.y, + max(self.virtual_size.width, self.size.width), + 1, + ) + ) + def refresh_lines(self, y_start: int, line_count: int = 1) -> None: """Refresh one or more lines. @@ -155,7 +170,10 @@ def refresh_lines(self, y_start: int, line_count: int = 1) -> None: line_count: Total number of lines to refresh. """ - width = self.size.width - scroll_x, scroll_y = self.scroll_offset - refresh_region = Region(scroll_x, y_start - scroll_y, width, line_count) + refresh_region = Region( + 0, + y_start - self.scroll_offset.y, + max(self.virtual_size.width, self.size.width), + line_count, + ) self.refresh(refresh_region) diff --git a/src/textual/types.py b/src/textual/types.py index f06841b93d..95f33db4c2 100644 --- a/src/textual/types.py +++ b/src/textual/types.py @@ -13,6 +13,7 @@ UnusedParameter, WatchCallbackType, ) +from ._widget_navigation import Direction from .actions import ActionParseResult from .css.styles import RenderStyles from .widgets._directory_tree import DirEntry @@ -34,6 +35,7 @@ "CSSPathError", "CSSPathType", "DirEntry", + "Direction", "DuplicateID", "EasingFunction", "IgnoreReturnCallbackType", diff --git a/src/textual/widget.py b/src/textual/widget.py index 13673b0f2f..4a3f6c12fe 100644 --- a/src/textual/widget.py +++ b/src/textual/widget.py @@ -2852,9 +2852,12 @@ def __init_subclass__( ) def __rich_repr__(self) -> rich.repr.Result: - yield "id", self.id, None - if self.name: - yield "name", self.name + try: + yield "id", self.id, None + if self.name: + yield "name", self.name + except AttributeError: + pass def _get_scrollable_region(self, region: Region) -> Region: """Adjusts the Widget region to accommodate scrollbars. @@ -3498,6 +3501,7 @@ async def handle_key(self, event: events.Key) -> bool: return await self.dispatch_key(event) async def _on_compose(self, event: events.Compose) -> None: + _rich_traceback_omit = True event.prevent_default() try: widgets = [*self._pending_children, *compose(self)] diff --git a/src/textual/widgets/_data_table.py b/src/textual/widgets/_data_table.py index d8b694797d..245af7938f 100644 --- a/src/textual/widgets/_data_table.py +++ b/src/textual/widgets/_data_table.py @@ -606,7 +606,7 @@ def __init__( classes: str | None = None, disabled: bool = False, ) -> None: - """Initialises a widget to display tabular data. + """Initializes a widget to display tabular data. Args: show_header: Whether the table header should be visible or not. @@ -662,7 +662,7 @@ def __init__( RowCacheKey, tuple[SegmentLines, SegmentLines] ] = LRUCache(1000) """For each row (a row can have a height of multiple lines), we maintain a - cache of the fixed and scrollable lines within that row to minimise how often + cache of the fixed and scrollable lines within that row to minimize how often we need to re-render it. """ self._cell_render_cache: LRUCache[CellCacheKey, SegmentLines] = LRUCache(10000) """Cache for individual cells.""" diff --git a/src/textual/widgets/_directory_tree.py b/src/textual/widgets/_directory_tree.py index 3af6b4841f..fbddcb75d9 100644 --- a/src/textual/widgets/_directory_tree.py +++ b/src/textual/widgets/_directory_tree.py @@ -5,20 +5,19 @@ from pathlib import Path from typing import TYPE_CHECKING, Callable, ClassVar, Iterable, Iterator -from ..await_complete import AwaitComplete - -if TYPE_CHECKING: - from typing_extensions import Self - from rich.style import Style from rich.text import Text, TextType from .. import work +from ..await_complete import AwaitComplete from ..message import Message from ..reactive import var from ..worker import Worker, WorkerCancelled, WorkerFailed, get_current_worker from ._tree import TOGGLE_STYLE, Tree, TreeNode +if TYPE_CHECKING: + from typing_extensions import Self + @dataclass class DirEntry: @@ -164,7 +163,7 @@ def _add_to_load_queue(self, node: TreeNode[DirEntry]) -> AwaitComplete: Returns: An optionally awaitable object that can be awaited until the - load queue has finished processing. + load queue has finished processing. """ assert node.data is not None if not node.data.loaded: @@ -174,16 +173,18 @@ def _add_to_load_queue(self, node: TreeNode[DirEntry]) -> AwaitComplete: return AwaitComplete(self._load_queue.join()) def reload(self) -> AwaitComplete: - """Reload the `DirectoryTree` contents.""" - self.reset(str(self.path), DirEntry(self.PATH(self.path))) + """Reload the `DirectoryTree` contents. + + Returns: + An optionally awaitable that ensures the tree has finished reloading. + """ # Orphan the old queue... self._load_queue = Queue() + # ... reset the root node ... + processed = self.reload_node(self.root) # ...and replace the old load with a new one. self._loader() - # We have a fresh queue, we have a fresh loader, get the fresh root - # loading up. - queue_processed = self._add_to_load_queue(self.root) - return queue_processed + return processed def clear_node(self, node: TreeNode[DirEntry]) -> Self: """Clear all nodes under the given node. @@ -192,17 +193,7 @@ def clear_node(self, node: TreeNode[DirEntry]) -> Self: The `Tree` instance. """ self._clear_line_cache() - node_label = node._label - node_data = node.data - node_parent = node.parent - node = TreeNode( - self, - node_parent, - self._new_id(), - node_label, - node_data, - expanded=True, - ) + node.remove_children() self._updates += 1 self.refresh() return self @@ -225,6 +216,86 @@ def reset_node( node.data = data return self + async def _reload(self, node: TreeNode[DirEntry]) -> None: + """Reloads the subtree rooted at the given node while preserving state. + + After reloading the subtree, nodes that were expanded and still exist + will remain expanded and the highlighted node will be preserved, if it + still exists. If it doesn't, highlighting goes up to the first parent + directory that still exists. + + Args: + node: The root of the subtree to reload. + """ + async with self.lock: + # Track nodes that were expanded before reloading. + currently_open: set[Path] = set() + to_check: list[TreeNode[DirEntry]] = [node] + while to_check: + checking = to_check.pop() + if checking.allow_expand and checking.is_expanded: + if checking.data: + currently_open.add(checking.data.path) + to_check.extend(checking.children) + + # Track node that was highlighted before reloading. + highlighted_path: None | Path = None + if self.cursor_line > -1: + highlighted_node = self.get_node_at_line(self.cursor_line) + if highlighted_node is not None and highlighted_node.data is not None: + highlighted_path = highlighted_node.data.path + + if node.data is not None: + self.reset_node( + node, str(node.data.path.name), DirEntry(self.PATH(node.data.path)) + ) + + # Reopen nodes that were expanded and still exist. + to_reopen = [node] + while to_reopen: + reopening = to_reopen.pop() + if not reopening.data: + continue + if reopening.allow_expand and ( + reopening.data.path in currently_open or reopening == node + ): + try: + content = await self._load_directory(reopening).wait() + except (WorkerCancelled, WorkerFailed): + continue + reopening.data.loaded = True + self._populate_node(reopening, content) + to_reopen.extend(reopening.children) + reopening.expand() + + if highlighted_path is None: + return + + # Restore the highlighted path and consider the parents as fallbacks. + looking = [node] + highlight_candidates = set(highlighted_path.parents) + highlight_candidates.add(highlighted_path) + best_found: None | TreeNode[DirEntry] = None + while looking: + checking = looking.pop() + checking_path = ( + checking.data.path if checking.data is not None else None + ) + if checking_path in highlight_candidates: + best_found = checking + if checking_path == highlighted_path: + break + if ( + checking.allow_expand + and checking.is_expanded + and checking_path in highlighted_path.parents + ): + looking.extend(checking.children) + if best_found is not None: + # We need valid lines. Make sure the tree lines have been computed: + _ = self._tree_lines + self.cursor_line = best_found.line + def reload_node(self, node: TreeNode[DirEntry]) -> AwaitComplete: """Reload the given node's contents. @@ -233,12 +304,12 @@ def reload_node(self, node: TreeNode[DirEntry]) -> AwaitComplete: or any other nodes). Args: - node: The node to reload. + node: The root of the subtree to reload. + + Returns: + An optionally awaitable that ensures the subtree has finished reloading. """ - self.reset_node( - node, str(node.data.path.name), DirEntry(self.PATH(node.data.path)) - ) - return self._add_to_load_queue(node) + return AwaitComplete(self._reload(node)) def validate_path(self, path: str | Path) -> Path: """Ensure that the path is of the `Path` type. @@ -398,7 +469,7 @@ def _directory_content(self, location: Path, worker: Worker) -> Iterator[Path]: except PermissionError: pass - @work(thread=True) + @work(thread=True, exit_on_error=False) def _load_directory(self, node: TreeNode[DirEntry]) -> list[Path]: """Load the directory contents for a given node. @@ -425,28 +496,29 @@ async def _loader(self) -> None: # this blocks if the queue is empty. node = await self._load_queue.get() content: list[Path] = [] - try: - # Spin up a short-lived thread that will load the content of - # the directory associated with that node. - content = await self._load_directory(node).wait() - except WorkerCancelled: - # The worker was cancelled, that would suggest we're all - # done here and we should get out of the loader in general. - break - except WorkerFailed: - # This particular worker failed to start. We don't know the - # reason so let's no-op that (for now anyway). - pass - else: - # We're still here and we have directory content, get it into - # the tree. - if content: - self._populate_node(node, content) - finally: - # Mark this iteration as done. - self._load_queue.task_done() - - async def _on_tree_node_expanded(self, event: Tree.NodeExpanded) -> None: + async with self.lock: + try: + # Spin up a short-lived thread that will load the content of + # the directory associated with that node. + content = await self._load_directory(node).wait() + except WorkerCancelled: + # The worker was cancelled, that would suggest we're all + # done here and we should get out of the loader in general. + break + except WorkerFailed: + # This particular worker failed to start. We don't know the + # reason so let's no-op that (for now anyway). + pass + else: + # We're still here and we have directory content, get it into + # the tree. + if content: + self._populate_node(node, content) + finally: + # Mark this iteration as done. + self._load_queue.task_done() + + async def _on_tree_node_expanded(self, event: Tree.NodeExpanded[DirEntry]) -> None: event.stop() dir_entry = event.node.data if dir_entry is None: @@ -456,7 +528,7 @@ async def _on_tree_node_expanded(self, event: Tree.NodeExpanded) -> None: else: self.post_message(self.FileSelected(event.node, dir_entry.path)) - def _on_tree_node_selected(self, event: Tree.NodeSelected) -> None: + def _on_tree_node_selected(self, event: Tree.NodeSelected[DirEntry]) -> None: event.stop() dir_entry = event.node.data if dir_entry is None: diff --git a/src/textual/widgets/_list_item.py b/src/textual/widgets/_list_item.py index e87b8cf4fc..e450767c77 100644 --- a/src/textual/widgets/_list_item.py +++ b/src/textual/widgets/_list_item.py @@ -25,6 +25,9 @@ class ListItem(Widget, can_focus=False): background: $panel-lighten-1; overflow: hidden hidden; } + ListItem > :disabled { + background: $panel-darken-1; + } ListItem > Widget :hover { background: $boost; } diff --git a/src/textual/widgets/_list_view.py b/src/textual/widgets/_list_view.py index b102a17d20..6a1788feec 100644 --- a/src/textual/widgets/_list_view.py +++ b/src/textual/widgets/_list_view.py @@ -4,15 +4,15 @@ from typing_extensions import TypeGuard -from textual.await_remove import AwaitRemove -from textual.binding import Binding, BindingType -from textual.containers import VerticalScroll -from textual.events import Mount -from textual.geometry import clamp -from textual.message import Message -from textual.reactive import reactive -from textual.widget import AwaitMount, Widget -from textual.widgets._list_item import ListItem +from .. import _widget_navigation +from ..await_remove import AwaitRemove +from ..binding import Binding, BindingType +from ..containers import VerticalScroll +from ..events import Mount +from ..message import Message +from ..reactive import reactive +from ..widget import AwaitMount +from ..widgets._list_item import ListItem class ListView(VerticalScroll, can_focus=True, can_focus_children=False): @@ -38,7 +38,7 @@ class ListView(VerticalScroll, can_focus=True, can_focus_children=False): | down | Move the cursor down. | """ - index = reactive[Optional[int]](0, always_update=True) + index = reactive[Optional[int]](0, always_update=True, init=False) """The index of the currently highlighted item.""" class Highlighted(Message): @@ -117,7 +117,12 @@ def __init__( super().__init__( *children, name=name, id=id, classes=classes, disabled=disabled ) - self._index = initial_index + # Set the index to the given initial index, or the first available index after. + self._index = _widget_navigation.find_next_enabled( + self._nodes, + anchor=initial_index - 1 if initial_index is not None else None, + direction=1, + ) def _on_mount(self, _: Mount) -> None: """Ensure the ListView is fully-settled after mounting.""" @@ -142,17 +147,17 @@ def validate_index(self, index: int | None) -> int | None: Returns: The clamped index. """ - if not self._nodes or index is None: + if index is None or not self._nodes: return None - return self._clamp_index(index) + elif index < 0: + return 0 + elif index >= len(self._nodes): + return len(self._nodes) - 1 - def _clamp_index(self, index: int) -> int: - """Clamp the index to a valid value given the current list of children""" - last_index = max(len(self._nodes) - 1, 0) - return clamp(index, 0, last_index) + return index def _is_valid_index(self, index: int | None) -> TypeGuard[int]: - """Return True if the current index is valid given the current list of children""" + """Determine whether the current index is valid into the list of children.""" if index is None: return False return 0 <= index < len(self._nodes) @@ -164,16 +169,14 @@ def watch_index(self, old_index: int | None, new_index: int | None) -> None: assert isinstance(old_child, ListItem) old_child.highlighted = False - new_child: Widget | None - if self._is_valid_index(new_index): + if self._is_valid_index(new_index) and not self._nodes[new_index].disabled: new_child = self._nodes[new_index] assert isinstance(new_child, ListItem) new_child.highlighted = True + self._scroll_highlighted_region() + self.post_message(self.Highlighted(self, new_child)) else: - new_child = None - - self._scroll_highlighted_region() - self.post_message(self.Highlighted(self, new_child)) + self.post_message(self.Highlighted(self, None)) def extend(self, items: Iterable[ListItem]) -> AwaitMount: """Append multiple new ListItems to the end of the ListView. @@ -222,19 +225,30 @@ def action_select_cursor(self) -> None: def action_cursor_down(self) -> None: """Highlight the next item in the list.""" - if self.index is None: - self.index = 0 - return - self.index += 1 + candidate = _widget_navigation.find_next_enabled( + self._nodes, + anchor=self.index, + direction=1, + ) + if self.index is not None and candidate is not None and candidate < self.index: + return # Avoid wrapping around. + + self.index = candidate def action_cursor_up(self) -> None: """Highlight the previous item in the list.""" - if self.index is None: - self.index = 0 - return - self.index -= 1 + candidate = _widget_navigation.find_next_enabled( + self._nodes, + anchor=self.index, + direction=-1, + ) + if self.index is not None and candidate is not None and candidate > self.index: + return # Avoid wrapping around. + + self.index = candidate def _on_list_item__child_clicked(self, event: ListItem._ChildClicked) -> None: + event.stop() self.focus() self.index = self._nodes.index(event.item) self.post_message(self.Selected(self, event.item)) @@ -244,5 +258,6 @@ def _scroll_highlighted_region(self) -> None: if self.highlighted_child is not None: self.scroll_to_widget(self.highlighted_child, animate=False) - def __len__(self): + def __len__(self) -> int: + """Compute the length (in number of items) of the list view.""" return len(self._nodes) diff --git a/src/textual/widgets/_log.py b/src/textual/widgets/_log.py index df5b645c4f..12eea8bc10 100644 --- a/src/textual/widgets/_log.py +++ b/src/textual/widgets/_log.py @@ -61,6 +61,7 @@ def __init__( classes: The CSS classes of the text log. disabled: Whether the text log is disabled or not. """ + super().__init__(name=name, id=id, classes=classes, disabled=disabled) self.highlight = highlight """Enable highlighting.""" self.max_lines = max_lines @@ -71,7 +72,6 @@ def __init__( self._render_line_cache: LRUCache[int, Strip] = LRUCache(1024) self.highlighter = ReprHighlighter() """The Rich Highlighter object to use, if `highlight=True`""" - super().__init__(name=name, id=id, classes=classes, disabled=disabled) @property def lines(self) -> Sequence[str]: diff --git a/src/textual/widgets/_markdown.py b/src/textual/widgets/_markdown.py index f8ac0fdaff..24cde3dd91 100644 --- a/src/textual/widgets/_markdown.py +++ b/src/textual/widgets/_markdown.py @@ -7,6 +7,7 @@ from markdown_it.token import Token from rich import box from rich.style import Style +from rich.syntax import Syntax from rich.table import Table from rich.text import Text from typing_extensions import TypeAlias @@ -498,22 +499,41 @@ class MarkdownFence(MarkdownBlock): """ def __init__(self, markdown: Markdown, code: str, lexer: str) -> None: + super().__init__(markdown) self.code = code self.lexer = lexer - super().__init__(markdown) + self.theme = ( + self._markdown.code_dark_theme + if self.app.dark + else self._markdown.code_light_theme + ) - def compose(self) -> ComposeResult: - from rich.syntax import Syntax + def _block(self) -> Syntax: + return Syntax( + self.code, + lexer=self.lexer, + word_wrap=False, + indent_guides=True, + padding=(1, 2), + theme=self.theme, + ) + + def _on_mount(self, _: Mount) -> None: + """Watch app theme switching.""" + self.watch(self.app, "dark", self._retheme) + + def _retheme(self) -> None: + """Rerender when the theme changes.""" + self.theme = ( + self._markdown.code_dark_theme + if self.app.dark + else self._markdown.code_light_theme + ) + self.get_child_by_type(Static).update(self._block()) + def compose(self) -> ComposeResult: yield Static( - Syntax( - self.code, - lexer=self.lexer, - word_wrap=False, - indent_guides=True, - padding=(1, 2), - theme="material", - ), + self._block(), expand=True, shrink=False, ) @@ -567,6 +587,12 @@ class Markdown(Widget): BULLETS = ["\u25CF ", "▪ ", "‣ ", "• ", "⭑ "] + code_dark_theme: reactive[str] = reactive("material") + """The theme to use for code blocks when in [dark mode][textual.app.App.dark].""" + + code_light_theme: reactive[str] = reactive("material-light") + """The theme to use for code blocks when in [light mode][textual.app.App.dark].""" + def __init__( self, markdown: str | None = None, @@ -653,6 +679,18 @@ def _on_mount(self, _: Mount) -> None: if self._markdown is not None: self.update(self._markdown) + def _watch_code_dark_theme(self) -> None: + """React to the dark theme being changed.""" + if self.app.dark: + for block in self.query(MarkdownFence): + block._retheme() + + def _watch_code_light_theme(self) -> None: + """React to the light theme being changed.""" + if not self.app.dark: + for block in self.query(MarkdownFence): + block._retheme() + @staticmethod def sanitize_location(location: str) -> tuple[Path, str]: """Given a location, break out the path and any anchor. @@ -858,11 +896,7 @@ def update(self, markdown: str) -> AwaitComplete: stack[-1].set_content(content) elif token.type in ("fence", "code_block"): (stack[-1]._blocks if stack else output).append( - MarkdownFence( - self, - token.content.rstrip(), - token.info, - ) + MarkdownFence(self, token.content.rstrip(), token.info) ) else: external = self.unhandled_token(token) diff --git a/src/textual/widgets/_option_list.py b/src/textual/widgets/_option_list.py index 59e2adf921..c59320afda 100644 --- a/src/textual/widgets/_option_list.py +++ b/src/textual/widgets/_option_list.py @@ -14,8 +14,10 @@ from rich.repr import Result from rich.rule import Rule from rich.style import Style -from typing_extensions import Literal, Self, TypeAlias +from typing_extensions import Self, TypeAlias +from .. import _widget_navigation +from .._widget_navigation import Direction from ..binding import Binding, BindingType from ..events import Click, Idle, Leave, MouseMove from ..geometry import Region, Size @@ -158,11 +160,8 @@ class OptionList(ScrollView, can_focus=True): "option-list--option", "option-list--option-disabled", "option-list--option-highlighted", - "option-list--option-highlighted-disabled", "option-list--option-hover", - "option-list--option-hover-disabled", "option-list--option-hover-highlighted", - "option-list--option-hover-highlighted-disabled", "option-list--separator", } """ @@ -170,17 +169,15 @@ class OptionList(ScrollView, can_focus=True): | :- | :- | | `option-list--option-disabled` | Target disabled options. | | `option-list--option-highlighted` | Target the highlighted option. | - | `option-list--option-highlighted-disabled` | Target a disabled option that is also highlighted. | | `option-list--option-hover` | Target an option that has the mouse over it. | - | `option-list--option-hover-disabled` | Target a disabled option that has the mouse over it. | | `option-list--option-hover-highlighted` | Target a highlighted option that has the mouse over it. | - | `option-list--option-hover-highlighted-disabled` | Target a disabled highlighted option that has the mouse over it. | | `option-list--separator` | Target the separators. | """ DEFAULT_CSS = """ OptionList { height: auto; + max-height: 100%; background: $boost; color: $text; overflow-x: hidden; @@ -210,24 +207,10 @@ class OptionList(ScrollView, can_focus=True): color: $text-disabled; } - OptionList > .option-list--option-highlighted-disabled { - color: $text-disabled; - background: $accent 20%; - } - - OptionList:focus > .option-list--option-highlighted-disabled { - background: $accent 30%; - } - OptionList > .option-list--option-hover { background: $boost; } - OptionList > .option-list--option-hover-disabled { - color: $text-disabled; - background: $boost; - } - OptionList > .option-list--option-hover-highlighted { background: $accent 60%; color: $text; @@ -239,11 +222,6 @@ class OptionList(ScrollView, can_focus=True): color: $text; text-style: bold; } - - OptionList > .option-list--option-hover-highlighted-disabled { - color: $text-disabled; - background: $accent 60%; - } """ highlighted: reactive[int | None] = reactive["int | None"](None) @@ -380,8 +358,7 @@ def __init__( # Finally, cause the highlighted property to settle down based on # the state of the option list in regard to its available options. - # Be sure to have a look at validate_highlighted. - self.highlighted = None + self.action_first() def _request_content_tracking_refresh( self, rescroll_to_highlight: bool = False @@ -437,8 +414,8 @@ async def _on_click(self, event: Click) -> None: Args: event: The click event. """ - clicked_option = event.style.meta.get("option") - if clicked_option is not None: + clicked_option: int | None = event.style.meta.get("option") + if clicked_option is not None and not self._options[clicked_option].disabled: self.highlighted = clicked_option self.action_select() @@ -597,15 +574,16 @@ def add_options(self, items: Iterable[NewOptionListContent]) -> Self: content = [self._make_content(item) for item in items] self._duplicate_id_check(content) self._contents.extend(content) - # Pull out the content that is genuine options. Add them to the - # list of options and map option IDs to their new indices. + # Pull out the content that is genuine options, create any new + # ID mappings required, then add the new options to the option + # list. new_options = [item for item in content if isinstance(item, Option)] - self._options.extend(new_options) for new_option_index, new_option in enumerate( new_options, start=len(self._options) ): if new_option.id: self._option_ids[new_option.id] = new_option_index + self._options.extend(new_options) self._refresh_content_tracking(force=True) self.refresh() @@ -680,7 +658,7 @@ def remove_option_at_index(self, index: int) -> Self: self._remove_option(index) except IndexError: raise OptionDoesNotExist( - f"There is no option with an index of {index}" + f"There is no option with an index of {index!r}" ) from None return self @@ -758,6 +736,10 @@ def _set_option_disabled(self, index: int, disabled: bool) -> Self: The `OptionList` instance. """ self._options[index].disabled = disabled + if index == self.highlighted: + self.highlighted = _widget_navigation.find_next_enabled( + self._options, anchor=index, direction=1 + ) # TODO: Refresh only if the affected option is visible. self.refresh() return self @@ -927,21 +909,6 @@ def render_line(self, y: int) -> Strip: # Handle drawing a disabled option. if self._options[option_index].disabled: - # Disabled but the highlight? - if option_index == highlighted: - return strip.apply_style( - self.get_component_rich_style( - "option-list--option-hover-highlighted-disabled" - if option_index == mouse_over - else "option-list--option-highlighted-disabled" - ) - ) - # Disabled but mouse hover? - if option_index == mouse_over: - return strip.apply_style( - self.get_component_rich_style("option-list--option-hover-disabled") - ) - # Just a normal disabled option. return strip.apply_style( self.get_component_rich_style("option-list--option-disabled") ) @@ -997,51 +964,53 @@ def scroll_to_highlight(self, top: bool = False) -> None: def validate_highlighted(self, highlighted: int | None) -> int | None: """Validate the `highlighted` property value on access.""" - if not self._options: + if highlighted is None or not self._options: return None - if highlighted is None or highlighted < 0: + elif highlighted < 0: return 0 - return min(highlighted, len(self._options) - 1) + elif highlighted >= len(self._options): + return len(self._options) - 1 + + return highlighted def watch_highlighted(self, highlighted: int | None) -> None: """React to the highlighted option having changed.""" - if highlighted is not None: + if highlighted is not None and not self._options[highlighted].disabled: self.scroll_to_highlight() - if not self._options[highlighted].disabled: - self.post_message(self.OptionHighlighted(self, highlighted)) + self.post_message(self.OptionHighlighted(self, highlighted)) def action_cursor_up(self) -> None: - """Move the highlight up by one option.""" - if self.highlighted is not None: - if self.highlighted > 0: - self.highlighted -= 1 - else: - self.highlighted = len(self._options) - 1 - elif self._options: - self.action_first() + """Move the highlight up to the previous enabled option.""" + self.highlighted = _widget_navigation.find_next_enabled( + self._options, + anchor=self.highlighted, + direction=-1, + ) def action_cursor_down(self) -> None: - """Move the highlight down by one option.""" - if self.highlighted is not None: - if self.highlighted < len(self._options) - 1: - self.highlighted += 1 - else: - self.highlighted = 0 - elif self._options: - self.action_first() + """Move the highlight down to the next enabled option.""" + self.highlighted = _widget_navigation.find_next_enabled( + self._options, + anchor=self.highlighted, + direction=1, + ) def action_first(self) -> None: - """Move the highlight to the first option.""" - if self._options: - self.highlighted = 0 + """Move the highlight to the first enabled option.""" + self.highlighted = _widget_navigation.find_first_enabled(self._options) def action_last(self) -> None: - """Move the highlight to the last option.""" - if self._options: - self.highlighted = len(self._options) - 1 + """Move the highlight to the last enabled option.""" + self.highlighted = _widget_navigation.find_last_enabled(self._options) - def _page(self, direction: Literal[-1, 1]) -> None: - """Move the highlight by one page. + def _page(self, direction: Direction) -> None: + """Move the highlight roughly by one page in the given direction. + + The highlight will tentatively move by exactly one page. + If this would result in highlighting a disabled option, instead we look for + an enabled option "further down" the list of options. + If there are no such enabled options, we fallback to the "last" enabled option. + (The meaning of "further down" and "last" depend on the direction specified.) Args: direction: The direction to head, -1 for up and 1 for down. @@ -1071,19 +1040,33 @@ def _page(self, direction: Literal[-1, 1]) -> None: target_option = self._lines[target_line].option_index except IndexError: # An index error suggests we've gone out of bounds, let's - # settle on whatever the call things is a good place to wrap + # settle on whatever the call thinks is a good place to wrap # to. fallback() else: - # Looks like we've figured out the next option to jump to. - self.highlighted = target_option + # Looks like we've figured where we'd like to jump to, we + # just need to make sure we jump to an option that's enabled. + if target_option is not None: + target_option = _widget_navigation.find_next_enabled_no_wrap( + candidates=self._options, + anchor=target_option, + direction=direction, + with_anchor=True, + ) + # If we couldn't find an enabled option that's at least one page + # away from the current one, we instead move less than one page + # to the last enabled option in the correct direction. + if target_option is None: + fallback() + else: + self.highlighted = target_option def action_page_up(self) -> None: - """Move the highlight up one page.""" + """Move the highlight up roughly by one page.""" self._page(-1) def action_page_down(self) -> None: - """Move the highlight down one page.""" + """Move the highlight down roughly by one page.""" self._page(1) def action_select(self) -> None: diff --git a/src/textual/widgets/_radio_set.py b/src/textual/widgets/_radio_set.py index 8223e69713..2946eabf4a 100644 --- a/src/textual/widgets/_radio_set.py +++ b/src/textual/widgets/_radio_set.py @@ -2,11 +2,11 @@ from __future__ import annotations -from contextlib import suppress -from typing import ClassVar, Literal, Optional +from typing import ClassVar, Optional import rich.repr +from .. import _widget_navigation from ..binding import Binding, BindingType from ..containers import Container from ..events import Click, Mount @@ -248,59 +248,23 @@ def action_previous_button(self) -> None: Note that this will wrap around to the end if at the start. """ - self._move_selected_button(-1) + self._selected = _widget_navigation.find_next_enabled( + self.children, + anchor=self._selected, + direction=-1, + ) def action_next_button(self) -> None: """Navigate to the next button in the set. Note that this will wrap around to the start if at the end. """ - self._move_selected_button(1) - - def _move_selected_button(self, direction: Literal[-1, 1]) -> None: - """Move the selected button to the next or previous one. - - Note that this will wrap around the start/end of the button list. - - We compute the available buttons by ignoring the disabled ones and then - we induce an ordering by computing the distance to the currently selected one if - we start at the selected button and then start moving in the direction indicated. - - For example, if the direction is `1` and self._selected is 2, we have this: - selected: v - buttons: X X X X X X X - indices: 0 1 2 3 4 5 6 - distance: 5 6 0 1 2 3 4 - - Args: - direction: `1` to move to the next button and `-1` for the previous. - """ - - candidate_indices = ( - index - for index, button in enumerate(self.children) - if not button.disabled and index != self._selected + self._selected = _widget_navigation.find_next_enabled( + self.children, + anchor=self._selected, + direction=1, ) - if self._selected is None: - with suppress(StopIteration): - self._selected = next(candidate_indices) - else: - selected = self._selected - - def distance(index: int) -> int: - """Induce a distance between the given index and the selected button. - - Args: - index: The index of the button to consider. - - Returns: - The distance between the two buttons. - """ - return direction * (index - selected) % len(self.children) - - self._selected = min(candidate_indices, key=distance, default=selected) - def action_toggle(self) -> None: """Toggle the state of the currently-selected button.""" if self._selected is not None: diff --git a/src/textual/widgets/_select.py b/src/textual/widgets/_select.py index 93d82379b1..b582f3edea 100644 --- a/src/textual/widgets/_select.py +++ b/src/textual/widgets/_select.py @@ -523,6 +523,10 @@ def action_show_overlay(self) -> None: select_current = self.query_one(SelectCurrent) select_current.has_value = True self.expanded = True + # If we haven't opened the overlay yet, highlight the first option. + select_overlay = self.query_one(SelectOverlay) + if select_overlay.highlighted is None: + select_overlay.action_first() def is_blank(self) -> bool: """Indicates whether this `Select` is blank or not. diff --git a/src/textual/widgets/_selection_list.py b/src/textual/widgets/_selection_list.py index bf5b4866d3..45b311deb4 100644 --- a/src/textual/widgets/_selection_list.py +++ b/src/textual/widgets/_selection_list.py @@ -157,7 +157,9 @@ class SelectionList(Generic[SelectionType], OptionList): class SelectionMessage(Generic[MessageSelectionType], Message): """Base class for all selection messages.""" - def __init__(self, selection_list: SelectionList, index: int) -> None: + def __init__( + self, selection_list: SelectionList[MessageSelectionType], index: int + ) -> None: """Initialise the selection message. Args: @@ -189,14 +191,14 @@ def __rich_repr__(self) -> Result: yield "selection", self.selection yield "selection_index", self.selection_index - class SelectionHighlighted(SelectionMessage): + class SelectionHighlighted(SelectionMessage[MessageSelectionType]): """Message sent when a selection is highlighted. Can be handled using `on_selection_list_selection_highlighted` in a subclass of [`SelectionList`][textual.widgets.SelectionList] or in a parent node in the DOM. """ - class SelectionToggled(SelectionMessage): + class SelectionToggled(SelectionMessage[MessageSelectionType]): """Message sent when a selection is toggled. Can be handled using `on_selection_list_selection_toggled` in a subclass of @@ -229,7 +231,7 @@ def control(self) -> SelectionList[MessageSelectionType]: def __init__( self, - *selections: Selection + *selections: Selection[SelectionType] | tuple[TextType, SelectionType] | tuple[TextType, SelectionType, bool], name: str | None = None, @@ -308,7 +310,10 @@ def _apply_to_all(self, state_change: Callable[[SelectionType], bool]) -> Self: # expecting a message storm from this. with self.prevent(self.SelectedChanged): for selection in self._options: - changed = state_change(cast(Selection, selection).value) or changed + changed = ( + state_change(cast(Selection[SelectionType], selection).value) + or changed + ) # If the above did make a change, *then* send a message. if changed: @@ -441,7 +446,7 @@ def toggle_all(self) -> Self: def _make_selection( self, selection: ( - Selection + Selection[SelectionType] | tuple[TextType, SelectionType] | tuple[TextType, SelectionType, bool] ), @@ -634,7 +639,7 @@ def add_options( self, items: Iterable[ NewOptionListContent - | Selection + | Selection[SelectionType] | tuple[TextType, SelectionType] | tuple[TextType, SelectionType, bool] ], @@ -657,7 +662,7 @@ def add_options( # extend the types of accepted items to keep mypy and friends happy, # but then we runtime check that we've been given sensible types (in # this case the supported tuple values). - cleaned_options: list[Selection] = [] + cleaned_options: list[Selection[SelectionType]] = [] for item in items: if isinstance(item, tuple): cleaned_options.append( diff --git a/src/textual/widgets/_switch.py b/src/textual/widgets/_switch.py index 12d5ebb7d0..96cce3eeb8 100644 --- a/src/textual/widgets/_switch.py +++ b/src/textual/widgets/_switch.py @@ -74,7 +74,7 @@ class Switch(Widget, can_focus=True): } """ - value = reactive(False, init=False) + value: reactive[bool] = reactive(False, init=False) """The value of the switch; `True` for on and `False` for off.""" slider_pos = reactive(0.0) @@ -124,7 +124,7 @@ def __init__( super().__init__(name=name, id=id, classes=classes, disabled=disabled) if value: self.slider_pos = 1.0 - self._reactive_value = value + self.set_reactive(Switch.value, value) self._should_animate = animate def watch_value(self, value: bool) -> None: diff --git a/src/textual/widgets/_tabbed_content.py b/src/textual/widgets/_tabbed_content.py index 5f05064b16..0edced54b2 100644 --- a/src/textual/widgets/_tabbed_content.py +++ b/src/textual/widgets/_tabbed_content.py @@ -324,6 +324,9 @@ def __init__( @property def active_pane(self) -> TabPane | None: """The currently active pane, or `None` if no pane is active.""" + active = self.active + if not active: + return None return self.get_pane(self.active) def validate_active(self, active: str) -> str: @@ -508,13 +511,22 @@ def _on_tabs_tab_activated(self, event: Tabs.TabActivated) -> None: if self._is_associated_tabs(event.tabs): # The message is relevant, so consume it and update state accordingly. event.stop() + assert event.tab.id is not None switcher = self.get_child_by_type(ContentSwitcher) switcher.current = ContentTab.sans_prefix(event.tab.id) - self.active = ContentTab.sans_prefix(event.tab.id) + with self.prevent(self.TabActivated): + # We prevent TabbedContent.TabActivated because it is also + # posted from the watcher for active, we're also about to + # post it below too, which is valid as here we're reacting + # to what the Tabs are doing. This ensures we don't get + # doubled-up messages. + self.active = ContentTab.sans_prefix(event.tab.id) self.post_message( TabbedContent.TabActivated( tabbed_content=self, - tab=event.tab, + tab=self.get_child_by_type(ContentTabs).get_content_tab( + self.active + ), ) ) @@ -545,7 +557,14 @@ def _watch_active(self, active: str) -> None: """Switch tabs when the active attributes changes.""" with self.prevent(Tabs.TabActivated): self.get_child_by_type(ContentTabs).active = ContentTab.add_prefix(active) - self.get_child_by_type(ContentSwitcher).current = active + self.get_child_by_type(ContentSwitcher).current = active + if active: + self.post_message( + TabbedContent.TabActivated( + tabbed_content=self, + tab=self.get_child_by_type(ContentTabs).get_content_tab(active), + ) + ) @property def tab_count(self) -> int: diff --git a/src/textual/widgets/_tabs.py b/src/textual/widgets/_tabs.py index 2bdf4b4d86..d67a30968d 100644 --- a/src/textual/widgets/_tabs.py +++ b/src/textual/widgets/_tabs.py @@ -583,6 +583,7 @@ def watch_active(self, previously_active: str, active: str) -> None: self.query("#tabs-list > Tab.-active").remove_class("-active") active_tab.add_class("-active") self._highlight_active(animate=previously_active != "") + self._scroll_active_tab() self.post_message(self.TabActivated(self, active_tab)) else: underline = self.query_one(Underline) diff --git a/src/textual/widgets/_text_area.py b/src/textual/widgets/_text_area.py index 0f98234814..f700a742f3 100644 --- a/src/textual/widgets/_text_area.py +++ b/src/textual/widgets/_text_area.py @@ -3,14 +3,14 @@ import dataclasses import re from collections import defaultdict -from dataclasses import dataclass, field +from dataclasses import dataclass from functools import lru_cache from pathlib import Path -from typing import TYPE_CHECKING, Any, ClassVar, Iterable, Optional, Tuple +from typing import TYPE_CHECKING, ClassVar, Iterable, Optional, Sequence, Tuple from rich.style import Style from rich.text import Text -from typing_extensions import Literal, Protocol, runtime_checkable +from typing_extensions import Literal from textual._text_area_theme import TextAreaTheme from textual._tree_sitter import TREE_SITTER @@ -24,6 +24,8 @@ _utf8_encode, ) from textual.document._document_navigator import DocumentNavigator +from textual.document._edit import Edit +from textual.document._history import EditHistory from textual.document._languages import BUILTIN_LANGUAGES from textual.document._syntax_aware_document import ( SyntaxAwareDocument, @@ -83,7 +85,7 @@ class TextAreaLanguage: highlight_query: str -class TextArea(ScrollView, can_focus=True): +class TextArea(ScrollView): DEFAULT_CSS = """\ TextArea { width: 1fr; @@ -91,41 +93,51 @@ class TextArea(ScrollView, can_focus=True): border: tall $background; padding: 0 1; + & .text-area--gutter { + color: $text 40%; + } + + & .text-area--cursor-gutter { + color: $text 60%; + background: $boost; + text-style: bold; + } + + & .text-area--cursor-line { + background: $boost; + } + + & .text-area--selection { + background: $accent-lighten-1 40%; + } + + & .text-area--matching-bracket { + background: $foreground 30%; + } + &:focus { border: tall $accent; } -} - -.text-area--cursor { - color: $text 90%; - background: $foreground 90%; -} - -TextArea:light .text-area--cursor { - color: $text 90%; - background: $foreground 70%; -} - -.text-area--gutter { - color: $text 40%; -} - -.text-area--cursor-line { - background: $boost; -} - -.text-area--cursor-gutter { - color: $text 60%; - background: $boost; - text-style: bold; -} - -.text-area--selection { - background: $accent-lighten-1 40%; -} - -.text-area--matching-bracket { - background: $foreground 30%; + + &:dark { + .text-area--cursor { + color: $text 90%; + background: $foreground 90%; + } + &.-read-only .text-area--cursor { + background: $warning-darken-1; + } + } + + &:light { + .text-area--cursor { + color: $text 90%; + background: $foreground 70%; + } + &.-read-only .text-area--cursor { + background: $warning-darken-1; + } + } } """ @@ -153,7 +165,6 @@ class TextArea(ScrollView, can_focus=True): """ BINDINGS = [ - Binding("escape", "screen.focus_next", "Shift Focus", show=False), # Cursor movement Binding("up", "cursor_up", "cursor up", show=False), Binding("down", "cursor_down", "cursor down", show=False), @@ -209,11 +220,12 @@ class TextArea(ScrollView, can_focus=True): "ctrl+u", "delete_to_start_of_line", "delete to line start", show=False ), Binding("ctrl+k", "delete_to_end_of_line", "delete to line end", show=False), + Binding("ctrl+z", "undo", "Undo", show=False), + Binding("ctrl+y", "redo", "Redo", show=False), ] """ | Key(s) | Description | | :- | :- | - | escape | Focus on the next item. | | up | Move the cursor up. | | down | Move the cursor down. | | left | Move the cursor left. | @@ -241,6 +253,8 @@ class TextArea(ScrollView, can_focus=True): | ctrl+k | Delete from cursor to the end of the line. | | f6 | Select the current line. | | f7 | Select all text in the document. | + | ctrl+z | Undo. | + | ctrl+y | Redo. | """ language: Reactive[str | None] = reactive(None, always_update=True, init=False) @@ -254,7 +268,7 @@ class TextArea(ScrollView, can_focus=True): it first using [`TextArea.register_language`][textual.widgets._text_area.TextArea.register_language]. """ - theme: Reactive[str | None] = reactive(None, always_update=True, init=False) + theme: Reactive[str] = reactive("css", always_update=True, init=False) """The name of the theme to use. Themes must be registered using [`TextArea.register_theme`][textual.widgets._text_area.TextArea.register_theme] before they can be used. @@ -297,7 +311,15 @@ class TextArea(ScrollView, can_focus=True): soft_wrap: Reactive[bool] = reactive(True, init=False) """True if text should soft wrap.""" - _cursor_visible: Reactive[bool] = reactive(True, repaint=False, init=False) + read_only: Reactive[bool] = reactive(False) + """True if the content is read-only. + + Read-only means end users cannot insert, delete or replace content. + + The document can still be edited programmatically via the API. + """ + + _cursor_visible: Reactive[bool] = reactive(False, repaint=False, init=False) """Indicates where the cursor is in the blink cycle. If it's currently not visible due to blinking, this is False.""" @@ -337,10 +359,12 @@ def __init__( text: str = "", *, language: str | None = None, - theme: str | None = None, + theme: str = "css", soft_wrap: bool = True, - tab_behaviour: Literal["focus", "indent"] = "focus", + tab_behavior: Literal["focus", "indent"] = "focus", + read_only: bool = False, show_line_numbers: bool = False, + max_checkpoints: int = 50, name: str | None = None, id: str | None = None, classes: str | None = None, @@ -353,15 +377,16 @@ def __init__( language: The language to use. theme: The theme to use. soft_wrap: Enable soft wrapping. - tab_behaviour: If 'focus', pressing tab will switch focus. If 'indent', pressing tab will insert a tab. + tab_behavior: If 'focus', pressing tab will switch focus. If 'indent', pressing tab will insert a tab. + read_only: Enable read-only mode. This prevents edits using the keyboard. show_line_numbers: Show line numbers on the left edge. + max_checkpoints: The maximum number of undo history checkpoints to retain. name: The name of the `TextArea` widget. id: The ID of the widget, used to refer to it from Textual CSS. classes: One or more Textual CSS compatible class names separated by spaces. disabled: True if the widget is disabled. """ super().__init__(name=name, id=id, classes=classes, disabled=disabled) - self._initial_text = text self._languages: dict[str, TextAreaLanguage] = {} """Maps language names to TextAreaLanguage.""" @@ -375,7 +400,11 @@ def __init__( self._word_pattern = re.compile(r"(?<=\W)(?=\w)|(?<=\w)(?=\W)") """Compiled regular expression for what we consider to be a 'word'.""" - self._undo_stack: list[Undoable] = [] + self.history: EditHistory = EditHistory( + max_checkpoints=max_checkpoints, + checkpoint_timer=2.0, + checkpoint_max_characters=100, + ) """A stack (the end of the list is the top of the stack) for tracking edits.""" self._selecting = False @@ -389,7 +418,7 @@ def __init__( self._highlights: dict[int, list[Highlight]] = defaultdict(list) """Mapping line numbers to the set of highlights for that line.""" - self._highlight_query: "Query" | None = None + self._highlight_query: "Query | None" = None """The query that's currently being used for highlighting.""" self.document: DocumentBase = Document(text) @@ -407,20 +436,19 @@ def __init__( self._set_document(text, language) - self._theme: TextAreaTheme | None = None - """The `TextAreaTheme` corresponding to the set theme name. When the `theme` - reactive is set as a string, the watcher will update this attribute to the - corresponding `TextAreaTheme` object.""" - self.language = language - self.theme = theme - self._reactive_soft_wrap = soft_wrap + self._theme: TextAreaTheme + """The `TextAreaTheme` corresponding to the set theme name. When the `theme` + reactive is set as a string, the watcher will update this attribute to the + corresponding `TextAreaTheme` object.""" - self._reactive_show_line_numbers = show_line_numbers + self.set_reactive(TextArea.soft_wrap, soft_wrap) + self.set_reactive(TextArea.read_only, read_only) + self.set_reactive(TextArea.show_line_numbers, show_line_numbers) - self.tab_behaviour = tab_behaviour + self.tab_behavior = tab_behavior # When `app.dark` is toggled, reset the theme (since it caches values). self.watch(self.app, "dark", self._app_dark_toggled, init=False) @@ -431,9 +459,9 @@ def code_editor( text: str = "", *, language: str | None = None, - theme: str | None = None, + theme: str = "monokai", soft_wrap: bool = False, - tab_behaviour: Literal["focus", "indent"] = "indent", + tab_behavior: Literal["focus", "indent"] = "indent", show_line_numbers: bool = True, name: str | None = None, id: str | None = None, @@ -443,26 +471,26 @@ def code_editor( """Construct a new `TextArea` with sensible defaults for editing code. This instantiates a `TextArea` with line numbers enabled, soft wrapping - disabled, and "indent" tab behaviour. + disabled, "indent" tab behavior, and the "monokai" theme. Args: text: The initial text to load into the TextArea. language: The language to use. theme: The theme to use. soft_wrap: Enable soft wrapping. - tab_behaviour: If 'focus', pressing tab will switch focus. If 'indent', pressing tab will insert a tab. + tab_behavior: If 'focus', pressing tab will switch focus. If 'indent', pressing tab will insert a tab. show_line_numbers: Show line numbers on the left edge. name: The name of the `TextArea` widget. id: The ID of the widget, used to refer to it from Textual CSS. classes: One or more Textual CSS compatible class names separated by spaces. disabled: True if the widget is disabled. """ - return TextArea( + return cls( text, language=language, theme=theme, soft_wrap=soft_wrap, - tab_behaviour=tab_behaviour, + tab_behavior=tab_behavior, show_line_numbers=show_line_numbers, name=name, id=id, @@ -520,9 +548,14 @@ def _build_highlight_map(self) -> None: # Add the last line of the node range highlights[node_end_row].append((0, node_end_column, highlight_name)) - def watch_has_focus(self, value: bool) -> None: - self._cursor_visible = value - super().watch_has_focus(value) + def _watch_has_focus(self, focus: bool) -> None: + self._cursor_visible = focus + if focus: + self._restart_blink() + self.app.cursor_position = self.cursor_screen_offset + self.history.checkpoint() + else: + self._pause_blink(visible=False) def _watch_selection( self, previous_selection: Selection, selection: Selection @@ -551,6 +584,18 @@ def _watch_selection( if previous_selection != selection: self.post_message(self.SelectionChanged(selection, self)) + def _watch_cursor_blink(self, blink: bool) -> None: + if not self.is_mounted: + return None + if blink and self.has_focus: + self._restart_blink() + else: + self._pause_blink(visible=self.has_focus) + + def _watch_read_only(self, read_only: bool) -> None: + self.set_class(read_only, "-read-only") + self._set_theme(self._theme.name) + def _recompute_cursor_offset(self): """Recompute the (x, y) coordinate of the cursor in the wrapped document.""" self._cursor_offset = self.wrapped_document.location_to_offset( @@ -571,7 +616,7 @@ def find_matching_bracket( If the character is not available for bracket matching, `None` is returned. """ match_location = None - bracket_stack = [] + bracket_stack: list[str] = [] if bracket in _OPENING_BRACKETS: for candidate, candidate_location in self._yield_character_locations( search_from @@ -621,11 +666,7 @@ def _watch_language(self, language: str | None) -> None: f"then switch to it by setting the `TextArea.language` attribute." ) - self._set_document( - self.document.text if self.document is not None else self._initial_text, - language, - ) - self._initial_text = "" + self._set_document(self.document.text, language) def _watch_show_line_numbers(self) -> None: """The line number gutter contributes to virtual size, so recalculate.""" @@ -641,32 +682,28 @@ def _watch_show_vertical_scrollbar(self) -> None: self._rewrap_and_refresh_virtual_size() self.scroll_cursor_visible() - def _watch_theme(self, theme: str | None) -> None: + def _watch_theme(self, theme: str) -> None: """We set the styles on this widget when the theme changes, to ensure that - if padding is applied, the colours match.""" + if padding is applied, the colors match.""" self._set_theme(theme) - def _app_dark_toggled(self): + def _app_dark_toggled(self) -> None: self._set_theme(self._theme.name) - def _set_theme(self, theme: str | None): + def _set_theme(self, theme: str) -> None: theme_object: TextAreaTheme | None - if theme is None: - # If the theme is None, use the default. - theme_object = TextAreaTheme.default() - else: - # If the user supplied a string theme name, find it and apply it. - try: - theme_object = self._themes[theme] - except KeyError: - theme_object = TextAreaTheme.get_builtin_theme(theme) + # If the user supplied a string theme name, find it and apply it. + try: + theme_object = self._themes[theme] + except KeyError: + theme_object = TextAreaTheme.get_builtin_theme(theme) if theme_object is None: raise ThemeDoesNotExist( f"{theme!r} is not a builtin theme, or it has not been registered. " f"To use a custom theme, register it first using `register_theme`, " f"then switch to that theme by setting the `TextArea.theme` attribute." - ) + ) from None self._theme = dataclasses.replace(theme_object) if theme_object: @@ -725,7 +762,7 @@ def available_languages(self) -> set[str]: def register_language( self, - language: str | "Language", + language: "str | Language", highlight_query: str, ) -> None: """Register a language and corresponding highlight query. @@ -782,7 +819,7 @@ def _set_document(self, text: str, language: str | None) -> None: if TREE_SITTER and language: # Attempt to get the override language. text_area_language = self._languages.get(language, None) - document_language: str | "Language" + document_language: "str | Language" if text_area_language: document_language = text_area_language.language highlight_query = text_area_language.highlight_query @@ -835,11 +872,12 @@ def _watch_scroll_y(self) -> None: def load_text(self, text: str) -> None: """Load text into the TextArea. - This will replace the text currently in the TextArea. + This will replace the text currently in the TextArea and clear the edit history. Args: text: The text to load into the TextArea. """ + self.history.clear() self._set_document(text, self.language) def _on_resize(self) -> None: @@ -920,11 +958,11 @@ def _refresh_size(self) -> None: width, height = self.document.get_size(self.indent_width) self.virtual_size = Size(width + self.gutter_width + 1, height) - def render_line(self, widget_y: int) -> Strip: + def render_line(self, y: int) -> Strip: """Render a single line of the TextArea. Called by Textual. Args: - widget_y: Y Coordinate of line relative to the widget region. + y: Y Coordinate of line relative to the widget region. Returns: A rendered line. @@ -938,7 +976,7 @@ def render_line(self, widget_y: int) -> Strip: scroll_x, scroll_y = self.scroll_offset # Account for how much the TextArea is scrolled. - y_offset = widget_y + scroll_y + y_offset = y + scroll_y # If we're beyond the height of the document, render blank lines out_of_bounds = y_offset >= wrapped_document.height @@ -960,11 +998,10 @@ def render_line(self, widget_y: int) -> Strip: # Get the line from the Document. line_string = document.get_line(line_index) line = Text(line_string, end="") - line_character_count = len(line) line.tab_size = self.indent_width line.set_length(line_character_count + 1) # space at end for cursor - virtual_width, virtual_height = self.virtual_size + virtual_width, _virtual_height = self.virtual_size selection = self.selection start, end = selection @@ -974,21 +1011,6 @@ def render_line(self, widget_y: int) -> Strip: selection_top_row, selection_top_column = selection_top selection_bottom_row, selection_bottom_column = selection_bottom - highlights = self._highlights - if highlights and theme: - line_bytes = _utf8_encode(line_string) - byte_to_codepoint = build_byte_to_codepoint_dict(line_bytes) - get_highlight_from_theme = theme.syntax_styles.get - line_highlights = highlights[line_index] - for highlight_start, highlight_end, highlight_name in line_highlights: - node_style = get_highlight_from_theme(highlight_name) - if node_style is not None: - line.stylize( - node_style, - byte_to_codepoint.get(highlight_start, 0), - byte_to_codepoint.get(highlight_end) if highlight_end else None, - ) - cursor_line_style = theme.cursor_line_style if theme else None if cursor_line_style and cursor_row == line_index: line.stylize(cursor_line_style) @@ -1023,6 +1045,21 @@ def render_line(self, widget_y: int) -> Strip: else: line.stylize(selection_style, end=line_character_count) + highlights = self._highlights + if highlights and theme: + line_bytes = _utf8_encode(line_string) + byte_to_codepoint = build_byte_to_codepoint_dict(line_bytes) + get_highlight_from_theme = theme.syntax_styles.get + line_highlights = highlights[line_index] + for highlight_start, highlight_end, highlight_name in line_highlights: + node_style = get_highlight_from_theme(highlight_name) + if node_style is not None: + line.stylize( + node_style, + byte_to_codepoint.get(highlight_start, 0), + byte_to_codepoint.get(highlight_end) if highlight_end else None, + ) + # Highlight the cursor matching_bracket = self._matching_bracket_location match_cursor_bracket = self.match_cursor_bracket @@ -1031,8 +1068,10 @@ def render_line(self, widget_y: int) -> Strip: ) if cursor_row == line_index: - draw_cursor = not self.cursor_blink or ( - self.cursor_blink and self._cursor_visible + draw_cursor = ( + self.has_focus + and not self.cursor_blink + or (self.cursor_blink and self._cursor_visible) ) if draw_matched_brackets: matching_bracket_style = theme.bracket_matching_style if theme else None @@ -1153,6 +1192,8 @@ def text(self) -> str: def text(self, value: str) -> None: """Replace the text currently in the TextArea. This is an alias of `load_text`. + Setting this value will clear the edit history. + Args: value: The text to load into the TextArea. """ @@ -1189,14 +1230,15 @@ def edit(self, edit: Edit) -> EditResult: """ old_gutter_width = self.gutter_width result = edit.do(self) + self.history.record(edit) new_gutter_width = self.gutter_width if old_gutter_width != new_gutter_width: self.wrapped_document.wrap(self.wrap_width, self.indent_width) else: self.wrapped_document.wrap_range( - edit.from_location, - edit.to_location, + edit.top, + edit.bottom, result.end_location, ) @@ -1206,19 +1248,137 @@ def edit(self, edit: Edit) -> EditResult: self.post_message(self.Changed(self)) return result + def undo(self) -> None: + """Undo the edits since the last checkpoint (the most recent batch of edits).""" + if edits := self.history._pop_undo(): + self._undo_batch(edits) + + def action_undo(self) -> None: + """Undo the edits since the last checkpoint (the most recent batch of edits).""" + self.undo() + + def redo(self) -> None: + """Redo the most recently undone batch of edits.""" + if edits := self.history._pop_redo(): + self._redo_batch(edits) + + def action_redo(self) -> None: + """Redo the most recently undone batch of edits.""" + self.redo() + + def _undo_batch(self, edits: Sequence[Edit]) -> None: + """Undo a batch of Edits. + + The sequence must be chronologically ordered by edit time. + + There must be no edits missing from the sequence, or the resulting content + will be incorrect. + + Args: + edits: The edits to undo, in the order they were originally performed. + """ + if not edits: + return + + old_gutter_width = self.gutter_width + minimum_from = edits[-1].from_location + maximum_old_end = (0, 0) + maximum_new_end = (0, 0) + for edit in reversed(edits): + edit.undo(self) + end_location = ( + edit._edit_result.end_location if edit._edit_result else (0, 0) + ) + if edit.from_location < minimum_from: + minimum_from = edit.from_location + if end_location > maximum_old_end: + maximum_old_end = end_location + if edit.to_location > maximum_new_end: + maximum_new_end = edit.to_location + + new_gutter_width = self.gutter_width + if old_gutter_width != new_gutter_width: + self.wrapped_document.wrap(self.wrap_width, self.indent_width) + else: + self.wrapped_document.wrap_range( + minimum_from, maximum_old_end, maximum_new_end + ) + + self._refresh_size() + for edit in reversed(edits): + edit.after(self) + self._build_highlight_map() + self.post_message(self.Changed(self)) + + def _redo_batch(self, edits: Sequence[Edit]) -> None: + """Redo a batch of Edits in order. + + The sequence must be chronologically ordered by edit time. + + Edits are applied from the start of the sequence to the end. + + There must be no edits missing from the sequence, or the resulting content + will be incorrect. + + Args: + edits: The edits to redo. + """ + if not edits: + return + + old_gutter_width = self.gutter_width + minimum_from = edits[0].from_location + maximum_old_end = (0, 0) + maximum_new_end = (0, 0) + for edit in edits: + edit.do(self, record_selection=False) + end_location = ( + edit._edit_result.end_location if edit._edit_result else (0, 0) + ) + if edit.from_location < minimum_from: + minimum_from = edit.from_location + if end_location > maximum_new_end: + maximum_new_end = end_location + if edit.to_location > maximum_old_end: + maximum_old_end = edit.to_location + + new_gutter_width = self.gutter_width + if old_gutter_width != new_gutter_width: + self.wrapped_document.wrap(self.wrap_width, self.indent_width) + else: + self.wrapped_document.wrap_range( + minimum_from, + maximum_old_end, + maximum_new_end, + ) + + self._refresh_size() + for edit in edits: + edit.after(self) + self._build_highlight_map() + self.post_message(self.Changed(self)) + async def _on_key(self, event: events.Key) -> None: """Handle key presses which correspond to document inserts.""" + self._restart_blink() + if self.read_only: + return + key = event.key insert_values = { "enter": "\n", } - if self.tab_behaviour == "indent": + if self.tab_behavior == "indent": + if key == "escape": + event.stop() + event.prevent_default() + self.screen.focus_next() + return if self.indent_type == "tabs": insert_values["tab"] = "\t" else: insert_values["tab"] = " " * self._find_columns_to_next_tab_stop() - self._restart_blink() if event.is_printable or key in insert_values: event.stop() event.prevent_default() @@ -1227,7 +1387,7 @@ async def _on_key(self, event: events.Key) -> None: # None because we've checked that it's printable. assert insert is not None start, end = self.selection - self.replace(insert, start, end, maintain_selection_offset=False) + self._replace_via_keyboard(insert, start, end) def _find_columns_to_next_tab_stop(self) -> int: """Get the location of the next tab stop after the cursors position on the current line. @@ -1281,26 +1441,24 @@ def gutter_width(self) -> int: ) return gutter_width - def _on_mount(self, _: events.Mount) -> None: + def _on_mount(self, event: events.Mount) -> None: self.blink_timer = self.set_interval( 0.5, self._toggle_cursor_blink_visible, pause=not (self.cursor_blink and self.has_focus), ) - def _on_blur(self, _: events.Blur) -> None: - self._pause_blink(visible=True) - - def _on_focus(self, _: events.Focus) -> None: - self._restart_blink() - self.app.cursor_position = self.cursor_screen_offset - def _toggle_cursor_blink_visible(self) -> None: """Toggle visibility of the cursor for the purposes of 'cursor blink'.""" self._cursor_visible = not self._cursor_visible _, cursor_y = self._cursor_offset self.refresh_lines(cursor_y) + def _watch__cursor_visible(self) -> None: + """When the cursor visibility is toggled, ensure the row is refreshed.""" + _, cursor_y = self._cursor_offset + self.refresh_lines(cursor_y) + def _restart_blink(self) -> None: """Reset the cursor blink timer.""" if self.cursor_blink: @@ -1321,6 +1479,7 @@ async def _on_mouse_down(self, event: events.MouseDown) -> None: # TextArea widget while selecting, the widget still scrolls. self.capture_mouse() self._pause_blink(visible=True) + self.history.checkpoint() async def _on_mouse_move(self, event: events.MouseMove) -> None: """Handles click and drag to expand and contract the selection.""" @@ -1330,7 +1489,7 @@ async def _on_mouse_move(self, event: events.MouseMove) -> None: self.selection = Selection(selection_start, target) async def _on_mouse_up(self, event: events.MouseUp) -> None: - """Finalise the selection that has been made using the mouse.""" + """Finalize the selection that has been made using the mouse.""" self._selecting = False self.release_mouse() self.record_cursor_width() @@ -1338,7 +1497,10 @@ async def _on_mouse_up(self, event: events.MouseUp) -> None: async def _on_paste(self, event: events.Paste) -> None: """When a paste occurs, insert the text from the paste event into the document.""" - self.replace(event.text, *self.selection) + if self.read_only: + return + if result := self._replace_via_keyboard(event.text, *self.selection): + self.move_cursor(result.end_location) def cell_width_to_column_index(self, cell_width: int, row_index: int) -> int: """Return the column that the cell width corresponds to on the given row. @@ -1418,7 +1580,7 @@ def move_cursor( that is wide enough. """ if select: - start, end = self.selection + start, _end = self.selection self.selection = Selection(start, location) else: self.selection = Selection.cursor(location) @@ -1429,6 +1591,8 @@ def move_cursor( if center: self.scroll_cursor_visible(center) + self.history.checkpoint() + def move_cursor_relative( self, rows: int = 0, @@ -1449,7 +1613,7 @@ def move_cursor_relative( that is wide enough. """ clamp_visitable = self.clamp_visitable - start, end = self.selection + _start, end = self.selection current_row, current_column = end target = clamp_visitable((current_row + rows, current_column + columns)) self.move_cursor(target, select, center, record_width) @@ -1811,8 +1975,7 @@ def delete( Returns: An `EditResult` containing information about the edit. """ - top, bottom = sorted((start, end)) - return self.edit(Edit("", top, bottom, maintain_selection_offset)) + return self.edit(Edit("", start, end, maintain_selection_offset)) def replace( self, @@ -1838,12 +2001,54 @@ def replace( """ return self.edit(Edit(insert, start, end, maintain_selection_offset)) - def clear(self) -> None: - """Delete all text from the document.""" + def clear(self) -> EditResult: + """Delete all text from the document. + + Returns: + An EditResult relating to the deletion of all content. + """ document = self.document last_line = document[-1] document_end = (document.line_count, len(last_line)) - self.delete((0, 0), document_end, maintain_selection_offset=False) + return self.delete((0, 0), document_end, maintain_selection_offset=False) + + def _delete_via_keyboard( + self, + start: Location, + end: Location, + ) -> EditResult | None: + """Handle a deletion performed using a keyboard (as opposed to the API). + + Args: + start: The start location of the text to delete. + end: The end location of the text to delete. + + Returns: + An EditResult or None if no edit was performed (e.g. on read-only mode). + """ + if self.read_only: + return None + return self.delete(start, end, maintain_selection_offset=False) + + def _replace_via_keyboard( + self, + insert: str, + start: Location, + end: Location, + ) -> EditResult | None: + """Handle a replacement performed using a keyboard (as opposed to the API). + + Args: + insert: The text to insert into the document. + start: The start location of the text to replace. + end: The end location of the text to replace. + + Returns: + An EditResult or None if no edit was performed (e.g. on read-only mode). + """ + if self.read_only: + return None + return self.replace(insert, start, end, maintain_selection_offset=False) def action_delete_left(self) -> None: """Deletes the character to the left of the cursor and updates the cursor location. @@ -1856,7 +2061,7 @@ def action_delete_left(self) -> None: if selection.is_empty: end = self.get_cursor_left_location() - self.delete(start, end, maintain_selection_offset=False) + self._delete_via_keyboard(start, end) def action_delete_right(self) -> None: """Deletes the character to the right of the cursor and keeps the cursor at the same location. @@ -1869,13 +2074,13 @@ def action_delete_right(self) -> None: if selection.is_empty: end = self.get_cursor_right_location() - self.delete(start, end, maintain_selection_offset=False) + self._delete_via_keyboard(start, end) def action_delete_line(self) -> None: """Deletes the lines which intersect with the selection.""" start, end = self.selection start, end = sorted((start, end)) - start_row, start_column = start + start_row, _start_column = start end_row, end_column = end # Generally editors will only delete line the end line of the @@ -1886,20 +2091,21 @@ def action_delete_line(self) -> None: from_location = (start_row, 0) to_location = (end_row + 1, 0) - self.delete(from_location, to_location, maintain_selection_offset=False) - self.move_cursor_relative(columns=end_column, record_width=False) + deletion = self._delete_via_keyboard(from_location, to_location) + if deletion is not None: + self.move_cursor_relative(columns=end_column, record_width=False) def action_delete_to_start_of_line(self) -> None: """Deletes from the cursor location to the start of the line.""" from_location = self.selection.end to_location = self.get_cursor_line_start_location() - self.delete(from_location, to_location, maintain_selection_offset=False) + self._delete_via_keyboard(from_location, to_location) def action_delete_to_end_of_line(self) -> None: """Deletes from the cursor location to the end of the line.""" from_location = self.selection.end to_location = self.get_cursor_line_end_location() - self.delete(from_location, to_location, maintain_selection_offset=False) + self._delete_via_keyboard(from_location, to_location) def action_delete_word_left(self) -> None: """Deletes the word to the left of the cursor and updates the cursor location.""" @@ -1910,11 +2116,11 @@ def action_delete_word_left(self) -> None: # deletes the characters within the selection range, ignoring word boundaries. start, end = self.selection if start != end: - self.delete(start, end, maintain_selection_offset=False) + self._delete_via_keyboard(start, end) return to_location = self.get_cursor_word_left_location() - self.delete(self.selection.end, to_location, maintain_selection_offset=False) + self._delete_via_keyboard(self.selection.end, to_location) def action_delete_word_right(self) -> None: """Deletes the word to the right of the cursor and keeps the cursor at the same location. @@ -1928,7 +2134,7 @@ def action_delete_word_right(self) -> None: start, end = self.selection if start != end: - self.delete(start, end, maintain_selection_offset=False) + self._delete_via_keyboard(start, end) return cursor_row, cursor_column = end @@ -1948,135 +2154,7 @@ def action_delete_word_right(self) -> None: else: to_location = (cursor_row, current_row_length) - self.delete(end, to_location, maintain_selection_offset=False) - - -@dataclass -class Edit: - """Implements the Undoable protocol to replace text at some range within a document.""" - - text: str - """The text to insert. An empty string is equivalent to deletion.""" - from_location: Location - """The start location of the insert.""" - to_location: Location - """The end location of the insert""" - maintain_selection_offset: bool - """If True, the selection will maintain its offset to the replacement range.""" - _updated_selection: Selection | None = field(init=False, default=None) - """Where the selection should move to after the replace happens.""" - - def do(self, text_area: TextArea) -> EditResult: - """Perform the edit operation. - - Args: - text_area: The `TextArea` to perform the edit on. - - Returns: - An `EditResult` containing information about the replace operation. - """ - text = self.text - - edit_from = self.from_location - edit_to = self.to_location - - # This code is mostly handling how we adjust TextArea.selection - # when an edit is made to the document programmatically. - # We want a user who is typing away to maintain their relative - # position in the document even if an insert happens before - # their cursor position. - - edit_top, edit_bottom = sorted((edit_from, edit_to)) - edit_bottom_row, edit_bottom_column = edit_bottom - - selection_start, selection_end = text_area.selection - selection_start_row, selection_start_column = selection_start - selection_end_row, selection_end_column = selection_end - - replace_result = text_area.document.replace_range(edit_from, edit_to, text) - - new_edit_to_row, new_edit_to_column = replace_result.end_location - - # TODO: We could maybe improve the situation where the selection - # and the edit range overlap with each other. - column_offset = new_edit_to_column - edit_bottom_column - target_selection_start_column = ( - selection_start_column + column_offset - if edit_bottom_row == selection_start_row - and edit_bottom_column <= selection_start_column - else selection_start_column - ) - target_selection_end_column = ( - selection_end_column + column_offset - if edit_bottom_row == selection_end_row - and edit_bottom_column <= selection_end_column - else selection_end_column - ) - - row_offset = new_edit_to_row - edit_bottom_row - target_selection_start_row = selection_start_row + row_offset - target_selection_end_row = selection_end_row + row_offset - - if self.maintain_selection_offset: - self._updated_selection = Selection( - start=(target_selection_start_row, target_selection_start_column), - end=(target_selection_end_row, target_selection_end_column), - ) - else: - self._updated_selection = Selection.cursor(replace_result.end_location) - - return replace_result - - def undo(self, text_area: TextArea) -> EditResult: - """Undo the edit operation. - - Args: - text_area: The `TextArea` to undo the insert operation on. - - Returns: - An `EditResult` containing information about the replace operation. - """ - raise NotImplementedError() - - def after(self, text_area: TextArea) -> None: - """Possibly update the cursor location after the widget has been refreshed. - - Args: - text_area: The `TextArea` this operation was performed on. - """ - if self._updated_selection is not None: - text_area.selection = self._updated_selection - text_area.record_cursor_width() - - -@runtime_checkable -class Undoable(Protocol): - """Protocol for actions performed in the text editor which can be done and undone. - - These are typically actions which affect the document (e.g. inserting and deleting - text), but they can really be anything. - - To perform an edit operation, pass the Edit to `TextArea.edit()`""" - - def do(self, text_area: TextArea) -> Any: - """Do the action. - - Args: - The `TextArea` to perform the action on. - - Returns: - Anything. This protocol doesn't prescribe what is returned. - """ - - def undo(self, text_area: TextArea) -> Any: - """Undo the action. - - Args: - The `TextArea` to perform the action on. - - Returns: - Anything. This protocol doesn't prescribe what is returned. - """ + self._delete_via_keyboard(end, to_location) @lru_cache(maxsize=128) @@ -2089,7 +2167,7 @@ def build_byte_to_codepoint_dict(data: bytes) -> dict[int, int]: Returns: A `dict[int, int]` mapping byte indices to codepoint indices within `data`. """ - byte_to_codepoint = {} + byte_to_codepoint: dict[int, int] = {} current_byte_offset = 0 code_point_offset = 0 diff --git a/src/textual/widgets/_tree.py b/src/textual/widgets/_tree.py index 43a079a5eb..3e0a2d8cb4 100644 --- a/src/textual/widgets/_tree.py +++ b/src/textual/widgets/_tree.py @@ -2,6 +2,7 @@ from __future__ import annotations +from asyncio import Lock from dataclasses import dataclass from typing import TYPE_CHECKING, ClassVar, Generic, Iterable, NewType, TypeVar, cast @@ -615,8 +616,10 @@ def __init__( self.root = self._add_node(None, text_label, data) """The root node of the tree.""" self._line_cache: LRUCache[LineCacheKey, Strip] = LRUCache(1024) - self._tree_lines_cached: list[_TreeLine] | None = None + self._tree_lines_cached: list[_TreeLine[TreeDataType]] | None = None self._cursor_node: TreeNode[TreeDataType] | None = None + self.lock = Lock() + """Used to synchronise stateful directory tree operations.""" super().__init__(name=name, id=id, classes=classes, disabled=disabled) @@ -815,7 +818,7 @@ def _invalidate(self) -> None: self.root._reset() self.refresh(layout=True) - def _on_mouse_move(self, event: events.MouseMove): + def _on_mouse_move(self, event: events.MouseMove) -> None: meta = event.style.meta if meta and "line" in meta: self.hover_line = meta["line"] @@ -948,7 +951,7 @@ def _refresh_node(self, node: TreeNode[TreeDataType]) -> None: self._refresh_line(line_no) @property - def _tree_lines(self) -> list[_TreeLine]: + def _tree_lines(self) -> list[_TreeLine[TreeDataType]]: if self._tree_lines_cached is None: self._build() assert self._tree_lines_cached is not None @@ -957,13 +960,14 @@ def _tree_lines(self) -> list[_TreeLine]: async def _on_idle(self, event: events.Idle) -> None: """Check tree needs a rebuild on idle.""" # Property calls build if required - self._tree_lines + async with self.lock: + self._tree_lines def _build(self) -> None: """Builds the tree by traversing nodes, and creating tree lines.""" TreeLine = _TreeLine - lines: list[_TreeLine] = [] + lines: list[_TreeLine[TreeDataType]] = [] add_line = lines.append root = self.root @@ -989,7 +993,7 @@ def add_node( show_root = self.show_root get_label_width = self.get_label_width - def get_line_width(line: _TreeLine) -> int: + def get_line_width(line: _TreeLine[TreeDataType]) -> int: return get_label_width(line.node) + line._get_guide_width( guide_depth, show_root ) @@ -1147,17 +1151,18 @@ def _toggle_node(self, node: TreeNode[TreeDataType]) -> None: node.expand() async def _on_click(self, event: events.Click) -> None: - meta = event.style.meta - if "line" in meta: - cursor_line = meta["line"] - if meta.get("toggle", False): - node = self.get_node_at_line(cursor_line) - if node is not None: - self._toggle_node(node) + async with self.lock: + meta = event.style.meta + if "line" in meta: + cursor_line = meta["line"] + if meta.get("toggle", False): + node = self.get_node_at_line(cursor_line) + if node is not None: + self._toggle_node(node) - else: - self.cursor_line = cursor_line - await self.run_action("select_cursor") + else: + self.cursor_line = cursor_line + await self.run_action("select_cursor") def notify_style_update(self) -> None: self._invalidate() diff --git a/src/textual/widgets/text_area.py b/src/textual/widgets/text_area.py index 10683d4b09..058f823542 100644 --- a/src/textual/widgets/text_area.py +++ b/src/textual/widgets/text_area.py @@ -7,11 +7,12 @@ Selection, ) from textual.document._document_navigator import DocumentNavigator +from textual.document._edit import Edit +from textual.document._history import EditHistory from textual.document._languages import BUILTIN_LANGUAGES from textual.document._syntax_aware_document import SyntaxAwareDocument from textual.document._wrapped_document import WrappedDocument from textual.widgets._text_area import ( - Edit, EndColumn, Highlight, HighlightName, @@ -27,6 +28,7 @@ "DocumentNavigator", "Edit", "EditResult", + "EditHistory", "EndColumn", "Highlight", "HighlightName", diff --git a/src/textual/worker.py b/src/textual/worker.py index d858fbe8c5..fa38d196be 100644 --- a/src/textual/worker.py +++ b/src/textual/worker.py @@ -8,6 +8,7 @@ import enum import inspect from contextvars import ContextVar +from threading import Event from time import monotonic from typing import ( TYPE_CHECKING, @@ -162,6 +163,8 @@ def __init__( self.group = group self.description = description self.exit_on_error = exit_on_error + self.cancelled_event: Event = Event() + """A threading event set when the worker is cancelled.""" self._thread_worker = thread self._state = WorkerState.PENDING self.state = self._state @@ -409,6 +412,7 @@ def cancel(self) -> None: self._cancelled = True if self._task is not None: self._task.cancel() + self.cancelled_event.set() async def wait(self) -> ResultType: """Wait for the work to complete. diff --git a/tests/command_palette/test_discover.py b/tests/command_palette/test_discover.py new file mode 100644 index 0000000000..24849adf07 --- /dev/null +++ b/tests/command_palette/test_discover.py @@ -0,0 +1,34 @@ +from textual.app import App +from textual.command import CommandPalette, DiscoveryHit, Hit, Hits, Provider +from textual.widgets import OptionList + + +class SimpleSource(Provider): + + async def discover(self) -> Hits: + def goes_nowhere_does_nothing() -> None: + pass + + yield DiscoveryHit("XD-1", goes_nowhere_does_nothing, "XD-1") + + async def search(self, query: str) -> Hits: + def goes_nowhere_does_nothing() -> None: + pass + + yield Hit(1, query, goes_nowhere_does_nothing, query) + + +class CommandPaletteApp(App[None]): + COMMANDS = {SimpleSource} + + def on_mount(self) -> None: + self.action_command_palette() + + +async def test_discovery_visible() -> None: + """A provider with discovery should cause the command palette to be opened right away.""" + async with CommandPaletteApp().run_test() as pilot: + assert CommandPalette.is_open(pilot.app) + results = pilot.app.screen.query_one(OptionList) + assert results.visible is True + assert results.option_count == 1 diff --git a/tests/css/test_nested_css.py b/tests/css/test_nested_css.py index 821c655ff3..32086f2e4d 100644 --- a/tests/css/test_nested_css.py +++ b/tests/css/test_nested_css.py @@ -44,6 +44,53 @@ async def test_nest_app(): assert app.query_one("#foo .paul").styles.background == Color.parse("blue") +class ListOfNestedSelectorsApp(App[None]): + CSS = """ + Label { + &.foo, &.bar { + background: red; + } + } + """ + + def compose(self) -> ComposeResult: + yield Label("one", classes="foo") + yield Label("two", classes="bar") + yield Label("three", classes="heh") + + +async def test_lists_of_selectors_in_nested_css() -> None: + """Regression test for https://github.com/Textualize/textual/issues/3969.""" + app = ListOfNestedSelectorsApp() + red = Color.parse("red") + async with app.run_test(): + assert app.query_one(".foo").styles.background == red + assert app.query_one(".bar").styles.background == red + assert app.query_one(".heh").styles.background != red + + +class DeclarationAfterNestedApp(App[None]): + CSS = """ + Screen { + Label { + background: red; + } + background: green; + } + """ + + def compose(self) -> ComposeResult: + yield Label("one") + + +async def test_rule_declaration_after_nested() -> None: + """Regression test for https://github.com/Textualize/textual/issues/3999.""" + app = DeclarationAfterNestedApp() + async with app.run_test(): + assert app.screen.styles.background == Color.parse("green") + assert app.query_one(Label).styles.background == Color.parse("red") + + @pytest.mark.parametrize( ("css", "exception"), [ diff --git a/tests/listview/test_listview_navigation.py b/tests/listview/test_listview_navigation.py new file mode 100644 index 0000000000..4a93bb2695 --- /dev/null +++ b/tests/listview/test_listview_navigation.py @@ -0,0 +1,46 @@ +from textual.app import App, ComposeResult +from textual.widgets import Label, ListItem, ListView + + +class ListViewDisabledItemsApp(App[None]): + def compose(self) -> ComposeResult: + self.highlighted = [] + yield ListView( + ListItem(Label("0"), disabled=True), + ListItem(Label("1")), + ListItem(Label("2"), disabled=True), + ListItem(Label("3"), disabled=True), + ListItem(Label("4")), + ListItem(Label("5")), + ListItem(Label("6"), disabled=True), + ListItem(Label("7")), + ListItem(Label("8"), disabled=True), + ) + + def _on_list_view_highlighted(self, message: ListView.Highlighted) -> None: + if message.item is None: + self.highlighted.append(None) + else: + self.highlighted.append(str(message.item.children[0].renderable)) + + +async def test_keyboard_navigation_with_disabled_items() -> None: + """Regression test for https://github.com/Textualize/textual/issues/3881.""" + + app = ListViewDisabledItemsApp() + async with app.run_test() as pilot: + for _ in range(5): + await pilot.press("down") + for _ in range(5): + await pilot.press("up") + + assert app.highlighted == [ + None, + "1", + "4", + "5", + "7", + "5", + "4", + "1", + ] diff --git a/tests/option_list/test_option_list_id_stability.py b/tests/option_list/test_option_list_id_stability.py new file mode 100644 index 0000000000..bd746914b0 --- /dev/null +++ b/tests/option_list/test_option_list_id_stability.py @@ -0,0 +1,22 @@ +"""Tests inspired by https://github.com/Textualize/textual/issues/4101""" + +from __future__ import annotations + +from textual.app import App, ComposeResult +from textual.widgets import OptionList +from textual.widgets.option_list import Option + + +class OptionListApp(App[None]): + """Test option list application.""" + + def compose(self) -> ComposeResult: + yield OptionList() + + +async def test_get_after_add() -> None: + """It should be possible to get an option by ID after adding.""" + async with OptionListApp().run_test() as pilot: + option_list = pilot.app.query_one(OptionList) + option_list.add_option(Option("0", id="0")) + assert option_list.get_option("0").id == "0" diff --git a/tests/option_list/test_option_list_movement.py b/tests/option_list/test_option_list_movement.py index d5b3d1c641..447d0ba3c8 100644 --- a/tests/option_list/test_option_list_movement.py +++ b/tests/option_list/test_option_list_movement.py @@ -2,8 +2,11 @@ from __future__ import annotations +import pytest + from textual.app import App, ComposeResult from textual.widgets import OptionList +from textual.widgets.option_list import Option class OptionListApp(App[None]): @@ -140,20 +143,69 @@ async def test_empty_list_movement() -> None: assert option_list.highlighted is None -async def test_no_highlight_movement() -> None: - """Attempting to move around in a list with no highlight should select the most appropriate item.""" - for movement, landing in ( - ("up", 0), +@pytest.mark.parametrize( + ["movement", "landing"], + [ + ("up", 99), ("down", 0), ("home", 0), ("end", 99), ("pageup", 0), ("pagedown", 99), - ): - async with EmptyOptionListApp().run_test() as pilot: - option_list = pilot.app.query_one(OptionList) - for _ in range(100): - option_list.add_option("test") - await pilot.press("tab") - await pilot.press(movement) - assert option_list.highlighted == landing + ], +) +async def test_no_highlight_movement(movement: str, landing: int) -> None: + """Attempting to move around in a list with no highlight should select the most appropriate item.""" + async with EmptyOptionListApp().run_test() as pilot: + option_list = pilot.app.query_one(OptionList) + for _ in range(100): + option_list.add_option("test") + await pilot.press("tab") + await pilot.press(movement) + assert option_list.highlighted == landing + + +class OptionListDisabledOptionsApp(App[None]): + def compose(self) -> ComposeResult: + self.highlighted = [] + yield OptionList( + Option("0", disabled=True), + Option("1"), + Option("2", disabled=True), + Option("3", disabled=True), + Option("4"), + Option("5"), + Option("6", disabled=True), + Option("7"), + Option("8", disabled=True), + ) + + def _on_option_list_option_highlighted( + self, message: OptionList.OptionHighlighted + ) -> None: + self.highlighted.append(str(message.option.prompt)) + + +async def test_keyboard_navigation_with_disabled_options() -> None: + """Regression test for https://github.com/Textualize/textual/issues/3881.""" + + app = OptionListDisabledOptionsApp() + async with app.run_test() as pilot: + for _ in range(5): + await pilot.press("down") + for _ in range(5): + await pilot.press("up") + + assert app.highlighted == [ + "1", + "4", + "5", + "7", + "1", + "4", + "1", + "7", + "5", + "4", + "1", + ] diff --git a/tests/option_list/test_option_messages.py b/tests/option_list/test_option_messages.py index e66debc458..7d0d899653 100644 --- a/tests/option_list/test_option_messages.py +++ b/tests/option_list/test_option_messages.py @@ -92,15 +92,6 @@ async def test_select_message_with_keyboard() -> None: ] -async def test_select_disabled_option_with_keyboard() -> None: - """Hitting enter on an option should result in a message.""" - async with OptionListApp().run_test() as pilot: - assert isinstance(pilot.app, OptionListApp) - pilot.app.query_one(OptionList).disable_option("1") - await pilot.press("tab", "down", "enter") - assert pilot.app.messages[1:] == [] - - async def test_click_option_with_mouse() -> None: """Clicking on an option via the mouse should result in highlight and select messages.""" async with OptionListApp().run_test() as pilot: diff --git a/tests/snapshot_tests/__snapshots__/test_snapshots.ambr b/tests/snapshot_tests/__snapshots__/test_snapshots.ambr index 5824f20be7..f31002578f 100644 --- a/tests/snapshot_tests/__snapshots__/test_snapshots.ambr +++ b/tests/snapshot_tests/__snapshots__/test_snapshots.ambr @@ -1872,144 +1872,144 @@ font-weight: 700; } - .terminal-1822845634-matrix { + .terminal-1403746156-matrix { font-family: Fira Code, monospace; font-size: 20px; line-height: 24.4px; font-variant-east-asian: full-width; } - .terminal-1822845634-title { + .terminal-1403746156-title { font-size: 18px; font-weight: bold; font-family: arial; } - .terminal-1822845634-r1 { fill: #e1e1e1 } - .terminal-1822845634-r2 { fill: #c5c8c6 } - .terminal-1822845634-r3 { fill: #262626 } - .terminal-1822845634-r4 { fill: #e2e2e2 } - .terminal-1822845634-r5 { fill: #4a4a4a } - .terminal-1822845634-r6 { fill: #2e2e2e;font-weight: bold } - .terminal-1822845634-r7 { fill: #e3e3e3 } - .terminal-1822845634-r8 { fill: #e3e3e3;font-weight: bold } - .terminal-1822845634-r9 { fill: #98729f } - .terminal-1822845634-r10 { fill: #4ebf71;font-weight: bold } - .terminal-1822845634-r11 { fill: #0178d4 } - .terminal-1822845634-r12 { fill: #14191f } - .terminal-1822845634-r13 { fill: #5d5d5d } - .terminal-1822845634-r14 { fill: #e3e3e3;text-decoration: underline; } + .terminal-1403746156-r1 { fill: #e1e1e1 } + .terminal-1403746156-r2 { fill: #c5c8c6 } + .terminal-1403746156-r3 { fill: #262626 } + .terminal-1403746156-r4 { fill: #e2e2e2 } + .terminal-1403746156-r5 { fill: #4a4a4a } + .terminal-1403746156-r6 { fill: #2e2e2e;font-weight: bold } + .terminal-1403746156-r7 { fill: #e3e3e3 } + .terminal-1403746156-r8 { fill: #e3e3e3;font-weight: bold } + .terminal-1403746156-r9 { fill: #855c8d } + .terminal-1403746156-r10 { fill: #4ebf71;font-weight: bold } + .terminal-1403746156-r11 { fill: #0178d4 } + .terminal-1403746156-r12 { fill: #14191f } + .terminal-1403746156-r13 { fill: #5d5d5d } + .terminal-1403746156-r14 { fill: #e3e3e3;text-decoration: underline; } - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - CheckboxApp + CheckboxApp - + - - - - ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ - X Arrakis 😓 - ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ - ▔▔▔▔▔▔▔▔▔▔▔▔▔ - X Caladan - ▁▁▁▁▁▁▁▁▁▁▁▁▁ - ▔▔▔▔▔▔▔▔▔▔▔▔ - X Chusuk - ▁▁▁▁▁▁▁▁▁▁▁▁ - ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ - XGiedi Prime - ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ - ▔▔▔▔▔▔▔▔▔▔▔ - XGinaz - ▁▁▁▁▁▁▁▁▁▁▁ - ▔▔▔▔▔▔▔▔▔▔▔▔▔ - X Grumman - ▁▁▁▁▁▁▁▁▁▁▁▁▁ - ▔▔▔▔▔▔▔▔▔▔▔▔▔▃▃ - XKaitain - ▁▁▁▁▁▁▁▁▁▁▁▁▁ - ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ + + + + ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ + X Arrakis 😓 + ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ + ▔▔▔▔▔▔▔▔▔▔▔▔▔ + X Caladan + ▁▁▁▁▁▁▁▁▁▁▁▁▁ + ▔▔▔▔▔▔▔▔▔▔▔▔ + X Chusuk + ▁▁▁▁▁▁▁▁▁▁▁▁ + ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ + XGiedi Prime + ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ + ▔▔▔▔▔▔▔▔▔▔▔ + XGinaz + ▁▁▁▁▁▁▁▁▁▁▁ + ▔▔▔▔▔▔▔▔▔▔▔▔▔ + X Grumman + ▁▁▁▁▁▁▁▁▁▁▁▁▁ + ▔▔▔▔▔▔▔▔▔▔▔▔▔▃▃ + XKaitain + ▁▁▁▁▁▁▁▁▁▁▁▁▁ + ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ @@ -3130,6 +3130,167 @@ ''' # --- +# name: test_command_palette_discovery + ''' + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + CommandPaletteApp + + + + + + + + + + + + + ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ + + 🔎Command Palette Search... + + + This is a test of this code 0 + This is a test of this code 1 + This is a test of this code 2 + This is a test of this code 3 + This is a test of this code 4 + This is a test of this code 5 + This is a test of this code 6 + This is a test of this code 7 + This is a test of this code 8 + This is a test of this code 9 + ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ + + + + + + + + + + ''' +# --- # name: test_content_switcher_example_initial ''' @@ -4847,141 +5008,141 @@ font-weight: 700; } - .terminal-2893669067-matrix { + .terminal-1056312446-matrix { font-family: Fira Code, monospace; font-size: 20px; line-height: 24.4px; font-variant-east-asian: full-width; } - .terminal-2893669067-title { + .terminal-1056312446-title { font-size: 18px; font-weight: bold; font-family: arial; } - .terminal-2893669067-r1 { fill: #e1e1e1 } - .terminal-2893669067-r2 { fill: #c5c8c6 } - .terminal-2893669067-r3 { fill: #fea62b } - .terminal-2893669067-r4 { fill: #fea62b;font-weight: bold } - .terminal-2893669067-r5 { fill: #fea62b;font-weight: bold;font-style: italic; } - .terminal-2893669067-r6 { fill: #cc555a;font-weight: bold } - .terminal-2893669067-r7 { fill: #1e1e1e } - .terminal-2893669067-r8 { fill: #1e1e1e;text-decoration: underline; } - .terminal-2893669067-r9 { fill: #fea62b;text-decoration: underline; } - .terminal-2893669067-r10 { fill: #4b4e55;text-decoration: underline; } - .terminal-2893669067-r11 { fill: #4ebf71 } - .terminal-2893669067-r12 { fill: #b93c5b } + .terminal-1056312446-r1 { fill: #e1e1e1 } + .terminal-1056312446-r2 { fill: #c5c8c6 } + .terminal-1056312446-r3 { fill: #fea62b } + .terminal-1056312446-r4 { fill: #fea62b;font-weight: bold } + .terminal-1056312446-r5 { fill: #fea62b;font-weight: bold;font-style: italic; } + .terminal-1056312446-r6 { fill: #be3f48;font-weight: bold } + .terminal-1056312446-r7 { fill: #1e1e1e } + .terminal-1056312446-r8 { fill: #1e1e1e;text-decoration: underline; } + .terminal-1056312446-r9 { fill: #fea62b;text-decoration: underline; } + .terminal-1056312446-r10 { fill: #3a3d43;text-decoration: underline; } + .terminal-1056312446-r11 { fill: #4ebf71 } + .terminal-1056312446-r12 { fill: #b93c5b } - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - BorderSubTitleAlignAll + BorderSubTitleAlignAll - - - - - - Border titleLef…▁▁▁▁Left▁▁▁▁ - This is the story ofa Pythondeveloper that - Border subtitleCen…▔▔▔▔@@@▔▔▔▔▔ - - - - - - +--------------+Title───────────────── - |had to fill up|nine labelsand ended up redoing it - +-Left-------+──────────────Subtitle - - - - - Title, but really looo… - Title, but r…Title, but reall… - because the first tryhad some labelsthat were too long. - Subtitle, bu…Subtitle, but re… - Subtitle, but really l… - + + + + + + Border titleLef…▁▁▁▁Left▁▁▁▁ + This is the story ofa Pythondeveloper that + Border subtitleCen…▔▔▔▔@@@▔▔▔▔▔ + + + + + + +--------------+Title───────────────── + |had to fill up|nine labelsand ended up redoing it + +-Left-------+──────────────Subtitle + + + + + Title, but really looo… + Title, but r…Title, but reall… + because the first tryhad some labelsthat were too long. + Subtitle, bu…Subtitle, but re… + Subtitle, but really l… + @@ -16255,137 +16416,137 @@ font-weight: 700; } - .terminal-1146140386-matrix { + .terminal-905695451-matrix { font-family: Fira Code, monospace; font-size: 20px; line-height: 24.4px; font-variant-east-asian: full-width; } - .terminal-1146140386-title { + .terminal-905695451-title { font-size: 18px; font-weight: bold; font-family: arial; } - .terminal-1146140386-r1 { fill: #e1e1e1 } - .terminal-1146140386-r2 { fill: #c5c8c6 } - .terminal-1146140386-r3 { fill: #dde6ed;font-weight: bold } - .terminal-1146140386-r4 { fill: #dde6ed } - .terminal-1146140386-r5 { fill: #fea62b;font-weight: bold;font-style: italic; } - .terminal-1146140386-r6 { fill: #e1e2e3 } - .terminal-1146140386-r7 { fill: #cc555a } - .terminal-1146140386-r8 { fill: #cc555a;font-weight: bold;font-style: italic; } + .terminal-905695451-r1 { fill: #e1e1e1 } + .terminal-905695451-r2 { fill: #c5c8c6 } + .terminal-905695451-r3 { fill: #dde6ed;font-weight: bold } + .terminal-905695451-r4 { fill: #dde6ed } + .terminal-905695451-r5 { fill: #fea62b;font-weight: bold;font-style: italic; } + .terminal-905695451-r6 { fill: #e1e2e3 } + .terminal-905695451-r7 { fill: #be3f48 } + .terminal-905695451-r8 { fill: #be3f48;font-weight: bold;font-style: italic; } - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - DataTableCursorStyles + DataTableCursorStyles - - - - Foreground is 'css', background is 'css': -  Movies      -  Severance   - Foundation - Dark - - Foreground is 'css', background is 'renderable': -  Movies      - Severance - Foundation - Dark - - Foreground is 'renderable', background is 'renderable': -  Movies      - Severance - Foundation - Dark - - Foreground is 'renderable', background is 'css': -  Movies      - Severance - Foundation - Dark + + + + Foreground is 'css', background is 'css': +  Movies      +  Severance   + Foundation + Dark + + Foreground is 'css', background is 'renderable': +  Movies      + Severance + Foundation + Dark + + Foreground is 'renderable', background is 'renderable': +  Movies      + Severance + Foundation + Dark + + Foreground is 'renderable', background is 'css': +  Movies      + Severance + Foundation + Dark @@ -16740,6 +16901,166 @@ ''' # --- +# name: test_directory_tree_reloading + ''' + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + DirectoryTreeReloadApp + + + + + + + + + + 📂 test_directory_tree_reloading0 + ├── 📂 b1 + │   ├── 📂 c1 + │   │   ┣━━ 📂 d1 + │   │   ┃   ┣━━ 📄 f1.txt + │   │   ┃   ┗━━ 📄 f2.txt + │   │   ┣━━ 📄 f1.txt + │   │   ┗━━ 📄 f2.txt + │   ├── 📄 f1.txt + │   └── 📄 f2.txt + ├── 📄 f1.txt + └── 📄 f2.txt + + + + + + + + + + + + + + + + + ''' +# --- # name: test_disabled_widgets ''' @@ -19489,137 +19810,136 @@ font-weight: 700; } - .terminal-1152611452-matrix { + .terminal-2024488306-matrix { font-family: Fira Code, monospace; font-size: 20px; line-height: 24.4px; font-variant-east-asian: full-width; } - .terminal-1152611452-title { + .terminal-2024488306-title { font-size: 18px; font-weight: bold; font-family: arial; } - .terminal-1152611452-r1 { fill: #1e1e1e } - .terminal-1152611452-r2 { fill: #e1e1e1 } - .terminal-1152611452-r3 { fill: #c5c8c6 } - .terminal-1152611452-r4 { fill: #ff0000 } - .terminal-1152611452-r5 { fill: #f8f8f2 } - .terminal-1152611452-r6 { fill: #272822 } - .terminal-1152611452-r7 { fill: #e2e3e3;font-weight: bold } + .terminal-2024488306-r1 { fill: #1e1e1e } + .terminal-2024488306-r2 { fill: #e1e1e1 } + .terminal-2024488306-r3 { fill: #c5c8c6 } + .terminal-2024488306-r4 { fill: #ff0000 } + .terminal-2024488306-r5 { fill: #e2e2e2 } + .terminal-2024488306-r6 { fill: #e2e3e3;font-weight: bold } - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - InputVsTextArea + InputVsTextArea - - - - 01234567890123456789012345678901234567890123456789012345678901234567890123456789 - ────────────────────────────────────── - - - - ────────────────────────────────────── - ────────────────────────────────────── - - - - - ────────────────────────────────────── - ────────────────────────────────────── - - - - - ────────────────────────────────────── - ────────────────────────────────────── - - Button - - - ────────────────────────────────────── + + + + 01234567890123456789012345678901234567890123456789012345678901234567890123456789 + ────────────────────────────────────── + + + + ────────────────────────────────────── + ────────────────────────────────────── + + + + + ────────────────────────────────────── + ────────────────────────────────────── + + + + + ────────────────────────────────────── + ────────────────────────────────────── + + Button + + + ────────────────────────────────────── @@ -21850,7 +22170,7 @@ ''' # --- -# name: test_markdown_example +# name: test_markdown_dark_theme_override ''' @@ -21873,140 +22193,140 @@ font-weight: 700; } - .terminal-454878977-matrix { + .terminal-2700004228-matrix { font-family: Fira Code, monospace; font-size: 20px; line-height: 24.4px; font-variant-east-asian: full-width; } - .terminal-454878977-title { + .terminal-2700004228-title { font-size: 18px; font-weight: bold; font-family: arial; } - .terminal-454878977-r1 { fill: #e1e1e1 } - .terminal-454878977-r2 { fill: #121212 } - .terminal-454878977-r3 { fill: #c5c8c6 } - .terminal-454878977-r4 { fill: #0053aa } - .terminal-454878977-r5 { fill: #dde8f3;font-weight: bold } - .terminal-454878977-r6 { fill: #939393;font-weight: bold } - .terminal-454878977-r7 { fill: #24292f } - .terminal-454878977-r8 { fill: #e2e3e3;font-weight: bold } - .terminal-454878977-r9 { fill: #4ebf71;font-weight: bold } - .terminal-454878977-r10 { fill: #e1e1e1;font-style: italic; } - .terminal-454878977-r11 { fill: #e1e1e1;font-weight: bold } + .terminal-2700004228-r1 { fill: #e1e1e1 } + .terminal-2700004228-r2 { fill: #121212 } + .terminal-2700004228-r3 { fill: #c5c8c6 } + .terminal-2700004228-r4 { fill: #0053aa } + .terminal-2700004228-r5 { fill: #dde8f3;font-weight: bold } + .terminal-2700004228-r6 { fill: #d2d2d2 } + .terminal-2700004228-r7 { fill: #859900 } + .terminal-2700004228-r8 { fill: #839496 } + .terminal-2700004228-r9 { fill: #268bd2 } + .terminal-2700004228-r10 { fill: #34535b;font-style: italic; } + .terminal-2700004228-r11 { fill: #2aa198 } - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - MarkdownExampleApp + MarkdownThemeSwitchertApp - - - - ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ - - Markdown Document - - ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ - This is an example of Textual's Markdown widget. - - ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ - -                               Features                               - - ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ - Markdown syntax and extensions are supported. - - ● Typography emphasisstronginline code etc. - ● Headers - ● Lists (bullet and ordered) - ● Syntax highlighted code blocks - ● Tables! - - - - + + + + ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ + + This is a H1 + + ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ + + + defmain(): + │   print("Hello world!") + + + + + + + + + + + + + + @@ -22014,7 +22334,500 @@ ''' # --- -# name: test_markdown_viewer_example +# name: test_markdown_example + ''' + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + MarkdownExampleApp + + + + + + + + + + ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ + + Markdown Document + + ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ + This is an example of Textual's Markdown widget. + + ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ + +                               Features                               + + ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ + Markdown syntax and extensions are supported. + + ● Typography emphasisstronginline code etc. + ● Headers + ● Lists (bullet and ordered) + ● Syntax highlighted code blocks + ● Tables! + + + + + + + + + + ''' +# --- +# name: test_markdown_light_theme_override + ''' + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + MarkdownThemeSwitchertApp + + + + + + + + + + ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ + + This is a H1 + + ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ + + + defmain(): + │   print("Hello world!") + + + + + + + + + + + + + + + + + + + + ''' +# --- +# name: test_markdown_theme_switching + ''' + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + MarkdownThemeSwitchertApp + + + + + + + + + + ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ + + This is a H1 + + ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ + + + defmain(): + │   print("Hello world!") + + + + + + + + + + + + + + + + + + + + ''' +# --- +# name: test_markdown_viewer_example ''' @@ -24729,138 +25542,138 @@ font-weight: 700; } - .terminal-2860072847-matrix { + .terminal-2568645244-matrix { font-family: Fira Code, monospace; font-size: 20px; line-height: 24.4px; font-variant-east-asian: full-width; } - .terminal-2860072847-title { + .terminal-2568645244-title { font-size: 18px; font-weight: bold; font-family: arial; } - .terminal-2860072847-r1 { fill: #1e1e1e } - .terminal-2860072847-r2 { fill: #0178d4 } - .terminal-2860072847-r3 { fill: #c5c8c6 } - .terminal-2860072847-r4 { fill: #ddedf9;font-weight: bold } - .terminal-2860072847-r5 { fill: #e2e2e2;font-weight: bold } - .terminal-2860072847-r6 { fill: #e2e2e2 } - .terminal-2860072847-r7 { fill: #434343 } - .terminal-2860072847-r8 { fill: #cc555a } - .terminal-2860072847-r9 { fill: #e1e1e1 } + .terminal-2568645244-r1 { fill: #1e1e1e } + .terminal-2568645244-r2 { fill: #0178d4 } + .terminal-2568645244-r3 { fill: #c5c8c6 } + .terminal-2568645244-r4 { fill: #ddedf9;font-weight: bold } + .terminal-2568645244-r5 { fill: #e2e2e2;font-weight: bold } + .terminal-2568645244-r6 { fill: #e2e2e2 } + .terminal-2568645244-r7 { fill: #434343 } + .terminal-2568645244-r8 { fill: #be3f48 } + .terminal-2568645244-r9 { fill: #e1e1e1 } - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - OptionListApp + OptionListApp - + - - ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ - OneOneOne - TwoTwoTwo - ──────────────────────────────────────────────────────────────────── - ThreeThreeThree - ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ - - - - - - - - - - - - - - - - - + + ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ + OneOneOne + TwoTwoTwo + ──────────────────────────────────────────────────────────────────── + ThreeThreeThree + ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ + + + + + + + + + + + + + + + + + @@ -25515,6 +26328,165 @@ ''' # --- +# name: test_option_list_scrolling_in_long_list + ''' + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + LongOptionListApp + + + + + + + + + + ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ + This is option #78 + This is option #79 + This is option #80 + This is option #81 + This is option #82 + This is option #83 + This is option #84 + This is option #85 + This is option #86 + This is option #87 + This is option #88 + This is option #89 + This is option #90 + This is option #91 + This is option #92 + This is option #93 + This is option #94 + This is option #95▇▇ + This is option #96 + This is option #97 + This is option #98 + This is option #99 + ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ + + + + + ''' +# --- # name: test_option_list_strings ''' @@ -27939,140 +28911,140 @@ font-weight: 700; } - .terminal-2449642391-matrix { + .terminal-386310630-matrix { font-family: Fira Code, monospace; font-size: 20px; line-height: 24.4px; font-variant-east-asian: full-width; } - .terminal-2449642391-title { + .terminal-386310630-title { font-size: 18px; font-weight: bold; font-family: arial; } - .terminal-2449642391-r1 { fill: #e1e1e1 } - .terminal-2449642391-r2 { fill: #c5c8c6 } - .terminal-2449642391-r3 { fill: #1e1e1e } - .terminal-2449642391-r4 { fill: #0178d4 } - .terminal-2449642391-r5 { fill: #575757 } - .terminal-2449642391-r6 { fill: #262626;font-weight: bold } - .terminal-2449642391-r7 { fill: #e2e2e2 } - .terminal-2449642391-r8 { fill: #e2e2e2;text-decoration: underline; } - .terminal-2449642391-r9 { fill: #434343 } - .terminal-2449642391-r10 { fill: #4ebf71;font-weight: bold } - .terminal-2449642391-r11 { fill: #cc555a;font-weight: bold;font-style: italic; } + .terminal-386310630-r1 { fill: #e1e1e1 } + .terminal-386310630-r2 { fill: #c5c8c6 } + .terminal-386310630-r3 { fill: #1e1e1e } + .terminal-386310630-r4 { fill: #0178d4 } + .terminal-386310630-r5 { fill: #575757 } + .terminal-386310630-r6 { fill: #262626;font-weight: bold } + .terminal-386310630-r7 { fill: #e2e2e2 } + .terminal-386310630-r8 { fill: #e2e2e2;text-decoration: underline; } + .terminal-386310630-r9 { fill: #434343 } + .terminal-386310630-r10 { fill: #4ebf71;font-weight: bold } + .terminal-386310630-r11 { fill: #be3f48;font-weight: bold;font-style: italic; } - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - RadioChoicesApp + RadioChoicesApp - + - - - - - - - - ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ - Battlestar Galactica Amanda -  Dune 1984 Connor MacLeod -  Dune 2021 Duncan MacLeod -  Serenity Heather MacLeod -  Star Trek: The Motion Pictur Joe Dawson -  Star Wars: A New Hope Kurgan, The -  The Last Starfighter Methos -  Total Recall 👉 🔴 Rachel Ellenstein -  Wing Commander Ramírez - ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ - - - - - - + + + + + + + + ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ + Battlestar Galactica Amanda +  Dune 1984 Connor MacLeod +  Dune 2021 Duncan MacLeod +  Serenity Heather MacLeod +  Star Trek: The Motion Pictur Joe Dawson +  Star Wars: A New Hope Kurgan, The +  The Last Starfighter Methos +  Total Recall 👉 🔴 Rachel Ellenstein +  Wing Commander Ramírez + ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ + + + + + + @@ -29366,137 +30338,137 @@ font-weight: 700; } - .terminal-1777085921-matrix { + .terminal-565918768-matrix { font-family: Fira Code, monospace; font-size: 20px; line-height: 24.4px; font-variant-east-asian: full-width; } - .terminal-1777085921-title { + .terminal-565918768-title { font-size: 18px; font-weight: bold; font-family: arial; } - .terminal-1777085921-r1 { fill: #e1e1e1 } - .terminal-1777085921-r2 { fill: #c5c8c6 } - .terminal-1777085921-r3 { fill: #004578 } - .terminal-1777085921-r4 { fill: #23568b } - .terminal-1777085921-r5 { fill: #fea62b } - .terminal-1777085921-r6 { fill: #cc555a } - .terminal-1777085921-r7 { fill: #14191f } + .terminal-565918768-r1 { fill: #e1e1e1 } + .terminal-565918768-r2 { fill: #c5c8c6 } + .terminal-565918768-r3 { fill: #004578 } + .terminal-565918768-r4 { fill: #23568b } + .terminal-565918768-r5 { fill: #fea62b } + .terminal-565918768-r6 { fill: #be3f48 } + .terminal-565918768-r7 { fill: #14191f } - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - MyApp + MyApp - + - - SPAM - SPAM - SPAM - ──────────────────────────────────────────────────────────────────────────── - SPAM - SPAM - SPAM - SPAM - SPAM - SPAM▄▄ - SPAM - SPAM - ──────────────────────────────────────────────────────────────────────── - @@@@@@@@@@@@@@@@@@@@@@@@@@@@@@>>bullseye<<@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@ - ▇▇ - ▄▄ - - - - - - - - ──────────────────────────────────────────────────────────────────────────── + + SPAM + SPAM + SPAM + ──────────────────────────────────────────────────────────────────────────── + SPAM + SPAM + SPAM + SPAM + SPAM + SPAM▄▄ + SPAM + SPAM + ──────────────────────────────────────────────────────────────────────── + @@@@@@@@@@@@@@@@@@@@@@@@@@@@@@>>bullseye<<@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@ + ▇▇ + ▄▄ + + + + + + + + ──────────────────────────────────────────────────────────────────────────── @@ -30971,141 +31943,141 @@ font-weight: 700; } - .terminal-2124086361-matrix { + .terminal-1048388837-matrix { font-family: Fira Code, monospace; font-size: 20px; line-height: 24.4px; font-variant-east-asian: full-width; } - .terminal-2124086361-title { + .terminal-1048388837-title { font-size: 18px; font-weight: bold; font-family: arial; } - .terminal-2124086361-r1 { fill: #c5c8c6 } - .terminal-2124086361-r2 { fill: #e3e3e3 } - .terminal-2124086361-r3 { fill: #e1e1e1 } - .terminal-2124086361-r4 { fill: #0178d4 } - .terminal-2124086361-r5 { fill: #e1e1e1;font-weight: bold } - .terminal-2124086361-r6 { fill: #575757 } - .terminal-2124086361-r7 { fill: #4ebf71;font-weight: bold } - .terminal-2124086361-r8 { fill: #ddedf9;font-weight: bold } - .terminal-2124086361-r9 { fill: #98a84b } - .terminal-2124086361-r10 { fill: #262626;font-weight: bold } - .terminal-2124086361-r11 { fill: #e2e2e2 } - .terminal-2124086361-r12 { fill: #ddedf9 } + .terminal-1048388837-r1 { fill: #c5c8c6 } + .terminal-1048388837-r2 { fill: #e3e3e3 } + .terminal-1048388837-r3 { fill: #e1e1e1 } + .terminal-1048388837-r4 { fill: #0178d4 } + .terminal-1048388837-r5 { fill: #e1e1e1;font-weight: bold } + .terminal-1048388837-r6 { fill: #575757 } + .terminal-1048388837-r7 { fill: #4ebf71;font-weight: bold } + .terminal-1048388837-r8 { fill: #ddedf9;font-weight: bold } + .terminal-1048388837-r9 { fill: #879a3b } + .terminal-1048388837-r10 { fill: #262626;font-weight: bold } + .terminal-1048388837-r11 { fill: #e2e2e2 } + .terminal-1048388837-r12 { fill: #ddedf9 } - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - SelectionListApp + SelectionListApp - + - - SelectionListApp - - -  Shall we play some games? ── Selected games ───────────── - [ - XFalken's Maze'secret_back_door', - XBlack Jack'a_nice_game_of_chess', - XGin Rummy'fighter_combat' - XHearts] - XBridge────────────────────────────── - XCheckers - XChess - XPoker - XFighter Combat - - ────────────────────────────── - - - - - - - + + SelectionListApp + + +  Shall we play some games? ── Selected games ───────────── + [ + XFalken's Maze'secret_back_door', + XBlack Jack'a_nice_game_of_chess', + XGin Rummy'fighter_combat' + XHearts] + XBridge────────────────────────────── + XCheckers + XChess + XPoker + XFighter Combat + + ────────────────────────────── + + + + + + + @@ -33056,136 +34028,136 @@ font-weight: 700; } - .terminal-2727430444-matrix { + .terminal-1404658517-matrix { font-family: Fira Code, monospace; font-size: 20px; line-height: 24.4px; font-variant-east-asian: full-width; } - .terminal-2727430444-title { + .terminal-1404658517-title { font-size: 18px; font-weight: bold; font-family: arial; } - .terminal-2727430444-r1 { fill: #e1e1e1 } - .terminal-2727430444-r2 { fill: #c5c8c6 } - .terminal-2727430444-r3 { fill: #e1e1e1;font-weight: bold } - .terminal-2727430444-r4 { fill: #98a84b;font-weight: bold;font-style: italic; } - .terminal-2727430444-r5 { fill: #98729f;font-weight: bold } - .terminal-2727430444-r6 { fill: #e1e1e1;font-style: italic; } - .terminal-2727430444-r7 { fill: #e1e1e1;text-decoration: underline; } + .terminal-1404658517-r1 { fill: #e1e1e1 } + .terminal-1404658517-r2 { fill: #c5c8c6 } + .terminal-1404658517-r3 { fill: #e1e1e1;font-weight: bold } + .terminal-1404658517-r4 { fill: #879a3b;font-weight: bold;font-style: italic; } + .terminal-1404658517-r5 { fill: #855c8d;font-weight: bold } + .terminal-1404658517-r6 { fill: #e1e1e1;font-style: italic; } + .terminal-1404658517-r7 { fill: #e1e1e1;text-decoration: underline; } - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - TableStaticApp + TableStaticApp - + - - ┏━━━━━━━━━━━━━━┳━━━━━━━━┳━━━━━━━━━━━┓ - FooBar   baz       - ┡━━━━━━━━━━━━━━╇━━━━━━━━╇━━━━━━━━━━━┩ - Hello World!ItalicUnderline - └──────────────┴────────┴───────────┘ - - - - - - - - - - - - - - - - - - + + ┏━━━━━━━━━━━━━━┳━━━━━━━━┳━━━━━━━━━━━┓ + FooBar   baz       + ┡━━━━━━━━━━━━━━╇━━━━━━━━╇━━━━━━━━━━━┩ + Hello World!ItalicUnderline + └──────────────┴────────┴───────────┘ + + + + + + + + + + + + + + + + + + @@ -35825,6 +36797,89 @@ ''' # --- +# name: test_text_area_read_only_cursor_rendering + ''' + + + + + + + + + + + + + + + + + + + + + + + TextAreaSnapshot + + + + + + + + + + ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ + 1  Hello, world!           + + + ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ + + + + + ''' +# --- # name: test_text_area_selection_rendering[selection0] ''' @@ -36416,77 +37471,82 @@ font-weight: 700; } - .terminal-3978829874-matrix { + .terminal-421307802-matrix { font-family: Fira Code, monospace; font-size: 20px; line-height: 24.4px; font-variant-east-asian: full-width; } - .terminal-3978829874-title { + .terminal-421307802-title { font-size: 18px; font-weight: bold; font-family: arial; } - .terminal-3978829874-r1 { fill: #1e1e1e } - .terminal-3978829874-r2 { fill: #0178d4 } - .terminal-3978829874-r3 { fill: #c5c8c6 } - .terminal-3978829874-r4 { fill: #7d7e7a } - .terminal-3978829874-r5 { fill: #f8f8f2 } - .terminal-3978829874-r6 { fill: #abaca9;font-weight: bold } - .terminal-3978829874-r7 { fill: #151515 } + .terminal-421307802-r1 { fill: #1e1e1e } + .terminal-421307802-r2 { fill: #0178d4 } + .terminal-421307802-r3 { fill: #c5c8c6 } + .terminal-421307802-r4 { fill: #7d7e7a } + .terminal-421307802-r5 { fill: #569cd6 } + .terminal-421307802-r6 { fill: #f8f8f2 } + .terminal-421307802-r7 { fill: #4ec9b0 } + .terminal-421307802-r8 { fill: #abaca9;font-weight: bold } + .terminal-421307802-r9 { fill: #b5cea8 } + .terminal-421307802-r10 { fill: #151515 } + .terminal-421307802-r11 { fill: #7daf9c } + .terminal-421307802-r12 { fill: #ce9178 } - + - + - + - + - + - + - + - + - + - TextAreaSnapshot + TextAreaSnapshot - - - - ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ - 1  def hello(name): - 2      x = 123                               - 3      while not False:  - 4          print("hello " + name)  - 5          continue  - 6   - - ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ + + + + ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ + 1  defhello(name): + 2      x =123 + 3  whilenotFalse + 4  print("hello "+ name)  + 5  continue + 6   + + ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ @@ -37769,146 +38829,146 @@ font-weight: 700; } - .terminal-4085160594-matrix { + .terminal-3972235479-matrix { font-family: Fira Code, monospace; font-size: 20px; line-height: 24.4px; font-variant-east-asian: full-width; } - .terminal-4085160594-title { + .terminal-3972235479-title { font-size: 18px; font-weight: bold; font-family: arial; } - .terminal-4085160594-r1 { fill: #c5c8c6 } - .terminal-4085160594-r2 { fill: #e3e3e3 } - .terminal-4085160594-r3 { fill: #e1e1e1 } - .terminal-4085160594-r4 { fill: #e1e1e1;text-decoration: underline; } - .terminal-4085160594-r5 { fill: #e1e1e1;font-weight: bold } - .terminal-4085160594-r6 { fill: #e1e1e1;font-style: italic; } - .terminal-4085160594-r7 { fill: #98729f;font-weight: bold } - .terminal-4085160594-r8 { fill: #d0b344 } - .terminal-4085160594-r9 { fill: #98a84b } - .terminal-4085160594-r10 { fill: #00823d;font-style: italic; } - .terminal-4085160594-r11 { fill: #ffcf56 } - .terminal-4085160594-r12 { fill: #e76580 } - .terminal-4085160594-r13 { fill: #fea62b;font-weight: bold } - .terminal-4085160594-r14 { fill: #f5e5e9;font-weight: bold } - .terminal-4085160594-r15 { fill: #b86b00 } - .terminal-4085160594-r16 { fill: #780028 } + .terminal-3972235479-r1 { fill: #c5c8c6 } + .terminal-3972235479-r2 { fill: #e3e3e3 } + .terminal-3972235479-r3 { fill: #e1e1e1 } + .terminal-3972235479-r4 { fill: #e1e1e1;text-decoration: underline; } + .terminal-3972235479-r5 { fill: #e1e1e1;font-weight: bold } + .terminal-3972235479-r6 { fill: #e1e1e1;font-style: italic; } + .terminal-3972235479-r7 { fill: #855c8d;font-weight: bold } + .terminal-3972235479-r8 { fill: #c5a635 } + .terminal-3972235479-r9 { fill: #879a3b } + .terminal-3972235479-r10 { fill: #0f722f;font-style: italic; } + .terminal-3972235479-r11 { fill: #ffcf56 } + .terminal-3972235479-r12 { fill: #e76580 } + .terminal-3972235479-r13 { fill: #fea62b;font-weight: bold } + .terminal-3972235479-r14 { fill: #f5e5e9;font-weight: bold } + .terminal-3972235479-r15 { fill: #b86b00 } + .terminal-3972235479-r16 { fill: #780028 } - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - Textual Keys + Textual Keys - + - - Textual Keys - ╭────────────────────────────────────────────────────────────────────────────╮ - Press some keys! - - To quit the app press ctrl+ctwice or press the Quit button below. - ╰────────────────────────────────────────────────────────────────────────────╯ - Key(key='a'character='a'name='a'is_printable=True) - Key(key='b'character='b'name='b'is_printable=True) - - - - - - - - - - - - - - ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ - ClearQuit - ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ + + Textual Keys + ╭────────────────────────────────────────────────────────────────────────────╮ + Press some keys! + + To quit the app press ctrl+ctwice or press the Quit button below. + ╰────────────────────────────────────────────────────────────────────────────╯ + Key(key='a'character='a'name='a'is_printable=True) + Key(key='b'character='b'name='b'is_printable=True) + + + + + + + + + + + + + + ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ + ClearQuit + ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ diff --git a/tests/snapshot_tests/snapshot_apps/command_palette_discovery.py b/tests/snapshot_tests/snapshot_apps/command_palette_discovery.py new file mode 100644 index 0000000000..3b8a03496a --- /dev/null +++ b/tests/snapshot_tests/snapshot_apps/command_palette_discovery.py @@ -0,0 +1,38 @@ +from textual.app import App +from textual.command import DiscoveryHit, Hit, Hits, Provider + + +class TestSource(Provider): + def goes_nowhere_does_nothing(self) -> None: + pass + + async def discover(self) -> Hits: + for n in range(10): + command = f"This is a test of this code {n}" + yield DiscoveryHit( + command, + self.goes_nowhere_does_nothing, + command, + ) + + async def search(self, query: str) -> Hits: + matcher = self.matcher(query) + for n in range(10): + command = f"This should not appear {n}" + yield Hit( + n / 10, + matcher.highlight(command), + self.goes_nowhere_does_nothing, + command, + ) + + +class CommandPaletteApp(App[None]): + COMMANDS = {TestSource} + + def on_mount(self) -> None: + self.action_command_palette() + + +if __name__ == "__main__": + CommandPaletteApp().run() diff --git a/tests/snapshot_tests/snapshot_apps/directory_tree_reload.py b/tests/snapshot_tests/snapshot_apps/directory_tree_reload.py new file mode 100644 index 0000000000..f1dcc07941 --- /dev/null +++ b/tests/snapshot_tests/snapshot_apps/directory_tree_reload.py @@ -0,0 +1,62 @@ +from pathlib import Path + +from textual.app import App, ComposeResult +from textual.widgets import DirectoryTree + + +class DirectoryTreeReloadApp(App[None]): + BINDINGS = [ + ("r", "reload"), + ("e", "expand"), + ("d", "delete"), + ] + + async def setup(self, path_root: Path) -> None: + self.path_root = path_root + + structure = [ + "f1.txt", + "f2.txt", + "b1/f1.txt", + "b1/f2.txt", + "b2/f1.txt", + "b2/f2.txt", + "b1/c1/f1.txt", + "b1/c1/f2.txt", + "b1/c2/f1.txt", + "b1/c2/f2.txt", + "b1/c1/d1/f1.txt", + "b1/c1/d1/f2.txt", + "b1/c1/d2/f1.txt", + "b1/c1/d2/f2.txt", + ] + for file in structure: + path = path_root / Path(file) + path.parent.mkdir(parents=True, exist_ok=True) + path.touch(exist_ok=True) + + await self.mount(DirectoryTree(self.path_root)) + + async def action_reload(self) -> None: + dt = self.query_one(DirectoryTree) + await dt.reload() + + def action_expand(self) -> None: + self.query_one(DirectoryTree).root.expand_all() + + def action_delete(self) -> None: + self.rmdir(self.path_root / Path("b1/c1/d2")) + self.rmdir(self.path_root / Path("b1/c2")) + self.rmdir(self.path_root / Path("b2")) + + def rmdir(self, path: Path) -> None: + for file in path.iterdir(): + if file.is_file(): + file.unlink() + elif file.is_dir(): + self.rmdir(file) + path.rmdir() + + +if __name__ == "__main__": + DirectoryTreeReloadApp(Path("playground")).run() diff --git a/tests/snapshot_tests/snapshot_apps/markdown_theme_switcher.py b/tests/snapshot_tests/snapshot_apps/markdown_theme_switcher.py new file mode 100644 index 0000000000..698d0c2c78 --- /dev/null +++ b/tests/snapshot_tests/snapshot_apps/markdown_theme_switcher.py @@ -0,0 +1,33 @@ +from textual.app import App, ComposeResult +from textual.widgets import Markdown + +TEST_CODE_MARKDOWN = """ +# This is a H1 +```python +def main(): + print("Hello world!") +``` +""" + + +class MarkdownThemeSwitchertApp(App[None]): + BINDINGS = [ + ("t", "toggle_dark"), + ("d", "switch_dark"), + ("l", "switch_light"), + ] + + def action_switch_dark(self) -> None: + md = self.query_one(Markdown) + md.code_dark_theme = "solarized-dark" + + def action_switch_light(self) -> None: + md = self.query_one(Markdown) + md.code_light_theme = "solarized-light" + + def compose(self) -> ComposeResult: + yield Markdown(TEST_CODE_MARKDOWN) + + +if __name__ == "__main__": + MarkdownThemeSwitchertApp().run() diff --git a/tests/snapshot_tests/snapshot_apps/option_list_long.py b/tests/snapshot_tests/snapshot_apps/option_list_long.py new file mode 100644 index 0000000000..7971defa10 --- /dev/null +++ b/tests/snapshot_tests/snapshot_apps/option_list_long.py @@ -0,0 +1,12 @@ +from textual.app import App, ComposeResult +from textual.widgets import OptionList +from textual.widgets.option_list import Option + + +class LongOptionListApp(App[None]): + def compose(self) -> ComposeResult: + yield OptionList(*[Option(f"This is option #{n}") for n in range(100)]) + + +if __name__ == "__main__": + LongOptionListApp().run() diff --git a/tests/snapshot_tests/snapshot_apps/text_area_wrapping.py b/tests/snapshot_tests/snapshot_apps/text_area_wrapping.py index 8264438be4..c1fdcdff70 100644 --- a/tests/snapshot_tests/snapshot_apps/text_area_wrapping.py +++ b/tests/snapshot_tests/snapshot_apps/text_area_wrapping.py @@ -23,7 +23,7 @@ class TextAreaWrapping(App): def compose(self) -> ComposeResult: - yield TextArea.code_editor(TEXT, language="markdown", soft_wrap=True) + yield TextArea.code_editor(TEXT, language="markdown", theme="monokai", soft_wrap=True) app = TextAreaWrapping() diff --git a/tests/snapshot_tests/test_snapshots.py b/tests/snapshot_tests/test_snapshots.py index f79f9be42a..8adff1659f 100644 --- a/tests/snapshot_tests/test_snapshots.py +++ b/tests/snapshot_tests/test_snapshots.py @@ -229,6 +229,22 @@ def test_markdown_viewer_example(snap_compare): assert snap_compare(WIDGET_EXAMPLES_DIR / "markdown_viewer.py") +def test_markdown_theme_switching(snap_compare): + assert snap_compare(SNAPSHOT_APPS_DIR / "markdown_theme_switcher.py", press=["t"]) + + +def test_markdown_dark_theme_override(snap_compare): + assert snap_compare( + SNAPSHOT_APPS_DIR / "markdown_theme_switcher.py", press=["d", "wait:100"] + ) + + +def test_markdown_light_theme_override(snap_compare): + assert snap_compare( + SNAPSHOT_APPS_DIR / "markdown_theme_switcher.py", press=["l", "t", "wait:100"] + ) + + def test_checkbox_example(snap_compare): assert snap_compare(WIDGET_EXAMPLES_DIR / "checkbox.py") @@ -296,6 +312,10 @@ def test_option_list_replace_prompt_from_two_lines_to_three_lines(snap_compare): ) +def test_option_list_scrolling_in_long_list(snap_compare): + assert snap_compare(SNAPSHOT_APPS_DIR / "option_list_long.py", press=["up"]) + + def test_progress_bar_indeterminate(snap_compare): assert snap_compare(WIDGET_EXAMPLES_DIR / "progress_bar_isolated_.py", press=["f"]) @@ -403,6 +423,19 @@ def test_collapsible_custom_symbol(snap_compare): assert snap_compare(WIDGET_EXAMPLES_DIR / "collapsible_custom_symbol.py") +def test_directory_tree_reloading(snap_compare, tmp_path): + async def run_before(pilot): + await pilot.app.setup(tmp_path) + await pilot.press( + "e", "e", "down", "down", "down", "down", "e", "down", "d", "r" + ) + + assert snap_compare( + SNAPSHOT_APPS_DIR / "directory_tree_reload.py", + run_before=run_before, + ) + + # --- CSS properties --- # We have a canonical example for each CSS property that is shown in their docs. # If any of these change, something has likely broken, so snapshot each of them. @@ -724,6 +757,14 @@ async def run_before(pilot) -> None: assert snap_compare(SNAPSHOT_APPS_DIR / "command_palette.py", run_before=run_before) +def test_command_palette_discovery(snap_compare) -> None: + async def run_before(pilot) -> None: + pilot.app.screen.query_one(Input).cursor_blink = False + await pilot.app.screen.workers.wait_for_complete() + + assert snap_compare(SNAPSHOT_APPS_DIR / "command_palette_discovery.py", run_before=run_before) + + # --- textual-dev library preview tests --- @@ -846,6 +887,20 @@ def setup_selection(pilot): ) +def test_text_area_read_only_cursor_rendering(snap_compare): + def setup_selection(pilot): + text_area = pilot.app.query_one(TextArea) + text_area.theme = "css" + text_area.text = "Hello, world!" + text_area.read_only = True + + assert snap_compare( + SNAPSHOT_APPS_DIR / "text_area.py", + run_before=setup_selection, + terminal_size=(30, 5), + ) + + @pytest.mark.syntax @pytest.mark.parametrize( "theme_name", [theme.name for theme in TextAreaTheme.builtin_themes()] @@ -879,8 +934,7 @@ def setup_theme(pilot): @pytest.mark.syntax def test_text_area_wrapping_and_folding(snap_compare): assert snap_compare( - SNAPSHOT_APPS_DIR / "text_area_wrapping.py", - terminal_size=(20, 26) + SNAPSHOT_APPS_DIR / "text_area_wrapping.py", terminal_size=(20, 26) ) diff --git a/tests/test_data_bind.py b/tests/test_data_bind.py new file mode 100644 index 0000000000..ba29e87d1e --- /dev/null +++ b/tests/test_data_bind.py @@ -0,0 +1,85 @@ +import pytest + +from textual.app import App, ComposeResult +from textual.reactive import ReactiveError, reactive +from textual.widgets import Label + + +class FooLabel(Label): + foo = reactive("Foo") + + def render(self) -> str: + return self.foo + + +class DataBindApp(App): + bar = reactive("Bar") + + def compose(self) -> ComposeResult: + yield FooLabel(id="label1").data_bind(foo=DataBindApp.bar) + yield FooLabel(id="label2") # Not bound + + +async def test_data_binding(): + app = DataBindApp() + async with app.run_test(): + + # Check default + assert app.bar == "Bar" + + label1 = app.query_one("#label1", FooLabel) + label2 = app.query_one("#label2", FooLabel) + + # These are bound, so should have the same value as the App.foo + assert label1.foo == "Bar" + # Not yet bound, so should have its own default + assert label2.foo == "Foo" + + # Changing this reactive, should also change the bound widgets + app.bar = "Baz" + + # Sanity check + assert app.bar == "Baz" + + # Should also have updated bound labels + assert label1.foo == "Baz" + assert label2.foo == "Foo" + + with pytest.raises(ReactiveError): + # This should be an error because FooLabel.foo is not defined on the app + label2.data_bind(foo=FooLabel.foo) + + # Bind data outside of compose + label2.data_bind(foo=DataBindApp.bar) + # Confirm new binding has propagated + assert label2.foo == "Baz" + + # Set reactive and check propagation + app.bar = "Egg" + assert label1.foo == "Egg" + assert label2.foo == "Egg" + + # Test nothing goes awry when removing widget with bound data + await label1.remove() + + # Try one last time + app.bar = "Spam" + + # Confirm remaining widgets still propagate + assert label2.foo == "Spam" + + +async def test_data_binding_missing_reactive(): + + class DataBindErrorApp(App): + foo = reactive("Bar") + + def compose(self) -> ComposeResult: + yield FooLabel(id="label1").data_bind( + nofoo=DataBindErrorApp.foo + ) # Missing reactive + + app = DataBindErrorApp() + with pytest.raises(ReactiveError): + async with app.run_test(): + pass diff --git a/tests/test_reactive.py b/tests/test_reactive.py index 8ee7861a2a..7bddb3df79 100644 --- a/tests/test_reactive.py +++ b/tests/test_reactive.py @@ -575,3 +575,133 @@ def callback(self): await pilot.pause() assert from_holder assert from_app + + +async def test_set_reactive(): + """Test set_reactive doesn't call watchers.""" + + class MyWidget(Widget): + foo = reactive("") + + def __init__(self, foo: str) -> None: + super().__init__() + self.set_reactive(MyWidget.foo, foo) + + def watch_foo(self) -> None: + # Should never get here + 1 / 0 + + class MyApp(App): + def compose(self) -> ComposeResult: + yield MyWidget("foobar") + + app = MyApp() + async with app.run_test(): + assert app.query_one(MyWidget).foo == "foobar" + + +async def test_no_duplicate_external_watchers() -> None: + """Make sure we skip duplicated watchers.""" + + counter = 0 + + class Holder(Widget): + attr = var(None) + + class MyApp(App[None]): + def __init__(self) -> None: + super().__init__() + self.holder = Holder() + + def on_mount(self) -> None: + self.watch(self.holder, "attr", self.callback) + self.watch(self.holder, "attr", self.callback) + + def callback(self) -> None: + nonlocal counter + counter += 1 + + app = MyApp() + async with app.run_test(): + assert counter == 1 + app.holder.attr = 73 + assert counter == 2 + + +async def test_external_watch_init_does_not_propagate() -> None: + """Regression test for https://github.com/Textualize/textual/issues/3878. + + Make sure that when setting an extra watcher programmatically and `init` is set, + we init only the new watcher and not the other ones, but at the same + time make sure both watchers work in regular circumstances. + """ + + logs: list[str] = [] + + class SomeWidget(Widget): + test_1: var[int] = var(0) + test_2: var[int] = var(0, init=False) + + def watch_test_1(self) -> None: + logs.append("test_1") + + def watch_test_2(self) -> None: + logs.append("test_2") + + class InitOverrideApp(App[None]): + def compose(self) -> ComposeResult: + yield SomeWidget() + + def on_mount(self) -> None: + def watch_test_2_extra() -> None: + logs.append("test_2_extra") + + self.watch(self.query_one(SomeWidget), "test_2", watch_test_2_extra) + + app = InitOverrideApp() + async with app.run_test(): + assert logs == ["test_1", "test_2_extra"] + app.query_one(SomeWidget).test_2 = 73 + assert logs.count("test_2_extra") == 2 + assert logs.count("test_2") == 1 + + +async def test_external_watch_init_does_not_propagate_to_externals() -> None: + """Regression test for https://github.com/Textualize/textual/issues/3878. + + Make sure that when setting an extra watcher programmatically and `init` is set, + we init only the new watcher and not the other ones (even if they were + added dynamically with `watch`), but at the same time make sure all watchers + work in regular circumstances. + """ + + logs: list[str] = [] + + class SomeWidget(Widget): + test_var: var[int] = var(0) + + class MyApp(App[None]): + def compose(self) -> ComposeResult: + yield SomeWidget() + + def add_first_watcher(self) -> None: + def first_callback() -> None: + logs.append("first") + + self.watch(self.query_one(SomeWidget), "test_var", first_callback) + + def add_second_watcher(self) -> None: + def second_callback() -> None: + logs.append("second") + + self.watch(self.query_one(SomeWidget), "test_var", second_callback) + + app = MyApp() + async with app.run_test(): + assert logs == [] + app.add_first_watcher() + assert logs == ["first"] + app.add_second_watcher() + assert logs == ["first", "second"] + app.query_one(SomeWidget).test_var = 73 + assert logs == ["first", "second", "first", "second"] diff --git a/tests/test_tabbed_content.py b/tests/test_tabbed_content.py index 28765e8fc3..6d7b0976e9 100644 --- a/tests/test_tabbed_content.py +++ b/tests/test_tabbed_content.py @@ -1,3 +1,5 @@ +from __future__ import annotations + import pytest from textual.app import App, ComposeResult @@ -137,13 +139,12 @@ def compose(self) -> ComposeResult: async def test_tabbed_content_messages(): class TabbedApp(App): - message = None + activation_history: list[Tab] = [] def compose(self) -> ComposeResult: - with TabbedContent(initial="bar"): + with TabbedContent(): with TabPane("foo", id="foo"): yield Label("Foo", id="foo-label") - with TabPane("bar", id="bar"): yield Label("Bar", id="bar-label") with TabPane("baz", id="baz"): @@ -152,15 +153,19 @@ def compose(self) -> ComposeResult: def on_tabbed_content_tab_activated( self, event: TabbedContent.TabActivated ) -> None: - self.message = event + self.activation_history.append(event.tab) app = TabbedApp() async with app.run_test() as pilot: tabbed_content = app.query_one(TabbedContent) tabbed_content.active = "bar" await pilot.pause() - assert isinstance(app.message, TabbedContent.TabActivated) - assert app.message.tab.label.plain == "bar" + assert app.activation_history == [ + # foo was originally activated. + app.query_one(TabbedContent).get_tab("foo"), + # then we did bar "by hand" + app.query_one(TabbedContent).get_tab("bar"), + ] async def test_tabbed_content_add_later_from_empty(): diff --git a/tests/test_widget_navigation.py b/tests/test_widget_navigation.py new file mode 100644 index 0000000000..a322f3846f --- /dev/null +++ b/tests/test_widget_navigation.py @@ -0,0 +1,160 @@ +from __future__ import annotations + +import pytest + +from textual._widget_navigation import ( + find_first_enabled, + find_last_enabled, + find_next_enabled, + find_next_enabled_no_wrap, + get_directed_distance, +) + + +class _D: + def __init__(self, disabled): + self.disabled = disabled + + +# Represent disabled/enabled objects that are compact to write in tests. +D = _D(True) +E = _D(False) + + +@pytest.mark.parametrize( + ["index", "start", "direction", "wrap_at", "dist"], + [ + (2, 8, 1, 10, 4), + (2, 8, -1, 10, 6), + (8, 2, -1, 10, 4), + (8, 2, 1, 10, 6), + (8, 2, 1, 1234123512, 6), + (2, 8, 1, 11, 5), + (2, 8, 1, 12, 6), + (5, 5, 1, 10, 0), + ], +) +def test_distance(index, start, direction, wrap_at, dist): + assert ( + get_directed_distance( + index=index, + start=start, + direction=direction, + wrap_at=wrap_at, + ) + == dist + ) + + +@pytest.mark.parametrize( + "function", + [ + find_first_enabled, + find_last_enabled, + ], +) +def test_find_enabled_returns_none_on_empty(function): + assert function([]) is None + + +@pytest.mark.parametrize( + ["candidates", "anchor", "direction", "result"], + [ + # No anchor & no candidates -> no next + ([], None, 1, None), + ([], None, -1, None), + # No anchor but candidates -> get first/last one + ([E], None, 1, 0), + ([E, D], None, 1, 0), + ([E, E], None, 1, 0), + ([D, E], None, 1, 1), + ([D, D, E], None, 1, 2), + ([E], None, -1, 0), + ([E, E], None, -1, 1), + ([E, D], None, -1, 0), + ([E, D, D], None, -1, 0), + # No enabled candidates -> return the anchor + ([D, D, D], 0, 1, 0), + ([D, D, D], 1, 1, 1), + ([D, D, D], 1, -1, 1), + ([D, D, D], None, -1, None), + # General case + # 0 1 2 3 4 5 + ([E, D, D, E, E, D], 0, 1, 3), + ([E, D, D, E, E, D], 0, -1, 4), + ([E, D, D, E, E, D], 1, 1, 3), + ([E, D, D, E, E, D], 1, -1, 0), + ([E, D, D, E, E, D], 2, 1, 3), + ([E, D, D, E, E, D], 2, -1, 0), + ([E, D, D, E, E, D], 3, 1, 4), + ([E, D, D, E, E, D], 3, -1, 0), + ([E, D, D, E, E, D], 4, 1, 0), + ([E, D, D, E, E, D], 4, -1, 3), + ([E, D, D, E, E, D], 5, 1, 0), + ([E, D, D, E, E, D], 5, -1, 4), + ], +) +def test_find_next_enabled(candidates, anchor, direction, result): + assert find_next_enabled(candidates, anchor, direction) == result + + +@pytest.mark.parametrize( + ["candidates", "anchor", "direction", "result"], + [ + # No anchor & no candidates -> no next + ([], None, 1, None), + ([], None, -1, None), + # No anchor but candidates -> get first/last one + ([E], None, 1, 0), + ([E, D], None, 1, 0), + ([E, E], None, 1, 0), + ([D, E], None, 1, 1), + ([D, D, E], None, 1, 2), + ([E], None, -1, 0), + ([E, E], None, -1, 1), + ([E, D], None, -1, 0), + ([E, D, D], None, -1, 0), + # No enabled candidates -> return None + ([D, D, D], 0, 1, None), + ([D, D, D], 1, 1, None), + ([D, D, D], 1, -1, None), + ([D, D, D], None, -1, None), + # General case + # 0 1 2 3 4 5 + ([E, D, D, E, E, D], 0, 1, 3), + ([E, D, D, E, E, D], 0, -1, None), + ([E, D, D, E, E, D], 1, 1, 3), + ([E, D, D, E, E, D], 1, -1, 0), + ([E, D, D, E, E, D], 2, 1, 3), + ([E, D, D, E, E, D], 2, -1, 0), + ([E, D, D, E, E, D], 3, 1, 4), + ([E, D, D, E, E, D], 3, -1, 0), + ([E, D, D, E, E, D], 4, 1, None), + ([E, D, D, E, E, D], 4, -1, 3), + ([E, D, D, E, E, D], 5, 1, None), + ([E, D, D, E, E, D], 5, -1, 4), + ], +) +def test_find_next_enabled_no_wrap(candidates, anchor, direction, result): + assert find_next_enabled_no_wrap(candidates, anchor, direction) == result + + +@pytest.mark.parametrize( + ["function", "start", "direction"], + [ + (find_next_enabled, 0, 1), + (find_next_enabled, 0, -1), + (find_next_enabled_no_wrap, 0, 1), + (find_next_enabled_no_wrap, 0, -1), + (find_next_enabled, 1, 1), + (find_next_enabled, 1, -1), + (find_next_enabled_no_wrap, 1, 1), + (find_next_enabled_no_wrap, 1, -1), + (find_next_enabled, 2, 1), + (find_next_enabled, 2, -1), + (find_next_enabled_no_wrap, 2, 1), + (find_next_enabled_no_wrap, 2, -1), + ], +) +def test_find_next_with_anchor(function, start, direction): + assert function([E, E, E], start, direction, True) == start diff --git a/tests/text_area/test_edit_via_api.py b/tests/text_area/test_edit_via_api.py index 98072d35ae..e217bfa210 100644 --- a/tests/text_area/test_edit_via_api.py +++ b/tests/text_area/test_edit_via_api.py @@ -5,6 +5,7 @@ Note that more extensive testing for editing is done at the Document level. """ + import pytest from textual.app import App, ComposeResult @@ -521,10 +522,29 @@ async def test_replace_fully_within_selection(): ) assert text_area.selected_text == "XX56" + async def test_text_setter(): app = TextAreaApp() async with app.run_test(): text_area = app.query_one(TextArea) new_text = "hello\nworld\n" text_area.text = new_text - assert text_area.text == new_text \ No newline at end of file + assert text_area.text == new_text + + +async def test_edits_on_read_only_mode(): + """API edits should still be permitted on read-only mode.""" + app = TextAreaApp() + async with app.run_test(): + text_area = app.query_one(TextArea) + text_area.text = "0123456789" + text_area.read_only = True + + text_area.replace("X", (0, 1), (0, 5)) + assert text_area.text == "0X56789" + + text_area.insert("X") + assert text_area.text == "X0X56789" + + text_area.delete((0, 0), (0, 2)) + assert text_area.text == "X56789" diff --git a/tests/text_area/test_edit_via_bindings.py b/tests/text_area/test_edit_via_bindings.py index f33e24b5f4..17d7f52f02 100644 --- a/tests/text_area/test_edit_via_bindings.py +++ b/tests/text_area/test_edit_via_bindings.py @@ -6,9 +6,11 @@ Note that more extensive testing for editing is done at the Document level. """ + import pytest from textual.app import App, ComposeResult +from textual.events import Paste from textual.widgets import TextArea from textual.widgets.text_area import Selection @@ -416,3 +418,97 @@ async def test_delete_word_right_at_end_of_line(): assert text_area.text == "0123456789" assert text_area.selection == Selection.cursor((0, 5)) + + +@pytest.mark.parametrize( + "binding", + [ + "enter", + "backspace", + "ctrl+u", + "ctrl+f", + "ctrl+w", + "ctrl+k", + "ctrl+x", + "space", + "1", + "tab", + ], +) +async def test_edit_read_only_mode_does_nothing(binding): + """Try out various key-presses and bindings and ensure they don't alter + the document when read_only=True.""" + app = TextAreaApp() + async with app.run_test() as pilot: + text_area = app.query_one(TextArea) + text_area.read_only = True + selection = Selection.cursor((0, 2)) + text_area.selection = selection + + await pilot.press(binding) + + assert text_area.text == TEXT + assert text_area.selection == selection + + +@pytest.mark.parametrize( + "selection", + [ + Selection(start=(1, 0), end=(3, 0)), + Selection(start=(3, 0), end=(1, 0)), + ], +) +async def test_replace_lines_with_fewer_lines(selection): + app = TextAreaApp() + async with app.run_test() as pilot: + text_area = app.query_one(TextArea) + text_area.text = SIMPLE_TEXT + text_area.selection = selection + + await pilot.press("a") + + expected_text = """\ +ABCDE +aPQRST +UVWXY +Z""" + assert text_area.text == expected_text + assert text_area.selection == Selection.cursor((1, 1)) + + +@pytest.mark.parametrize( + "selection", + [ + Selection(start=(1, 0), end=(3, 0)), + Selection(start=(3, 0), end=(1, 0)), + ], +) +async def test_paste(selection): + app = TextAreaApp() + async with app.run_test() as pilot: + text_area = app.query_one(TextArea) + text_area.text = SIMPLE_TEXT + text_area.selection = selection + + app.post_message(Paste("a")) + await pilot.pause() + + expected_text = """\ +ABCDE +aPQRST +UVWXY +Z""" + assert text_area.text == expected_text + assert text_area.selection == Selection.cursor((1, 1)) + + +async def test_paste_read_only_does_nothing(): + app = TextAreaApp() + async with app.run_test() as pilot: + text_area = app.query_one(TextArea) + text_area.read_only = True + + app.post_message(Paste("hello")) + await pilot.pause() + + assert text_area.text == TEXT # No change diff --git a/tests/text_area/test_escape_binding.py b/tests/text_area/test_escape_binding.py new file mode 100644 index 0000000000..8d837e5c75 --- /dev/null +++ b/tests/text_area/test_escape_binding.py @@ -0,0 +1,55 @@ +from textual.app import App, ComposeResult +from textual.screen import ModalScreen +from textual.widgets import Button, TextArea + + +class TextAreaDialog(ModalScreen): + BINDINGS = [("escape", "dismiss")] + + def compose(self) -> ComposeResult: + yield TextArea( + tab_behavior="focus", # the default + ) + yield Button("Submit") + + +class TextAreaDialogApp(App): + def on_mount(self) -> None: + self.push_screen(TextAreaDialog()) + + +async def test_escape_key_when_tab_behavior_is_focus(): + """Regression test for https://github.com/Textualize/textual/issues/4110 + + When the `tab_behavior` of TextArea is the default to shift focus, + pressing should not shift focus but instead skip and allow any + parent bindings to run. + """ + + app = TextAreaDialogApp() + async with app.run_test() as pilot: + # Sanity check + assert isinstance(pilot.app.screen, TextAreaDialog) + assert isinstance(pilot.app.focused, TextArea) + + # Pressing escape should dismiss the dialog screen, not focus the button + await pilot.press("escape") + assert not isinstance(pilot.app.screen, TextAreaDialog) + + +async def test_escape_key_when_tab_behavior_is_indent(): + """When the `tab_behavior` of TextArea is indent rather than switch focus, + pressing should instead shift focus. + """ + + app = TextAreaDialogApp() + async with app.run_test() as pilot: + # Sanity check + assert isinstance(pilot.app.screen, TextAreaDialog) + assert isinstance(pilot.app.focused, TextArea) + + pilot.app.query_one(TextArea).tab_behavior = "indent" + # Pressing escape should focus the button, not dismiss the dialog screen + await pilot.press("escape") + assert isinstance(pilot.app.screen, TextAreaDialog) + assert isinstance(pilot.app.focused, Button) diff --git a/tests/text_area/test_history.py b/tests/text_area/test_history.py new file mode 100644 index 0000000000..b6a30e4132 --- /dev/null +++ b/tests/text_area/test_history.py @@ -0,0 +1,343 @@ +from __future__ import annotations + +import dataclasses + +import pytest + +from textual.app import App, ComposeResult +from textual.events import Paste +from textual.pilot import Pilot +from textual.widgets import TextArea +from textual.widgets.text_area import EditHistory, Selection + +MAX_CHECKPOINTS = 5 +SIMPLE_TEXT = """\ +ABCDE +FGHIJ +KLMNO +PQRST +UVWXY +Z +""" + + +@dataclasses.dataclass +class TimeMockableEditHistory(EditHistory): + mock_time: float | None = dataclasses.field(default=None, init=False) + + def _get_time(self) -> float: + """Return the mocked time if it is set, otherwise use default behaviour.""" + if self.mock_time is None: + return super()._get_time() + return self.mock_time + + +class TextAreaApp(App): + def compose(self) -> ComposeResult: + text_area = TextArea() + # Update the history object to a version that supports mocking the time. + text_area.history = TimeMockableEditHistory( + max_checkpoints=MAX_CHECKPOINTS, + checkpoint_timer=2.0, + checkpoint_max_characters=100, + ) + self.text_area = text_area + yield text_area + + +@pytest.fixture +async def pilot(): + app = TextAreaApp() + async with app.run_test() as pilot: + yield pilot + + +@pytest.fixture +async def text_area(pilot): + return pilot.app.text_area + + +async def test_simple_undo_redo(pilot, text_area: TextArea): + text_area.insert("123", (0, 0)) + + assert text_area.text == "123" + text_area.undo() + assert text_area.text == "" + text_area.redo() + assert text_area.text == "123" + + +async def test_undo_selection_retained(pilot: Pilot, text_area: TextArea): + # Select a range of text and press backspace. + text_area.text = SIMPLE_TEXT + text_area.selection = Selection((0, 0), (2, 3)) + await pilot.press("backspace") + assert text_area.text == "NO\nPQRST\nUVWXY\nZ\n" + assert text_area.selection == Selection.cursor((0, 0)) + + # Undo the deletion - the text comes back, and the selection is restored. + text_area.undo() + assert text_area.selection == Selection((0, 0), (2, 3)) + assert text_area.text == SIMPLE_TEXT + + # Redo the deletion - the text is gone again. The selection goes to the post-delete location. + text_area.redo() + assert text_area.text == "NO\nPQRST\nUVWXY\nZ\n" + assert text_area.selection == Selection.cursor((0, 0)) + + +async def test_undo_checkpoint_created_on_cursor_move( + pilot: Pilot, text_area: TextArea +): + text_area.text = SIMPLE_TEXT + # Characters are inserted on line 0 and 1. + checkpoint_one = text_area.text + checkpoint_one_selection = text_area.selection + await pilot.press("1") # Added to initial batch. + + # This cursor movement ensures a new checkpoint is created. + post_insert_one_location = text_area.selection + await pilot.press("down") + + checkpoint_two = text_area.text + checkpoint_two_selection = text_area.selection + await pilot.press("2") # Added to new batch. + + checkpoint_three = text_area.text + checkpoint_three_selection = text_area.selection + + # Going back to checkpoint two + text_area.undo() + assert text_area.text == checkpoint_two + assert text_area.selection == checkpoint_two_selection + + # Back again to checkpoint one (initial state) + text_area.undo() + assert text_area.text == checkpoint_one + assert text_area.selection == checkpoint_one_selection + + # Redo to move forward to checkpoint two. + text_area.redo() + assert text_area.text == checkpoint_two + assert text_area.selection == post_insert_one_location + + # Redo to move forward to checkpoint three. + text_area.redo() + assert text_area.text == checkpoint_three + assert text_area.selection == checkpoint_three_selection + + +async def test_setting_text_property_resets_history(pilot: Pilot, text_area: TextArea): + await pilot.press("1") + + # Programmatically setting text, which should invalidate the history + text = "Hello, world!" + text_area.text = text + + # The undo doesn't do anything, since we set the `text` property. + text_area.undo() + assert text_area.text == text + + +async def test_edits_batched_by_time(pilot: Pilot, text_area: TextArea): + # The first "12" is batched since they happen within 2 seconds. + text_area.history.mock_time = 0 + await pilot.press("1") + + text_area.history.mock_time = 1.0 + await pilot.press("2") + + # Since "3" appears 10 seconds later, it's in a separate batch. + text_area.history.mock_time += 10.0 + await pilot.press("3") + + assert text_area.text == "123" + + text_area.undo() + assert text_area.text == "12" + + text_area.undo() + assert text_area.text == "" + + +async def test_undo_checkpoint_character_limit_reached( + pilot: Pilot, text_area: TextArea +): + await pilot.press("1") + # Since the insertion below is > 100 characters it goes to a new batch. + text_area.insert("2" * 120) + + text_area.undo() + assert text_area.text == "1" + text_area.undo() + assert text_area.text == "" + + +async def test_redo_with_no_undo_is_noop(text_area: TextArea): + text_area.text = SIMPLE_TEXT + text_area.redo() + assert text_area.text == SIMPLE_TEXT + + +async def test_undo_with_empty_undo_stack_is_noop(text_area: TextArea): + text_area.text = SIMPLE_TEXT + text_area.undo() + assert text_area.text == SIMPLE_TEXT + + +async def test_redo_stack_cleared_on_edit(pilot: Pilot, text_area: TextArea): + text_area.text = "" + await pilot.press("1") + text_area.history.checkpoint() + await pilot.press("2") + text_area.history.checkpoint() + await pilot.press("3") + + text_area.undo() + text_area.undo() + text_area.undo() + assert text_area.text == "" + assert text_area.selection == Selection.cursor((0, 0)) + + # Redo stack has 3 edits in it now. + await pilot.press("f") + assert text_area.text == "f" + assert text_area.selection == Selection.cursor((0, 1)) + + # Redo stack is cleared because of the edit, so redo has no effect. + text_area.redo() + assert text_area.text == "f" + assert text_area.selection == Selection.cursor((0, 1)) + text_area.redo() + assert text_area.text == "f" + assert text_area.selection == Selection.cursor((0, 1)) + + +async def test_inserts_not_batched_with_deletes(pilot: Pilot, text_area: TextArea): + # 3 batches here: __1___ ___________2____________ __3__ + await pilot.press(*"123", "backspace", "backspace", *"23") + + assert text_area.text == "123" + + # Undo batch 1: the "23" insertion. + text_area.undo() + assert text_area.text == "1" + + # Undo batch 2: the double backspace. + text_area.undo() + assert text_area.text == "123" + + # Undo batch 3: the "123" insertion. + text_area.undo() + assert text_area.text == "" + + +async def test_paste_is_an_isolated_batch(pilot: Pilot, text_area: TextArea): + pilot.app.post_message(Paste("hello ")) + pilot.app.post_message(Paste("world")) + await pilot.pause() + + assert text_area.text == "hello world" + + await pilot.press("!") + + # The insertion of "!" does not get batched with the paste of "world". + text_area.undo() + assert text_area.text == "hello world" + + text_area.undo() + assert text_area.text == "hello " + + text_area.undo() + assert text_area.text == "" + + +async def test_focus_creates_checkpoint(pilot: Pilot, text_area: TextArea): + await pilot.press(*"123") + text_area.has_focus = False + text_area.has_focus = True + await pilot.press(*"456") + assert text_area.text == "123456" + + # Since we re-focused, a checkpoint exists between 123 and 456, + # so when we use undo, only the 456 is removed. + text_area.undo() + assert text_area.text == "123" + + +async def test_undo_redo_deletions_batched(pilot: Pilot, text_area: TextArea): + text_area.text = SIMPLE_TEXT + text_area.selection = Selection((0, 2), (1, 2)) + + # Perform a single delete of some selected text. It'll live in it's own + # batch since it's a multi-line operation. + await pilot.press("backspace") + checkpoint_one = "ABHIJ\nKLMNO\nPQRST\nUVWXY\nZ\n" + assert text_area.text == checkpoint_one + assert text_area.selection == Selection.cursor((0, 2)) + + # Pressing backspace a few times to delete more characters. + await pilot.press("backspace", "backspace", "backspace") + checkpoint_two = "HIJ\nKLMNO\nPQRST\nUVWXY\nZ\n" + assert text_area.text == checkpoint_two + assert text_area.selection == Selection.cursor((0, 0)) + + # When we undo, the 3 deletions above should be batched, but not + # the original deletion since it contains a newline character. + text_area.undo() + assert text_area.text == checkpoint_one + assert text_area.selection == Selection.cursor((0, 2)) + + # Undoing again restores us back to our initial text and selection. + text_area.undo() + assert text_area.text == SIMPLE_TEXT + assert text_area.selection == Selection((0, 2), (1, 2)) + + # At this point, the undo stack contains two items, so we can redo twice. + + # Redo to go back to checkpoint one. + text_area.redo() + assert text_area.text == checkpoint_one + assert text_area.selection == Selection.cursor((0, 2)) + + # Redo again to go back to checkpoint two + text_area.redo() + assert text_area.text == checkpoint_two + assert text_area.selection == Selection.cursor((0, 0)) + + # Redo again does nothing. + text_area.redo() + assert text_area.text == checkpoint_two + assert text_area.selection == Selection.cursor((0, 0)) + + +async def test_max_checkpoints(pilot: Pilot, text_area: TextArea): + assert len(text_area.history.undo_stack) == 0 + for index in range(MAX_CHECKPOINTS): + # Press enter since that will ensure a checkpoint is created. + await pilot.press("enter") + + assert len(text_area.history.undo_stack) == MAX_CHECKPOINTS + await pilot.press("enter") + # Ensure we don't go over the limit. + assert len(text_area.history.undo_stack) == MAX_CHECKPOINTS + + +async def test_redo_stack(pilot: Pilot, text_area: TextArea): + assert len(text_area.history.redo_stack) == 0 + await pilot.press("enter") + await pilot.press(*"123") + assert len(text_area.history.undo_stack) == 2 + assert len(text_area.history.redo_stack) == 0 + text_area.undo() + assert len(text_area.history.undo_stack) == 1 + assert len(text_area.history.redo_stack) == 1 + text_area.undo() + assert len(text_area.history.undo_stack) == 0 + assert len(text_area.history.redo_stack) == 2 + text_area.redo() + assert len(text_area.history.undo_stack) == 1 + assert len(text_area.history.redo_stack) == 1 + text_area.redo() + assert len(text_area.history.undo_stack) == 2 + assert len(text_area.history.redo_stack) == 0 diff --git a/tests/text_area/test_selection_bindings.py b/tests/text_area/test_selection_bindings.py index 7efdcef164..06b180485e 100644 --- a/tests/text_area/test_selection_bindings.py +++ b/tests/text_area/test_selection_bindings.py @@ -13,34 +13,41 @@ class TextAreaApp(App): + def __init__(self, read_only: bool = False): + super().__init__() + self.read_only = read_only + def compose(self) -> ComposeResult: - text_area = TextArea(show_line_numbers=True) - text_area.load_text(TEXT) - yield text_area + yield TextArea(TEXT, show_line_numbers=True, read_only=self.read_only) + +@pytest.fixture(params=[True, False]) +async def app(request): + """Each test that receives an `app` will execute twice. + Once with read_only=True, and once with read_only=False. + """ + return TextAreaApp(read_only=request.param) -async def test_mouse_click(): + +async def test_mouse_click(app: TextAreaApp): """When you click the TextArea, the cursor moves to the expected location.""" - app = TextAreaApp() async with app.run_test() as pilot: text_area = app.query_one(TextArea) await pilot.click(TextArea, Offset(x=5, y=2)) assert text_area.selection == Selection.cursor((1, 0)) -async def test_mouse_click_clamp_from_right(): +async def test_mouse_click_clamp_from_right(app: TextAreaApp): """When you click to the right of the document bounds, the cursor is clamped to within the document bounds.""" - app = TextAreaApp() async with app.run_test() as pilot: text_area = app.query_one(TextArea) await pilot.click(TextArea, Offset(x=8, y=20)) assert text_area.selection == Selection.cursor((4, 0)) -async def test_mouse_click_gutter_clamp(): +async def test_mouse_click_gutter_clamp(app: TextAreaApp): """When you click the gutter, it selects the start of the line.""" - app = TextAreaApp() async with app.run_test() as pilot: text_area = app.query_one(TextArea) await pilot.click(TextArea, Offset(x=0, y=3)) @@ -66,19 +73,17 @@ async def test_cursor_movement_basic(): assert text_area.selection == Selection.cursor((0, 0)) -async def test_cursor_selection_right(): +async def test_cursor_selection_right(app: TextAreaApp): """When you press shift+right the selection is updated correctly.""" - app = TextAreaApp() async with app.run_test() as pilot: text_area = app.query_one(TextArea) await pilot.press(*["shift+right"] * 3) assert text_area.selection == Selection((0, 0), (0, 3)) -async def test_cursor_selection_right_to_previous_line(): +async def test_cursor_selection_right_to_previous_line(app: TextAreaApp): """When you press shift+right resulting in the cursor moving to the next line, the selection is updated correctly.""" - app = TextAreaApp() async with app.run_test() as pilot: text_area = app.query_one(TextArea) text_area.selection = Selection.cursor((0, 15)) @@ -86,9 +91,8 @@ async def test_cursor_selection_right_to_previous_line(): assert text_area.selection == Selection((0, 15), (1, 2)) -async def test_cursor_selection_left(): +async def test_cursor_selection_left(app: TextAreaApp): """When you press shift+left the selection is updated correctly.""" - app = TextAreaApp() async with app.run_test() as pilot: text_area = app.query_one(TextArea) text_area.selection = Selection.cursor((2, 5)) @@ -96,10 +100,9 @@ async def test_cursor_selection_left(): assert text_area.selection == Selection((2, 5), (2, 2)) -async def test_cursor_selection_left_to_previous_line(): +async def test_cursor_selection_left_to_previous_line(app: TextAreaApp): """When you press shift+left resulting in the cursor moving back to the previous line, the selection is updated correctly.""" - app = TextAreaApp() async with app.run_test() as pilot: text_area = app.query_one(TextArea) text_area.selection = Selection.cursor((2, 2)) @@ -110,9 +113,8 @@ async def test_cursor_selection_left_to_previous_line(): assert text_area.selection == Selection((2, 2), (1, end_of_previous_line)) -async def test_cursor_selection_up(): +async def test_cursor_selection_up(app: TextAreaApp): """When you press shift+up the selection is updated correctly.""" - app = TextAreaApp() async with app.run_test() as pilot: text_area = app.query_one(TextArea) text_area.move_cursor((2, 3)) @@ -121,9 +123,8 @@ async def test_cursor_selection_up(): assert text_area.selection == Selection((2, 3), (1, 3)) -async def test_cursor_selection_up_when_cursor_on_first_line(): +async def test_cursor_selection_up_when_cursor_on_first_line(app: TextAreaApp): """When you press shift+up the on the first line, it selects to the start.""" - app = TextAreaApp() async with app.run_test() as pilot: text_area = app.query_one(TextArea) text_area.move_cursor((0, 4)) @@ -134,8 +135,7 @@ async def test_cursor_selection_up_when_cursor_on_first_line(): assert text_area.selection == Selection((0, 4), (0, 0)) -async def test_cursor_selection_down(): - app = TextAreaApp() +async def test_cursor_selection_down(app: TextAreaApp): async with app.run_test() as pilot: text_area = app.query_one(TextArea) text_area.move_cursor((2, 5)) @@ -144,8 +144,7 @@ async def test_cursor_selection_down(): assert text_area.selection == Selection((2, 5), (3, 5)) -async def test_cursor_selection_down_when_cursor_on_last_line(): - app = TextAreaApp() +async def test_cursor_selection_down_when_cursor_on_last_line(app: TextAreaApp): async with app.run_test() as pilot: text_area = app.query_one(TextArea) text_area.load_text("ABCDEF\nGHIJK") @@ -157,8 +156,7 @@ async def test_cursor_selection_down_when_cursor_on_last_line(): assert text_area.selection == Selection((1, 2), (1, 5)) -async def test_cursor_word_right(): - app = TextAreaApp() +async def test_cursor_word_right(app: TextAreaApp): async with app.run_test() as pilot: text_area = app.query_one(TextArea) text_area.load_text("ABC DEF\nGHIJK") @@ -168,8 +166,7 @@ async def test_cursor_word_right(): assert text_area.selection == Selection.cursor((0, 3)) -async def test_cursor_word_right_select(): - app = TextAreaApp() +async def test_cursor_word_right_select(app: TextAreaApp): async with app.run_test() as pilot: text_area = app.query_one(TextArea) text_area.load_text("ABC DEF\nGHIJK") @@ -179,8 +176,7 @@ async def test_cursor_word_right_select(): assert text_area.selection == Selection((0, 0), (0, 3)) -async def test_cursor_word_left(): - app = TextAreaApp() +async def test_cursor_word_left(app: TextAreaApp): async with app.run_test() as pilot: text_area = app.query_one(TextArea) text_area.load_text("ABC DEF\nGHIJK") @@ -191,8 +187,7 @@ async def test_cursor_word_left(): assert text_area.selection == Selection.cursor((0, 4)) -async def test_cursor_word_left_select(): - app = TextAreaApp() +async def test_cursor_word_left_select(app: TextAreaApp): async with app.run_test() as pilot: text_area = app.query_one(TextArea) text_area.load_text("ABC DEF\nGHIJK") @@ -204,9 +199,8 @@ async def test_cursor_word_left_select(): @pytest.mark.parametrize("key", ["end", "ctrl+e"]) -async def test_cursor_to_line_end(key): +async def test_cursor_to_line_end(key, app: TextAreaApp): """You can use the keyboard to jump the cursor to the end of the current line.""" - app = TextAreaApp() async with app.run_test() as pilot: text_area = app.query_one(TextArea) text_area.selection = Selection.cursor((2, 2)) @@ -217,9 +211,8 @@ async def test_cursor_to_line_end(key): @pytest.mark.parametrize("key", ["home", "ctrl+a"]) -async def test_cursor_to_line_home_basic_behaviour(key): +async def test_cursor_to_line_home_basic_behaviour(key, app: TextAreaApp): """You can use the keyboard to jump the cursor to the start of the current line.""" - app = TextAreaApp() async with app.run_test() as pilot: text_area = app.query_one(TextArea) text_area.selection = Selection.cursor((2, 2)) @@ -239,11 +232,12 @@ async def test_cursor_to_line_home_basic_behaviour(key): ((0, 15), (0, 4)), ], ) -async def test_cursor_line_home_smart_home(cursor_start, cursor_destination): +async def test_cursor_line_home_smart_home( + cursor_start, cursor_destination, app: TextAreaApp +): """If the line begins with whitespace, pressing home firstly goes to the start of the (non-whitespace) content. Pressing it again takes you to column 0. If you press it again, it goes back to the first non-whitespace column.""" - app = TextAreaApp() async with app.run_test() as pilot: text_area = app.query_one(TextArea) text_area.load_text(" hello world") @@ -252,9 +246,8 @@ async def test_cursor_line_home_smart_home(cursor_start, cursor_destination): assert text_area.selection == Selection.cursor(cursor_destination) -async def test_cursor_page_down(): +async def test_cursor_page_down(app: TextAreaApp): """Pagedown moves the cursor down 1 page, retaining column index.""" - app = TextAreaApp() async with app.run_test() as pilot: text_area = app.query_one(TextArea) text_area.load_text("XXX\n" * 200) @@ -266,9 +259,8 @@ async def test_cursor_page_down(): ) -async def test_cursor_page_up(): +async def test_cursor_page_up(app: TextAreaApp): """Pageup moves the cursor up 1 page, retaining column index.""" - app = TextAreaApp() async with app.run_test() as pilot: text_area = app.query_one(TextArea) text_area.load_text("XXX\n" * 200) @@ -280,10 +272,9 @@ async def test_cursor_page_up(): ) -async def test_cursor_vertical_movement_visual_alignment_snapping(): +async def test_cursor_vertical_movement_visual_alignment_snapping(app: TextAreaApp): """When you move the cursor vertically, it should stay vertically aligned even when double-width characters are used.""" - app = TextAreaApp() async with app.run_test() as pilot: text_area = app.query_one(TextArea) text_area.text = "こんにちは\n012345" @@ -301,8 +292,7 @@ async def test_cursor_vertical_movement_visual_alignment_snapping(): assert text_area.selection == Selection.cursor((1, 3)) -async def test_select_line_binding(): - app = TextAreaApp() +async def test_select_line_binding(app: TextAreaApp): async with app.run_test() as pilot: text_area = app.query_one(TextArea) text_area.move_cursor((2, 2)) @@ -312,8 +302,7 @@ async def test_select_line_binding(): assert text_area.selection == Selection((2, 0), (2, 56)) -async def test_select_all_binding(): - app = TextAreaApp() +async def test_select_all_binding(app: TextAreaApp): async with app.run_test() as pilot: text_area = app.query_one(TextArea) diff --git a/tests/text_area/test_setting_themes.py b/tests/text_area/test_setting_themes.py index 8d165a98a9..a3ee7aa35e 100644 --- a/tests/text_area/test_setting_themes.py +++ b/tests/text_area/test_setting_themes.py @@ -6,7 +6,7 @@ from textual.widgets._text_area import ThemeDoesNotExist -class TextAreaApp(App): +class TextAreaApp(App[None]): def compose(self) -> ComposeResult: yield TextArea("print('hello')", language="python") @@ -16,11 +16,11 @@ async def test_default_theme(): async with app.run_test(): text_area = app.query_one(TextArea) - assert text_area.theme is None + assert text_area.theme is "css" async def test_setting_builtin_themes(): - class MyTextAreaApp(App): + class MyTextAreaApp(App[None]): def compose(self) -> ComposeResult: yield TextArea("print('hello')", language="python", theme="vscode_dark") @@ -34,17 +34,6 @@ def compose(self) -> ComposeResult: assert text_area.theme == "monokai" -async def test_setting_theme_to_none(): - app = TextAreaApp() - - async with app.run_test(): - text_area = app.query_one(TextArea) - text_area.theme = None - assert text_area.theme is None - # When theme is None, we use the default theme. - assert text_area._theme.name == TextAreaTheme.default().name - - async def test_setting_unknown_theme_raises_exception(): app = TextAreaApp() async with app.run_test(): diff --git a/tests/toggles/test_radioset.py b/tests/toggles/test_radioset.py index 457dbd2331..1bd6eeb1b0 100644 --- a/tests/toggles/test_radioset.py +++ b/tests/toggles/test_radioset.py @@ -110,7 +110,7 @@ def on_mount(self) -> None: async with EmptyRadioSetApp().run_test() as pilot: assert pilot.app.query_one(RadioSet)._selected is None await pilot.press("up") - assert pilot.app.query_one(RadioSet)._selected == 0 + assert pilot.app.query_one(RadioSet)._selected == 4 async def test_radioset_breakout_navigation(): diff --git a/tests/tree/test_directory_tree.py b/tests/tree/test_directory_tree.py index c56bf48d52..b06b8d5edc 100644 --- a/tests/tree/test_directory_tree.py +++ b/tests/tree/test_directory_tree.py @@ -1,6 +1,9 @@ from __future__ import annotations +from pathlib import Path + from rich.text import Text + from textual import on from textual.app import App, ComposeResult from textual.widgets import DirectoryTree @@ -25,7 +28,7 @@ def record( self.messages.append(event.__class__.__name__) -async def test_directory_tree_file_selected_message(tmp_path) -> None: +async def test_directory_tree_file_selected_message(tmp_path: Path) -> None: """Selecting a file should result in a file selected message being emitted.""" FILE_NAME = "hello.txt" @@ -48,7 +51,7 @@ async def test_directory_tree_file_selected_message(tmp_path) -> None: assert pilot.app.messages == ["FileSelected"] -async def test_directory_tree_directory_selected_message(tmp_path) -> None: +async def test_directory_tree_directory_selected_message(tmp_path: Path) -> None: """Selecting a directory should result in a directory selected message being emitted.""" SUBDIR = "subdir" @@ -79,7 +82,7 @@ async def test_directory_tree_directory_selected_message(tmp_path) -> None: assert pilot.app.messages == ["DirectorySelected", "DirectorySelected"] -async def test_directory_tree_reload_node(tmp_path) -> None: +async def test_directory_tree_reload_node(tmp_path: Path) -> None: """Reloading a node of a directory tree should display newly created file inside the directory.""" RELOADED_DIRECTORY = "parentdir" @@ -123,7 +126,7 @@ async def test_directory_tree_reload_node(tmp_path) -> None: ] -async def test_directory_tree_reload_other_node(tmp_path) -> None: +async def test_directory_tree_reload_other_node(tmp_path: Path) -> None: """Reloading a node of a directory tree should not reload content of other directory.""" RELOADED_DIRECTORY = "parentdir" @@ -172,3 +175,29 @@ async def test_directory_tree_reload_other_node(tmp_path) -> None: # After reloading one node, the new file under the other one does not show up assert len(unaffected_node.children) == 1 assert unaffected_node.children[0].label == Text(NOT_RELOADED_FILE3_NAME) + + +async def test_directory_tree_reloading_preserves_state(tmp_path: Path) -> None: + """Regression test for https://github.com/Textualize/textual/issues/4122. + + Ensures `clear_node` does clear the node specified. + """ + ROOT = "root" + structure = [ + ROOT, + "root/file1.txt", + "root/file2.txt", + ] + + for path in structure: + if path.endswith(".txt"): + (tmp_path / path).touch() + else: + (tmp_path / path).mkdir() + + app = DirectoryTreeApp(tmp_path / ROOT) + async with app.run_test() as pilot: + directory_tree = app.query_one(DirectoryTree) + directory_tree.clear_node(directory_tree.root) + await pilot.pause() + assert not directory_tree.root.children