diff --git a/.github/workflows/black_format.yml b/.github/workflows/black_format.yml index 063068c357..0be0eb712c 100644 --- a/.github/workflows/black_format.yml +++ b/.github/workflows/black_format.yml @@ -17,6 +17,6 @@ jobs: with: python-version: 3.11 - name: Install black - run: python -m pip install black + run: python -m pip install black==24.1.1 - name: Run black run: black --check src 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 39994d188c..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: 23.1.0 + 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 d3f838be7d..6c2d653e06 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,16 +7,208 @@ and this project adheres to [Semantic Versioning](http://semver.org/). ## Unreleased +### Fixed + +- Fixed a crash in `TextArea` when undoing an edit to a selection the selection was made backwards https://github.com/Textualize/textual/issues/4301 +- Fix progress bar ETA not updating when setting `total` reactive https://github.com/Textualize/textual/pull/4316 +- ProgressBar won't show ETA until there is at least one second of samples https://github.com/Textualize/textual/pull/4316 + +## [0.53.1] - 2023-03-18 + +### Fixed + +- Fixed issue with data binding https://github.com/Textualize/textual/pull/4308 + +## [0.53.0] - 2023-03-18 + +### Added + +- Mapping of ANSI colors to hex codes configurable via `App.ansi_theme_dark` and `App.ansi_theme_light` https://github.com/Textualize/textual/pull/4192 +- `Pilot.resize_terminal` to resize the terminal in testing https://github.com/Textualize/textual/issues/4212 +- Added `sort_children` method https://github.com/Textualize/textual/pull/4244 +- Support for pseudo-classes in nested TCSS https://github.com/Textualize/textual/issues/4039 + +### Fixed + +- Fixed `TextArea.code_editor` missing recently added attributes https://github.com/Textualize/textual/pull/4172 +- Fixed `Sparkline` not working with data in a `deque` https://github.com/Textualize/textual/issues/3899 +- Tooltips are now cleared when the related widget is no longer under them https://github.com/Textualize/textual/issues/3045 +- Simplified tree-sitter highlight queries for HTML, which also seems to fix segfault issue https://github.com/Textualize/textual/pull/4195 +- Fixed `DirectoryTree.path` no longer reacting to new values https://github.com/Textualize/textual/issues/4208 +- Fixed content size cache with Pretty widget https://github.com/Textualize/textual/pull/4211 +- Fixed `grid-gutter` interaction with Pretty widget https://github.com/Textualize/textual/pull/4219 +- Fixed `TextArea` styling issue on alternate screens https://github.com/Textualize/textual/pull/4220 +- Fixed writing to invisible `RichLog` https://github.com/Textualize/textual/pull/4223 +- Fixed `RichLog.min_width` not being used https://github.com/Textualize/textual/pull/4223 +- Rename `CollapsibleTitle.action_toggle` to `action_toggle_collapsible` to fix clash with `DOMNode.action_toggle` https://github.com/Textualize/textual/pull/4221 +- Markdown component classes weren't refreshed when watching for CSS https://github.com/Textualize/textual/issues/3464 +- Rename `Switch.action_toggle` to `action_toggle_switch` to fix clash with `DOMNode.action_toggle` https://github.com/Textualize/textual/issues/4262 +- Fixed `OptionList.OptionHighlighted` leaking out of `Select` https://github.com/Textualize/textual/issues/4224 +- Fixed `Tab` enable/disable messages leaking into `TabbedContent` https://github.com/Textualize/textual/issues/4233 +- Fixed a style leak from `TabbedContent` https://github.com/Textualize/textual/issues/4232 +- Fixed active hidden scrollbars not releasing the mouse https://github.com/Textualize/textual/issues/4274 +- Fixed the mouse not being released when hiding a `TextArea` while mouse selection is happening https://github.com/Textualize/textual/issues/4292 +- Fix mouse scrolling not working when mouse cursor is over a disabled child widget https://github.com/Textualize/textual/issues/4242 + +### Changed + +- Clicking a non focusable widget focus ancestors https://github.com/Textualize/textual/pull/4236 +- BREAKING: widget class names must start with a capital letter or an underscore `_` https://github.com/Textualize/textual/pull/4252 +- BREAKING: for many widgets, messages are now sent when programmatic changes that mirror user input are made https://github.com/Textualize/textual/pull/4256 + - Changed `Collapsible` + - Changed `Markdown` + - Changed `Select` + - Changed `SelectionList` + - Changed `TabbedContent` + - Changed `Tabs` + - Changed `TextArea` + - Changed `Tree` +- Improved ETA calculation for ProgressBar https://github.com/Textualize/textual/pull/4271 +- BREAKING: `AppFocus` and `AppBlur` are now posted when the terminal window gains or loses focus, if the terminal supports this https://github.com/Textualize/textual/pull/4265 + - When the terminal window loses focus, the currently-focused widget will also lose focus. + - When the terminal window regains focus, the previously-focused widget will regain focus. +- TextArea binding for ctrl+k will now delete the line if the line is empty https://github.com/Textualize/textual/issues/4277 +- The active tab (in `Tabs`) / tab pane (in `TabbedContent`) can now be unset https://github.com/Textualize/textual/issues/4241 + +## [0.52.1] - 2024-02-20 + +### Fixed + +- Fixed the check for animation level in `LoadingIndicator` https://github.com/Textualize/textual/issues/4188 + +## [0.52.0] - 2024-02-19 + ### Changed +- Textual now writes to stderr rather than stdout https://github.com/Textualize/textual/pull/4177 + +### Added + +- Added an `asyncio` lock attribute `Widget.lock` to be used to synchronize widget state https://github.com/Textualize/textual/issues/4134 +- 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 +- Added support for a `TEXTUAL_SCREENSHOT_LOCATION` environment variable to specify the location of an automated screenshot https://github.com/Textualize/textual/pull/4181/ +- Added support for a `TEXTUAL_SCREENSHOT_FILENAME` environment variable to specify the filename of an automated screenshot https://github.com/Textualize/textual/pull/4181/ +- Added an `asyncio` lock attribute `Widget.lock` to be used to synchronize widget state https://github.com/Textualize/textual/issues/4134 +- `Widget.remove_children` now accepts a CSS selector to specify which children to remove https://github.com/Textualize/textual/pull/4183 +- `Widget.batch` combines widget locking and app update batching https://github.com/Textualize/textual/pull/4183 + +## [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 + +- Breaking change: Significant changes to `TextArea.__init__` default values/behaviour https://github.com/Textualize/textual/pull/3933 + - `soft_wrap=True` - soft wrapping is now enabled by default. + - `show_line_numbers=False` - line numbers are now disabled by default. + - `tab_behaviour="focus"` - pressing the tab key now switches focus instead of indenting by default. +- Breaking change: `TextArea` default theme changed to CSS, and default styling changed https://github.com/Textualize/textual/pull/4074 - Breaking change: `DOMNode.has_pseudo_class` now accepts a single name only https://github.com/Textualize/textual/pull/3970 - Made `textual.cache` (formerly `textual._cache`) public https://github.com/Textualize/textual/pull/3976 - `Tab.label` can now be used to change the label of a tab https://github.com/Textualize/textual/pull/3979 +- Changed the default notification timeout from 3 to 5 seconds https://github.com/Textualize/textual/pull/4059 +- Prior scroll animations are now cancelled on new scrolls https://github.com/Textualize/textual/pull/4081 ### Added - Added `DOMNode.has_pseudo_classes` https://github.com/Textualize/textual/pull/3970 - Added `Widget.allow_focus` and `Widget.allow_focus_children` https://github.com/Textualize/textual/pull/3989 +- Added `TextArea.soft_wrap` reactive attribute added https://github.com/Textualize/textual/pull/3933 +- Added `TextArea.tab_behaviour` reactive attribute added https://github.com/Textualize/textual/pull/3933 +- Added `TextArea.code_editor` classmethod/alternative constructor https://github.com/Textualize/textual/pull/3933 +- Added `TextArea.wrapped_document` attribute which can convert between wrapped visual coordinates and locations https://github.com/Textualize/textual/pull/3933 +- Added `show_line_numbers` to `TextArea.__init__` https://github.com/Textualize/textual/pull/3933 +- Added component classes allowing `TextArea` to be styled using CSS https://github.com/Textualize/textual/pull/4074 +- Added `Query.blur` and `Query.focus` https://github.com/Textualize/textual/pull/4012 +- Added `MessagePump.message_queue_size` https://github.com/Textualize/textual/pull/4012 +- 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 + ### Fixed @@ -26,9 +218,16 @@ and this project adheres to [Semantic Versioning](http://semver.org/). - `SelectionList` option IDs are usable as soon as the widget is instantiated https://github.com/Textualize/textual/issues/3903 - Fix issue with `Strip.crop` when crop window start aligned with strip end https://github.com/Textualize/textual/pull/3998 - Fixed Strip.crop_extend https://github.com/Textualize/textual/pull/4011 +- Fix for percentage dimensions https://github.com/Textualize/textual/pull/4037 +- Fixed a crash if the `TextArea` language was set but tree-sitter language binaries were not installed https://github.com/Textualize/textual/issues/4045 +- Ensuring `TextArea.SelectionChanged` message only sends when the updated selection is different https://github.com/Textualize/textual/pull/3933 +- Fixed declaration after nested rule set causing a parse error https://github.com/Textualize/textual/pull/4012 +- ID and class validation was too lenient https://github.com/Textualize/textual/issues/3954 +- Fixed CSS watcher crash if file becomes unreadable (even temporarily) https://github.com/Textualize/textual/pull/4079 +- 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 @@ -421,7 +620,6 @@ and this project adheres to [Semantic Versioning](http://semver.org/). ## [0.33.0] - 2023-08-15 - ### Fixed - Fixed unintuitive sizing behaviour of TabbedContent https://github.com/Textualize/textual/issues/2411 @@ -1597,6 +1795,18 @@ 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.53.1]: https://github.com/Textualize/textual/compare/v0.53.0...v0.53.1 +[0.53.0]: https://github.com/Textualize/textual/compare/v0.52.1...v0.53.0 +[0.52.1]: https://github.com/Textualize/textual/compare/v0.52.0...v0.52.1 +[0.52.0]: https://github.com/Textualize/textual/compare/v0.51.0...v0.52.0 +[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 [0.46.0]: https://github.com/Textualize/textual/compare/v0.45.1...v0.46.0 diff --git a/docs/api/constants.md b/docs/api/constants.md new file mode 100644 index 0000000000..88aa35b2f9 --- /dev/null +++ b/docs/api/constants.md @@ -0,0 +1 @@ +::: textual.constants diff --git a/docs/api/signal.md b/docs/api/signal.md new file mode 100644 index 0000000000..36727f7f2b --- /dev/null +++ b/docs/api/signal.md @@ -0,0 +1 @@ +::: textual.signal diff --git a/docs/blog/posts/remote-memray.md b/docs/blog/posts/remote-memray.md new file mode 100644 index 0000000000..2dc01de2dc --- /dev/null +++ b/docs/blog/posts/remote-memray.md @@ -0,0 +1,44 @@ +--- +draft: false +date: 2024-02-20 +categories: + - DevLog +authors: + - willmcgugan +--- + +# Remote memory profiling with Memray + +[Memray](https://github.com/bloomberg/memray) is a memory profiler for Python, built by some very smart devs at Bloomberg. +It is a fantastic tool to identify memory leaks in your code or other libraries (down to the C level)! + +They recently added a [Textual](https://github.com/textualize/textual/) interface which looks amazing, and lets you monitor your process right from the terminal: + +![Memray](https://raw.githubusercontent.com/bloomberg/memray/main/docs/_static/images/live_animated.webp) + + + +You would typically run this locally, or over a ssh session, but it is also possible to serve the interface over the web with the help of [textual-web](https://github.com/Textualize/textual-web). +I'm not sure if even the Memray devs themselves are aware of this, but here's how. + +First install Textual web (ideally with pipx) alongside Memray: + +```bash +pipx install textual-web +``` + +Now you can serve Memray with the following command (replace the text in quotes with your Memray options): + +```bash +textual-web -r "memray run --live -m http.server" +``` + +This will return a URL you can use to access the Memray app from anywhere. +Here's a quick video of that in action: + + + +## Found this interesting? + + +Join our [Discord server](https://discord.gg/Enf6Z3qhVr) if you want to discuss this post with the Textual devs or community. diff --git a/docs/blog/posts/toolong-retrospective.md b/docs/blog/posts/toolong-retrospective.md new file mode 100644 index 0000000000..a29ee6d2bb --- /dev/null +++ b/docs/blog/posts/toolong-retrospective.md @@ -0,0 +1,149 @@ +--- +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. + +!!! warning "Addendum" + + So it turns out that watching regular files for changes with selectors only works with `KqueueSelector` which is the default on macOS. + Disappointingly, the Python docs aren't clear on this. + Toolong will use a polling approach where this selector is unavailable. + +## 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/css_types/border.md b/docs/css_types/border.md index 00be2f8a98..fdca9683a5 100644 --- a/docs/css_types/border.md +++ b/docs/css_types/border.md @@ -18,6 +18,7 @@ The [``](./border.md) type can take any of the following values: | `inner` | Thick solid border. | | `none` | Disabled border. | | `outer` | Solid border with additional space around content. | +| `panel` | Solid border with thick top. | | `round` | Rounded corners. | | `solid` | Solid border. | | `tall` | Solid border with additional space top and bottom. | diff --git a/docs/events/app_blur.md b/docs/events/app_blur.md new file mode 100644 index 0000000000..3f1e8d6a8b --- /dev/null +++ b/docs/events/app_blur.md @@ -0,0 +1,11 @@ +--- +title: AppBlur +--- + +::: textual.events.AppBlur + options: + heading_level: 1 + +## See also + +- [AppFocus](app_focus.md) diff --git a/docs/events/app_focus.md b/docs/events/app_focus.md new file mode 100644 index 0000000000..16b5c0010d --- /dev/null +++ b/docs/events/app_focus.md @@ -0,0 +1,11 @@ +--- +title: AppFocus +--- + +::: textual.events.AppFocus + options: + heading_level: 1 + +## See also + +- [AppBlur](app_blur.md) diff --git a/docs/events/blur.md b/docs/events/blur.md index df317c5f45..2f8c52028a 100644 --- a/docs/events/blur.md +++ b/docs/events/blur.md @@ -1,17 +1,6 @@ -# Blur - -The `Blur` event is sent to a widget when it loses focus. - -- [ ] Bubbles -- [ ] Verbose - -## Attributes - -_No other attributes_ - -## Code - ::: textual.events.Blur + options: + heading_level: 1 ## See also diff --git a/docs/events/click.md b/docs/events/click.md index 8be36002f5..cc5b83e73e 100644 --- a/docs/events/click.md +++ b/docs/events/click.md @@ -1,25 +1,16 @@ -# Click - -The `Click` event is sent to a widget when the user clicks a mouse button. - -- [x] Bubbles -- [ ] Verbose - -## Attributes +::: textual.events.Click + options: + heading_level: 1 -| attribute | type | purpose | -|------------|------|-------------------------------------------| -| `x` | int | Mouse x coordinate, relative to Widget | -| `y` | int | Mouse y coordinate, relative to Widget | -| `delta_x` | int | Change in x since last mouse event | -| `delta_y` | int | Change in y since last mouse event | -| `button` | int | Index of mouse button | -| `shift` | bool | Shift key pressed if True | -| `meta` | bool | Meta key pressed if True | -| `ctrl` | bool | Ctrl key pressed if True | -| `screen_x` | int | Mouse x coordinate relative to the screen | -| `screen_y` | int | Mouse y coordinate relative to the screen | +See [MouseEvent][textual.events.MouseEvent] for the full list of properties and methods. -## Code +## See also -::: textual.events.Click +- [Enter](enter.md) +- [Leave](leave.md) +- [MouseDown](mouse_down.md) +- [MouseEvent][textual.events.MouseEvent] +- [MouseMove](mouse_move.md) +- [MouseScrollDown](mouse_scroll_down.md) +- [MouseScrollUp](mouse_scroll_up.md) +- [MouseUp](mouse_up.md) diff --git a/docs/events/descendant_blur.md b/docs/events/descendant_blur.md index c2f447b1f4..0fdbd239fa 100644 --- a/docs/events/descendant_blur.md +++ b/docs/events/descendant_blur.md @@ -1,20 +1,15 @@ -# DescendantBlur - -The `DescendantBlur` event is sent to a widget when one of its children loses focus. - -- [x] Bubbles -- [x] Verbose - -## Attributes - -_No other attributes_ - -## Code +--- +title: DescendantBlur +--- ::: textual.events.DescendantBlur + options: + heading_level: 1 ## See also +- [AppBlur](app_blur.md) +- [AppFocus](app_focus.md) - [Blur](blur.md) - [DescendantFocus](descendant_focus.md) - [Focus](focus.md) diff --git a/docs/events/descendant_focus.md b/docs/events/descendant_focus.md index 9eb3821805..0d03b29e59 100644 --- a/docs/events/descendant_focus.md +++ b/docs/events/descendant_focus.md @@ -1,20 +1,15 @@ -# DescendantFocus - -The `DescendantFocus` event is sent to a widget when one of its descendants receives focus. - -- [x] Bubbles -- [x] Verbose - -## Attributes - -_No other attributes_ - -## Code +--- +title: DescendantFocus +--- ::: textual.events.DescendantFocus + options: + heading_level: 1 ## See also +- [AppBlur](app_blur.md) +- [AppFocus](app_focus.md) - [Blur](blur.md) - [DescendantBlur](descendant_blur.md) - [Focus](focus.md) diff --git a/docs/events/enter.md b/docs/events/enter.md index 5fbcda7277..d6d532b913 100644 --- a/docs/events/enter.md +++ b/docs/events/enter.md @@ -1,14 +1,13 @@ -# Enter - -The `Enter` event is sent to a widget when the mouse pointer first moves over a widget. - -- [ ] Bubbles -- [x] Verbose - -## Attributes - -_No other attributes_ - -## Code - ::: textual.events.Enter + options: + heading_level: 1 + +## See also + +- [Click](click.md) +- [Leave](leave.md) +- [MouseDown](mouse_down.md) +- [MouseMove](mouse_move.md) +- [MouseScrollDown](mouse_scroll_down.md) +- [MouseScrollUp](mouse_scroll_up.md) +- [MouseUp](mouse_up.md) diff --git a/docs/events/focus.md b/docs/events/focus.md index e2c710f115..843e3b943c 100644 --- a/docs/events/focus.md +++ b/docs/events/focus.md @@ -1,20 +1,11 @@ -# Focus - -The `Focus` event is sent to a widget when it receives input focus. - -- [ ] Bubbles -- [ ] Verbose - -## Attributes - -_No other attributes_ - -## Code - ::: textual.events.Focus + options: + heading_level: 1 ## See also +- [AppBlur](app_blur.md) +- [AppFocus](app_focus.md) - [Blur](blur.md) - [DescendantBlur](descendant_blur.md) - [DescendantFocus](descendant_focus.md) diff --git a/docs/events/hide.md b/docs/events/hide.md index 2f7655b4ee..1e6d63a5c9 100644 --- a/docs/events/hide.md +++ b/docs/events/hide.md @@ -1,14 +1,3 @@ -# Hide - -The `Hide` event is sent to a widget when it is hidden from view. - -- [ ] Bubbles -- [ ] Verbose - -## Attributes - -_No additional attributes_ - -## Code - ::: textual.events.Hide + options: + heading_level: 1 diff --git a/docs/events/key.md b/docs/events/key.md index ae7e33250b..4f0bc74995 100644 --- a/docs/events/key.md +++ b/docs/events/key.md @@ -1,17 +1,3 @@ -# Key - -The `Key` event is sent to a widget when the user presses a key on the keyboard. - -- [x] Bubbles -- [ ] Verbose - -## Attributes - -| attribute | type | purpose | -| --------- | ----------- | ----------------------------------------------------------- | -| `key` | str | Name of the key that was pressed. | -| `char` | str or None | The character that was pressed, or None it isn't printable. | - -## Code - ::: textual.events.Key + options: + heading_level: 1 diff --git a/docs/events/leave.md b/docs/events/leave.md index 5a72463a97..e6231890da 100644 --- a/docs/events/leave.md +++ b/docs/events/leave.md @@ -1,14 +1,13 @@ -# Leave - -The `Leave` event is sent to a widget when the mouse pointer moves off a widget. - -- [ ] Bubbles -- [x] Verbose - -## Attributes - -_No other attributes_ - -## Code - ::: textual.events.Leave + options: + heading_level: 1 + +## See also + +- [Click](click.md) +- [Enter](enter.md) +- [MouseDown](mouse_down.md) +- [MouseMove](mouse_move.md) +- [MouseScrollDown](mouse_scroll_down.md) +- [MouseScrollUp](mouse_scroll_up.md) +- [MouseUp](mouse_up.md) diff --git a/docs/events/load.md b/docs/events/load.md index 2702a79062..16f5e3d153 100644 --- a/docs/events/load.md +++ b/docs/events/load.md @@ -1,16 +1,3 @@ -# Load - -The `Load` event is sent to the app prior to switching the terminal to application mode. - -The load event is typically used to do any setup actions required by the app that don't change the display. - -- [ ] Bubbles -- [ ] Verbose - -## Attributes - -_No additional attributes_ - -## Code - ::: textual.events.Load + options: + heading_level: 1 diff --git a/docs/events/mount.md b/docs/events/mount.md index 1b2377b77c..885b8f4e9e 100644 --- a/docs/events/mount.md +++ b/docs/events/mount.md @@ -1,16 +1,3 @@ -# Mount - -The `Mount` event is sent to a widget and Application when it is first mounted. - -The mount event is typically used to set the initial state of a widget or to add new children widgets. - -- [ ] Bubbles -- [x] Verbose - -## Attributes - -_No additional attributes_ - -## Code - ::: textual.events.Mount + options: + heading_level: 1 diff --git a/docs/events/mouse_capture.md b/docs/events/mouse_capture.md index 167478636f..945da83085 100644 --- a/docs/events/mouse_capture.md +++ b/docs/events/mouse_capture.md @@ -1,16 +1,11 @@ -# MouseCapture +--- +title: MouseCapture +--- -The `MouseCapture` event is sent to a widget when it is capturing mouse events from outside of its borders on the screen. - -- [ ] Bubbles -- [ ] Verbose - -## Attributes - -| attribute | type | purpose | -| ---------------- | ------ | --------------------------------------------- | -| `mouse_position` | Offset | Mouse coordinates when the mouse was captured | +::: textual.events.MouseCapture + options: + heading_level: 1 -## Code +## See also -::: textual.events.MouseCapture +- [MouseRelease](mouse_release.md) diff --git a/docs/events/mouse_down.md b/docs/events/mouse_down.md index 69ed3ca2fd..83d5e21cf4 100644 --- a/docs/events/mouse_down.md +++ b/docs/events/mouse_down.md @@ -1,25 +1,20 @@ -# MouseDown +--- +title: MouseDown +--- -The `MouseDown` event is sent to a widget when a mouse button is pressed. - -- [x] Bubbles -- [x] Verbose - -## Attributes +::: textual.events.MouseDown + options: + heading_level: 1 -| attribute | type | purpose | -| ---------- | ---- | ----------------------------------------- | -| `x` | int | Mouse x coordinate, relative to Widget | -| `y` | int | Mouse y coordinate, relative to Widget | -| `delta_x` | int | Change in x since last mouse event | -| `delta_y` | int | Change in y since last mouse event | -| `button` | int | Index of mouse button | -| `shift` | bool | Shift key pressed if True | -| `meta` | bool | Meta key pressed if True | -| `ctrl` | bool | Ctrl key pressed if True | -| `screen_x` | int | Mouse x coordinate relative to the screen | -| `screen_y` | int | Mouse y coordinate relative to the screen | +See [MouseEvent][textual.events.MouseEvent] for the full list of properties and methods. -## Code +## See also -::: textual.events.MouseDown +- [Click](click.md) +- [Enter](enter.md) +- [Leave](leave.md) +- [MouseEvent][textual.events.MouseEvent] +- [MouseMove](mouse_move.md) +- [MouseScrollDown](mouse_scroll_down.md) +- [MouseScrollUp](mouse_scroll_up.md) +- [MouseUp](mouse_up.md) diff --git a/docs/events/mouse_move.md b/docs/events/mouse_move.md index a781a2809b..3ac6632265 100644 --- a/docs/events/mouse_move.md +++ b/docs/events/mouse_move.md @@ -1,25 +1,20 @@ -# MouseMove +--- +title: MouseMove +--- -The `MouseMove` event is sent to a widget when the mouse pointer is moved over a widget. - -- [x] Bubbles -- [x] Verbose - -## Attributes +::: textual.events.MouseMove + options: + heading_level: 1 -| attribute | type | purpose | -|------------|------|-------------------------------------------| -| `x` | int | Mouse x coordinate, relative to Widget | -| `y` | int | Mouse y coordinate, relative to Widget | -| `delta_x` | int | Change in x since last mouse event | -| `delta_y` | int | Change in y since last mouse event | -| `button` | int | Index of mouse button | -| `shift` | bool | Shift key pressed if True | -| `meta` | bool | Meta key pressed if True | -| `ctrl` | bool | Ctrl key pressed if True | -| `screen_x` | int | Mouse x coordinate relative to the screen | -| `screen_y` | int | Mouse y coordinate relative to the screen | +See [MouseEvent][textual.events.MouseEvent] for the full list of properties and methods. -## Code +## See also -::: textual.events.MouseMove +- [Click](click.md) +- [Enter](enter.md) +- [Leave](leave.md) +- [MouseDown](mouse_down.md) +- [MouseEvent][textual.events.MouseEvent] +- [MouseScrollDown](mouse_scroll_down.md) +- [MouseScrollUp](mouse_scroll_up.md) +- [MouseUp](mouse_up.md) diff --git a/docs/events/mouse_release.md b/docs/events/mouse_release.md index 89d1fe4ed1..438e03569b 100644 --- a/docs/events/mouse_release.md +++ b/docs/events/mouse_release.md @@ -1,16 +1,11 @@ -# MouseRelease +--- +title: MouseRelease +--- -The `MouseRelease` event is sent to a widget when it is no longer receiving mouse events outside of its borders. - -- [ ] Bubbles -- [ ] Verbose - -## Attributes - -| attribute | type | purpose | -|------------------|--------|-----------------------------------------------| -| `mouse_position` | Offset | Mouse coordinates when the mouse was released | +::: textual.events.MouseRelease + options: + heading_level: 1 -## Code +## See also -::: textual.events.MouseRelease +- [MouseCapture](mouse_capture.md) diff --git a/docs/events/mouse_scroll_down.md b/docs/events/mouse_scroll_down.md index 7228cc0bba..bf51c7ff83 100644 --- a/docs/events/mouse_scroll_down.md +++ b/docs/events/mouse_scroll_down.md @@ -1,17 +1,20 @@ -# MouseScrollDown +--- +title: MouseScrollDown +--- -The `MouseScrollDown` event is sent to a widget when the scroll wheel (or trackpad equivalent) is moved _down_. - -- [x] Bubbles -- [x] Verbose - -## Attributes +::: textual.events.MouseScrollDown + options: + heading_level: 1 -| attribute | type | purpose | -|-----------|------|----------------------------------------| -| `x` | int | Mouse x coordinate, relative to Widget | -| `y` | int | Mouse y coordinate, relative to Widget | +See [MouseEvent][textual.events.MouseEvent] for the full list of properties and methods. -## Code +## See also -::: textual.events.MouseScrollDown +- [Click](click.md) +- [Enter](enter.md) +- [Leave](leave.md) +- [MouseDown](mouse_down.md) +- [MouseEvent][textual.events.MouseEvent] +- [MouseMove](mouse_move.md) +- [MouseScrollUp](mouse_scroll_up.md) +- [MouseUp](mouse_up.md) diff --git a/docs/events/mouse_scroll_up.md b/docs/events/mouse_scroll_up.md index 2114b5f410..facba94fdc 100644 --- a/docs/events/mouse_scroll_up.md +++ b/docs/events/mouse_scroll_up.md @@ -1,17 +1,20 @@ -# MouseScrollUp +--- +title: MouseScrollUp +--- -The `MouseScrollUp` event is sent to a widget when the scroll wheel (or trackpad equivalent) is moved _up_. - -- [x] Bubbles -- [x] Verbose - -## Attributes +::: textual.events.MouseScrollUp + options: + heading_level: 1 -| attribute | type | purpose | -|-----------|------|----------------------------------------| -| `x` | int | Mouse x coordinate, relative to Widget | -| `y` | int | Mouse y coordinate, relative to Widget | +See [MouseEvent][textual.events.MouseEvent] for the full list of properties and methods. -## Code +## See also -::: textual.events.MouseScrollUp +- [Click](click.md) +- [Enter](enter.md) +- [Leave](leave.md) +- [MouseDown](mouse_down.md) +- [MouseEvent][textual.events.MouseEvent] +- [MouseMove](mouse_move.md) +- [MouseScrollDown](mouse_scroll_down.md) +- [MouseUp](mouse_up.md) diff --git a/docs/events/mouse_up.md b/docs/events/mouse_up.md index 5b965132e8..97baf1d4ae 100644 --- a/docs/events/mouse_up.md +++ b/docs/events/mouse_up.md @@ -1,25 +1,20 @@ -# MouseUp +--- +title: MouseUp +--- -The `MouseUp` event is sent to a widget when the user releases a mouse button. - -- [x] Bubbles -- [x] Verbose - -## Attributes +::: textual.events.MouseUp + options: + heading_level: 1 -| attribute | type | purpose | -|------------|------|-------------------------------------------| -| `x` | int | Mouse x coordinate, relative to Widget | -| `y` | int | Mouse y coordinate, relative to Widget | -| `delta_x` | int | Change in x since last mouse event | -| `delta_y` | int | Change in y since last mouse event | -| `button` | int | Index of mouse button | -| `shift` | bool | Shift key pressed if True | -| `meta` | bool | Meta key pressed if True | -| `ctrl` | bool | Ctrl key pressed if True | -| `screen_x` | int | Mouse x coordinate relative to the screen | -| `screen_y` | int | Mouse y coordinate relative to the screen | +See [MouseEvent][textual.events.MouseEvent] for the full list of properties and methods. -## Code +## See also -::: textual.events.MouseUp +- [Click](click.md) +- [Enter](enter.md) +- [Leave](leave.md) +- [MouseDown](mouse_down.md) +- [MouseEvent][textual.events.MouseEvent] +- [MouseMove](mouse_move.md) +- [MouseScrollDown](mouse_scroll_down.md) +- [MouseScrollUp](mouse_scroll_up.md) diff --git a/docs/events/paste.md b/docs/events/paste.md index fdae43e5cc..fa2a01a641 100644 --- a/docs/events/paste.md +++ b/docs/events/paste.md @@ -1,16 +1,3 @@ -# Paste - -The `Paste` event is sent to a widget when the user pastes text. - -- [ ] Bubbles -- [ ] Verbose - -## Attributes - -| attribute | type | purpose | -|-----------|------|--------------------------| -| `text` | str | The text that was pasted | - -## Code - ::: textual.events.Paste + options: + heading_level: 1 diff --git a/docs/events/print.md b/docs/events/print.md new file mode 100644 index 0000000000..942ccbf5ea --- /dev/null +++ b/docs/events/print.md @@ -0,0 +1,3 @@ +::: textual.events.Print + options: + heading_level: 1 diff --git a/docs/events/resize.md b/docs/events/resize.md index 2ffe554afa..999ed7532c 100644 --- a/docs/events/resize.md +++ b/docs/events/resize.md @@ -1,18 +1,3 @@ -# Resize - -The `Resize` event is sent to a widget when its size changes and when it is first made visible. - -- [x] Bubbles -- [ ] Verbose - -## Attributes - -| attribute | type | purpose | -|------------------|------|--------------------------------------------------| -| `size` | Size | The new size of the Widget | -| `virtual_size` | Size | The virtual size (scrollable area) of the Widget | -| `container_size` | Size | The size of the container (parent widget) | - -## Code - ::: textual.events.Resize + options: + heading_level: 1 diff --git a/docs/events/screen_resume.md b/docs/events/screen_resume.md index 4852149a19..cd5f17266d 100644 --- a/docs/events/screen_resume.md +++ b/docs/events/screen_resume.md @@ -1,14 +1,7 @@ -# ScreenResume - -The `ScreenResume` event is sent to a **Screen** when it becomes current. - -- [ ] Bubbles -- [ ] Verbose - -## Attributes - -_No other attributes_ - -## Code +--- +title: ScreenResume +--- ::: textual.events.ScreenResume + options: + heading_level: 1 diff --git a/docs/events/screen_suspend.md b/docs/events/screen_suspend.md index b716832edd..f4c21a3355 100644 --- a/docs/events/screen_suspend.md +++ b/docs/events/screen_suspend.md @@ -1,14 +1,7 @@ -# ScreenSuspend - -The `ScreenSuspend` event is sent to a **Screen** when it is replaced by another screen. - -- [ ] Bubbles -- [ ] Verbose - -## Attributes - -_No other attributes_ - -## Code +--- +title: ScreenSuspend +--- ::: textual.events.ScreenSuspend + options: + heading_level: 1 diff --git a/docs/events/show.md b/docs/events/show.md index b669430ccd..7d77574092 100644 --- a/docs/events/show.md +++ b/docs/events/show.md @@ -1,14 +1,3 @@ -# Show - -The `Show` event is sent to a widget when it becomes visible. - -- [ ] Bubbles -- [ ] Verbose - -## Attributes - -_No additional attributes_ - -## Code - ::: textual.events.Show + options: + heading_level: 1 diff --git a/docs/examples/app/suspend.py b/docs/examples/app/suspend.py new file mode 100644 index 0000000000..6a0073e040 --- /dev/null +++ b/docs/examples/app/suspend.py @@ -0,0 +1,20 @@ +from os import system + +from textual import on +from textual.app import App, ComposeResult +from textual.widgets import Button + + +class SuspendingApp(App[None]): + + def compose(self) -> ComposeResult: + yield Button("Open the editor", id="edit") + + @on(Button.Pressed, "#edit") + def run_external_editor(self) -> None: + with self.suspend(): # (1)! + system("vim") + + +if __name__ == "__main__": + SuspendingApp().run() diff --git a/docs/examples/app/suspend_process.py b/docs/examples/app/suspend_process.py new file mode 100644 index 0000000000..695bd1cfc0 --- /dev/null +++ b/docs/examples/app/suspend_process.py @@ -0,0 +1,15 @@ +from textual.app import App, ComposeResult +from textual.binding import Binding +from textual.widgets import Label + + +class SuspendKeysApp(App[None]): + + BINDINGS = [Binding("ctrl+z", "suspend_process")] + + def compose(self) -> ComposeResult: + yield Label("Press Ctrl+Z to suspend!") + + +if __name__ == "__main__": + SuspendKeysApp().run() diff --git a/docs/examples/guide/animator/animation01_static.py b/docs/examples/guide/animator/animation01_static.py index fde4b6e621..cd336362e6 100644 --- a/docs/examples/guide/animator/animation01_static.py +++ b/docs/examples/guide/animator/animation01_static.py @@ -1,6 +1,9 @@ +from textual._easing import DEFAULT_EASING, EASING from textual.app import App, ComposeResult from textual.widgets import Static +ease = EASING[DEFAULT_EASING] + class AnimationApp(App): def compose(self) -> ComposeResult: @@ -10,6 +13,15 @@ def compose(self) -> ComposeResult: self.box.styles.padding = (1, 2) yield self.box + def key_1(self): + self.box.styles.opacity = 1 - ease(0.25) + + def key_2(self): + self.box.styles.opacity = 1 - ease(0.5) + + def key_3(self): + self.box.styles.opacity = 1 - ease(0.75) + if __name__ == "__main__": app = AnimationApp() diff --git a/docs/examples/guide/reactivity/recompose01.py b/docs/examples/guide/reactivity/recompose01.py new file mode 100644 index 0000000000..5502b977d8 --- /dev/null +++ b/docs/examples/guide/reactivity/recompose01.py @@ -0,0 +1,32 @@ +from datetime import datetime + +from textual.app import App, ComposeResult +from textual.reactive import reactive +from textual.widgets import Digits + + +class Clock(App): + + CSS = """ + Screen {align: center middle} + Digits {width: auto} + """ + + time: reactive[datetime] = reactive(datetime.now, init=False) + + def compose(self) -> ComposeResult: + yield Digits(f"{self.time:%X}") + + def watch_time(self) -> None: # (1)! + self.query_one(Digits).update(f"{self.time:%X}") + + def update_time(self) -> None: + self.time = datetime.now() + + def on_mount(self) -> None: + self.set_interval(1, self.update_time) # (2)! + + +if __name__ == "__main__": + app = Clock() + app.run() diff --git a/docs/examples/guide/reactivity/recompose02.py b/docs/examples/guide/reactivity/recompose02.py new file mode 100644 index 0000000000..7482dc3dfa --- /dev/null +++ b/docs/examples/guide/reactivity/recompose02.py @@ -0,0 +1,29 @@ +from datetime import datetime + +from textual.app import App, ComposeResult +from textual.reactive import reactive +from textual.widgets import Digits + + +class Clock(App): + + CSS = """ + Screen {align: center middle} + Digits {width: auto} + """ + + time: reactive[datetime] = reactive(datetime.now, recompose=True) + + def compose(self) -> ComposeResult: + yield Digits(f"{self.time:%X}") + + def update_time(self) -> None: + self.time = datetime.now() + + def on_mount(self) -> None: + self.set_interval(1, self.update_time) + + +if __name__ == "__main__": + app = Clock() + app.run() diff --git a/docs/examples/guide/reactivity/refresh03.py b/docs/examples/guide/reactivity/refresh03.py new file mode 100644 index 0000000000..889d2e942f --- /dev/null +++ b/docs/examples/guide/reactivity/refresh03.py @@ -0,0 +1,29 @@ +from textual.app import App, ComposeResult +from textual.reactive import reactive +from textual.widget import Widget +from textual.widgets import Input, Label + + +class Name(Widget): + """Generates a greeting.""" + + who = reactive("name", recompose=True) # (1)! + + def compose(self) -> ComposeResult: # (2)! + yield Label(f"Hello, {self.who}!") + + +class WatchApp(App): + CSS_PATH = "refresh02.tcss" + + def compose(self) -> ComposeResult: + yield Input(placeholder="Enter your name") + yield Name() + + def on_input_changed(self, event: Input.Changed) -> None: + self.query_one(Name).who = event.value + + +if __name__ == "__main__": + app = WatchApp() + app.run() diff --git a/docs/examples/guide/reactivity/refresh03.tcss b/docs/examples/guide/reactivity/refresh03.tcss new file mode 100644 index 0000000000..55ba7f0f8e --- /dev/null +++ b/docs/examples/guide/reactivity/refresh03.tcss @@ -0,0 +1,10 @@ +Input { + dock: top; + margin-top: 1; +} + +Name { + width: auto; + height: auto; + border: heavy $secondary; +} 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/how-to/render_compose.py b/docs/examples/how-to/render_compose.py index b1413d3d0c..06ce6a7fa8 100644 --- a/docs/examples/how-to/render_compose.py +++ b/docs/examples/how-to/render_compose.py @@ -1,6 +1,6 @@ from time import time -from textual.app import App, ComposeResult, RenderableType +from textual.app import App, ComposeResult, RenderResult from textual.containers import Container from textual.renderables.gradient import LinearGradient from textual.widgets import Static @@ -41,7 +41,7 @@ def on_mount(self) -> None: def compose(self) -> ComposeResult: yield Static("Making a splash with Textual!") # (2)! - def render(self) -> RenderableType: + def render(self) -> RenderResult: return LinearGradient(time() * 90, STOPS) # (3)! diff --git a/docs/examples/styles/border_all.py b/docs/examples/styles/border_all.py index 4e5a80675e..df76cbe186 100644 --- a/docs/examples/styles/border_all.py +++ b/docs/examples/styles/border_all.py @@ -17,6 +17,7 @@ def compose(self): Label("hkey", id="hkey"), Label("inner", id="inner"), Label("outer", id="outer"), + Label("panel", id="panel"), Label("round", id="round"), Label("solid", id="solid"), Label("tall", id="tall"), diff --git a/docs/examples/styles/border_all.tcss b/docs/examples/styles/border_all.tcss index 0b571c1a7a..bfdf48fdd9 100644 --- a/docs/examples/styles/border_all.tcss +++ b/docs/examples/styles/border_all.tcss @@ -34,6 +34,10 @@ border: outer $accent; } +#panel { + border: panel $accent; +} + #round { border: round $accent; } @@ -59,7 +63,7 @@ } Grid { - grid-size: 3 5; + grid-size: 4 4; align: center middle; grid-gutter: 1 2; } diff --git a/docs/examples/widgets/progress_bar_isolated_.py b/docs/examples/widgets/progress_bar_isolated_.py index 79907562cf..c1df28d5db 100644 --- a/docs/examples/widgets/progress_bar_isolated_.py +++ b/docs/examples/widgets/progress_bar_isolated_.py @@ -1,4 +1,5 @@ from textual.app import App, ComposeResult +from textual.clock import MockClock from textual.containers import Center, Middle from textual.timer import Timer from textual.widgets import Footer, ProgressBar @@ -11,13 +12,14 @@ class IndeterminateProgressBar(App[None]): """Timer to simulate progress happening.""" def compose(self) -> ComposeResult: + self.clock = MockClock() with Center(): with Middle(): - yield ProgressBar() + yield ProgressBar(clock=self.clock) yield Footer() def on_mount(self) -> None: - """Set up a timer to simulate progess happening.""" + """Set up a timer to simulate progress happening.""" self.progress_timer = self.set_interval(1 / 10, self.make_progress, pause=True) def make_progress(self) -> None: @@ -31,14 +33,18 @@ def action_start(self) -> None: def key_f(self) -> None: # Freeze time for the indeterminate progress bar. - self.query_one(ProgressBar).query_one("#bar")._get_elapsed_time = lambda: 5 + self.clock.set_time(5) + self.refresh() def key_t(self) -> None: # Freeze time to show always the same ETA. - self.query_one(ProgressBar).query_one("#eta")._get_elapsed_time = lambda: 3.9 - self.query_one(ProgressBar).update(total=100, progress=39) + self.clock.set_time(0) + self.query_one(ProgressBar).update(total=100, progress=0) + self.clock.set_time(3.9) + self.query_one(ProgressBar).update(progress=39) def key_u(self) -> None: + self.refresh() self.query_one(ProgressBar).update(total=100, progress=100) diff --git a/docs/examples/widgets/progress_bar_styled.py b/docs/examples/widgets/progress_bar_styled.py index 96c5005bab..f5b95ad055 100644 --- a/docs/examples/widgets/progress_bar_styled.py +++ b/docs/examples/widgets/progress_bar_styled.py @@ -18,7 +18,7 @@ def compose(self) -> ComposeResult: yield Footer() def on_mount(self) -> None: - """Set up a timer to simulate progess happening.""" + """Set up a timer to simulate progress happening.""" self.progress_timer = self.set_interval(1 / 10, self.make_progress, pause=True) def make_progress(self) -> None: diff --git a/docs/examples/widgets/progress_bar_styled_.py b/docs/examples/widgets/progress_bar_styled_.py index 8428f359a1..b195327567 100644 --- a/docs/examples/widgets/progress_bar_styled_.py +++ b/docs/examples/widgets/progress_bar_styled_.py @@ -1,4 +1,5 @@ from textual.app import App, ComposeResult +from textual.clock import MockClock from textual.containers import Center, Middle from textual.timer import Timer from textual.widgets import Footer, ProgressBar @@ -12,13 +13,14 @@ class StyledProgressBar(App[None]): """Timer to simulate progress happening.""" def compose(self) -> ComposeResult: + self.clock = MockClock() with Center(): with Middle(): - yield ProgressBar() + yield ProgressBar(clock=self.clock) yield Footer() def on_mount(self) -> None: - """Set up a timer to simulate progess happening.""" + """Set up a timer to simulate progress happening.""" self.progress_timer = self.set_interval(1 / 10, self.make_progress, pause=True) def make_progress(self) -> None: @@ -29,15 +31,18 @@ def action_start(self) -> None: """Start the progress tracking.""" self.query_one(ProgressBar).update(total=100) self.progress_timer.resume() + self.query_one(ProgressBar).refresh() def key_f(self) -> None: # Freeze time for the indeterminate progress bar. - self.query_one(ProgressBar).query_one("#bar")._get_elapsed_time = lambda: 5 + self.clock.set_time(5.0) def key_t(self) -> None: # Freeze time to show always the same ETA. - self.query_one(ProgressBar).query_one("#eta")._get_elapsed_time = lambda: 3.9 - self.query_one(ProgressBar).update(total=100, progress=39) + self.clock.set_time(0) + self.query_one(ProgressBar).update(total=100, progress=0) + self.clock.set_time(3.9) + self.query_one(ProgressBar).update(progress=39) def key_u(self) -> None: self.query_one(ProgressBar).update(total=100, progress=100) 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/actions.md b/docs/guide/actions.md index ca5ea8b824..4e7c8f8c19 100644 --- a/docs/guide/actions.md +++ b/docs/guide/actions.md @@ -139,5 +139,6 @@ Textual supports the following builtin actions which are defined on the app. - [action_remove_class][textual.app.App.action_remove_class] - [action_screenshot][textual.app.App.action_screenshot] - [action_switch_screen][textual.app.App.action_switch_screen] +- [action_suspend_process][textual.app.App.action_suspend_process] - [action_toggle_class][textual.app.App.action_toggle_class] - [action_toggle_dark][textual.app.App.action_toggle_dark] diff --git a/docs/guide/animation.md b/docs/guide/animation.md index 1e9724dbe0..50bc87d35d 100644 --- a/docs/guide/animation.md +++ b/docs/guide/animation.md @@ -16,33 +16,26 @@ The following example app contains a single `Static` widget which is immediately --8<-- "docs/examples/guide/animator/animation01.py" ``` -The animator updates the value of the `opacity` attribute on the `styles` object in small increments over two seconds. Here's what the output will look like after each half a second. - +The animator updates the value of the `opacity` attribute on the `styles` object in small increments over two seconds. Here's how the widget will change as time progresses: === "After 0s" ```{.textual path="docs/examples/guide/animator/animation01_static.py"} ``` -=== "After 0.5s" - - ```{.textual path="docs/examples/guide/animator/animation01.py" press="wait:500"} - ``` - - === "After 1s" - ```{.textual path="docs/examples/guide/animator/animation01.py" press="wait:1000"} + ```{.textual path="docs/examples/guide/animator/animation01_static.py" press="1"} ``` === "After 1.5s" - ```{.textual path="docs/examples/guide/animator/animation01.py" press="wait:1500"} + ```{.textual path="docs/examples/guide/animator/animation01_static.py" press="2"} ``` === "After 2s" - ```{.textual path="docs/examples/guide/animator/animation01.py" press="wait:2000"} + ```{.textual path="docs/examples/guide/animator/animation01_static.py" press="3"} ``` ## Duration and Speed diff --git a/docs/guide/app.md b/docs/guide/app.md index c78b8b52a7..5a59322802 100644 --- a/docs/guide/app.md +++ b/docs/guide/app.md @@ -240,6 +240,57 @@ if __name__ == "__main__" sys.exit(app.return_code or 0) ``` +## Suspending + +A Textual app may be suspended so you can leave application mode for a period of time. +This is often used to temporarily replace your app with another terminal application. + +You could use this to allow the user to edit content with their preferred text editor, for example. + +!!! info + + App suspension is unavailable with [textual-web](https://github.com/Textualize/textual-web). + +### Suspend context manager + +You can use the [App.suspend](/api/app/#textual.app.App.suspend) context manager to suspend your app. +The following Textual app will launch [vim](https://www.vim.org/) (a text editor) when the user clicks a button: + +=== "suspend.py" + + ```python hl_lines="15-16" + --8<-- "docs/examples/app/suspend.py" + ``` + + 1. All code in the body of the `with` statement will be run while the app is suspended. + +=== "Output" + + ```{.textual path="docs/examples/app/suspend.py"} + ``` + +### Suspending from foreground + +On Unix and Unix-like systems (GNU/Linux, macOS, etc) Textual has support for the user pressing a key combination to suspend the application as the foreground process. +Ordinarily this key combination is Ctrl+Z; +in a Textual application this is disabled by default, but an action is provided ([`action_suspend_process`](/api/app/#textual.app.App.action_suspend_process)) that you can bind in the usual way. +For example: + +=== "suspend_process.py" + + ```python hl_lines="8" + --8<-- "docs/examples/app/suspend_process.py" + ``` + +=== "Output" + + ```{.textual path="docs/examples/app/suspend_process.py"} + ``` + +!!! note + + If `suspend_process` is called on Windows, or when your application is being hosted under Textual Web, the call will be ignored. + ## CSS Textual apps can reference [CSS](CSS.md) files which define how your app and widgets will look, while keeping your Python code free of display related code (which tends to be messy). 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/devtools.md b/docs/guide/devtools.md index 2fed4e65e3..5dd7ec6a62 100644 --- a/docs/guide/devtools.md +++ b/docs/guide/devtools.md @@ -105,7 +105,7 @@ textual console -v ### Decreasing verbosity -Log messages are classififed in to groups, and the `-x` flag can be used to **exclude** all message from a group. The groups are: `EVENT`, `DEBUG`, `INFO`, `WARNING`, `ERROR`, `PRINT`, `SYSTEM`, and `LOGGING`. The group a message belongs to is printed after its timestamp. +Log messages are classififed in to groups, and the `-x` flag can be used to **exclude** all message from a group. The groups are: `EVENT`, `DEBUG`, `INFO`, `WARNING`, `ERROR`, `PRINT`, `SYSTEM`, `LOGGING` and `WORKER`. The group a message belongs to is printed after its timestamp. Multiple groups may be excluded, for example to exclude everything except warning, errors, and `print` statements: diff --git a/docs/guide/events.md b/docs/guide/events.md index d778473e35..0f06d6f7ce 100644 --- a/docs/guide/events.md +++ b/docs/guide/events.md @@ -43,7 +43,7 @@ When the `on_key` method returns, Textual will get the next event from the queue You may be familiar with Python's [super](https://docs.python.org/3/library/functions.html#super) function to call a function defined in a base class. You will not have to use this in event handlers as Textual will automatically call handler methods defined in a widget's base class(es). -For instance, let's say we are building the classic game of Pong and we have written a `Paddle` widget which extends [Static][textual.widgets.Static]. When a [Key][textual.events.Key] event arrives, Textual calls `Paddle.on_key` (to respond to ++left++ and ++right++ keys), then `Static.on_key`, and finally `Widget.on_key`. +For instance, let's say we are building the classic game of Pong and we have written a `Paddle` widget which extends [Static][textual.widgets.Static]. When a [Key][textual.events.Key] event arrives, Textual calls `Paddle.on_key` (to respond to ++up++ and ++down++ keys), then `Static.on_key`, and finally `Widget.on_key`. ### Preventing default behaviors @@ -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" + + ```css 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" + + ```css title="on_decorator.tcss" + --8<-- "docs/examples/events/on_decorator.tcss" + ``` + === "Output" ```{.textual path="docs/examples/events/on_decorator02.py"} @@ -313,7 +325,7 @@ Let's look at an example which looks up word definitions from an [api](https://d ``` === "dictionary.tcss" - ```python title="dictionary.tcss" + ```css title="dictionary.tcss" --8<-- "docs/examples/events/dictionary.tcss" ``` diff --git a/docs/guide/input.md b/docs/guide/input.md index 5f77a7f960..506adf5274 100644 --- a/docs/guide/input.md +++ b/docs/guide/input.md @@ -96,7 +96,7 @@ The following example shows how focus works in practice. === "key03.tcss" - ```python title="key03.tcss" hl_lines="15-17" + ```css title="key03.tcss" hl_lines="15-17" --8<-- "docs/examples/guide/input/key03.tcss" ``` @@ -138,7 +138,7 @@ The following example binds the keys ++r++, ++g++, and ++b++ to an action which === "binding01.tcss" - ```python title="binding01.tcss" + ```css title="binding01.tcss" --8<-- "docs/examples/guide/input/binding01.tcss" ``` @@ -208,7 +208,7 @@ The following example shows mouse movements being used to _attach_ a widget to t === "mouse01.tcss" - ```python title="mouse01.tcss" + ```css title="mouse01.tcss" --8<-- "docs/examples/guide/input/mouse01.tcss" ``` 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..0d191ad832 100644 --- a/docs/guide/reactivity.md +++ b/docs/guide/reactivity.md @@ -226,6 +226,84 @@ The app that uses `Counter` uses the method `watch` to keep its progress bar syn ```{.textual path="docs/examples/guide/reactivity/dynamic_watch.py" press="enter,enter,enter"} ``` +## Recompose + +An alternative to a refresh is *recompose*. +If you set `recompose=True` on a reactive, then Textual will remove all the child widgets and call [`compose()`][textual.widget.Widget.compose] again, when the reactive attribute changes. +The process of removing and mounting new widgets occurs in a single update, so it will appear as though the content has simply updated. + +The following example uses recompose: + +=== "refresh03.py" + + ```python hl_lines="10 12-13" + --8<-- "docs/examples/guide/reactivity/refresh03.py" + ``` + + 1. Setting `recompose=True` will cause all child widgets to be removed and `compose` called again to add new widgets. + 2. This `compose()` method will be called when `who` is changed. + +=== "refresh03.tcss" + + ```css + --8<-- "docs/examples/guide/reactivity/refresh03.tcss" + ``` + +=== "Output" + + ```{.textual path="docs/examples/guide/reactivity/refresh03.py" press="P,a,u,l"} + ``` + +While the end-result is identical to `refresh02.py`, this code works quite differently. +The main difference is that recomposing creates an entirely new set of child widgets rather than updating existing widgets. +So when the `who` attribute changes, the `Name` widget will replace its `Label` with a new instance (containing updated content). + +!!! warning + + You should avoid storing a reference to child widgets when using recompose. + Better to [query](../guide/queries.md) for a child widget when you need them. + +It is important to note that any child widgets will have their state reset after a recompose. +For simple content, that doesn't matter much. +But widgets with an internal state (such as [`DataTable`](../widgets/data_table.md), [`Input`](../widgets/input.md), or [`TextArea`](../widgets/text_area.md)) would not be particularly useful if recomposed. + +Recomposing is slightly less efficient than a simple refresh, and best avoided if you need to update rapidly or you have many child widgets. +That said, it can often simplify your code. +Let's look at a practical example. +First a version *without* recompose: + +=== "recompose01.py" + + ```python hl_lines="20 26-27" + --8<-- "docs/examples/guide/reactivity/recompose01.py" + ``` + + 1. Called when the `time` attribute changes. + 2. Update the time once a second. + +=== "Output" + + ```{.textual path="docs/examples/guide/reactivity/recompose01.py" } + ``` + +This displays a clock which updates once a second. +The code is straightforward, but note how we format the time in two places: `compose()` *and* `watch_time()`. +We can simplify this by recomposing rather than refreshing: + +=== "recompose02.py" + + ```python hl_lines="15" + --8<-- "docs/examples/guide/reactivity/recompose02.py" + ``` + +=== "Output" + + ```{.textual path="docs/examples/guide/reactivity/recompose02.py" } + ``` + +In this version, the app is recomposed when the `time` attribute changes, which replaces the `Digits` widget with a new instance and updated time. +There's no need for the `watch_time` method, because the new `Digits` instance will already show the current time. + ## Compute methods @@ -266,3 +344,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/images/screenshots/frogmouth.svg b/docs/images/screenshots/frogmouth.svg new file mode 100644 index 0000000000..5084919b2b --- /dev/null +++ b/docs/images/screenshots/frogmouth.svg @@ -0,0 +1,232 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Frogmouth + + + + + + + + + + +https://raw.githubusercontent.com/textualize/frogmouth/main/README.md + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +🖼  DiscordContentsLocalBookmarksHistory +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ +▼ Ⅰ Frogmouth +Frogmouth├── Ⅱ Screenshots +├── Ⅱ Compatibility +▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔├── Ⅱ Installing +Frogmouth is a Markdown viewer / browser for your terminal, ├── Ⅱ Running +built with Textual.├── Ⅱ Features +└── Ⅱ Follow this project +Frogmouth can open *.md files locally or via a URL. There is a  +familiar browser-like navigation stack, history, bookmarks, and +table of contents.▅▅ + +A quick video tour of Frogmouth. + +https://user-images.githubusercontent.com/554369/235305502-2699 +a70e-c9a6-495e-990e-67606d84bbfa.mp4 + +(thanks Screen Studio) + +▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ + +                        Screenshots                         + +▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ +▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ + +                       Compatibility                        + +▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ +Frogmouth runs on Linux, macOS, and Windows. Frogmouth requires +Python 3.8 or above. + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + F1  Help  F2  About  CTRL+N  Navigation  CTRL+Q  Quit  + + + diff --git a/docs/images/screenshots/harlequin.svg b/docs/images/screenshots/harlequin.svg new file mode 100644 index 0000000000..651b094f2e --- /dev/null +++ b/docs/images/screenshots/harlequin.svg @@ -0,0 +1,36 @@ +Harlequin Data Catalog ───────────── Query Editor ───────────────────────────────────────────────────────────────────────── +▼ f1 db 1  select +└─ ▼ main sch 2  drivers.surname,                                          +├─ ▶ circuits t 3  drivers.forename,                                         +├─ ▶ constructor_result 4  drivers.nationality,                                      +├─ ▶ constructor_standi 5  avg(driver_standings.position)asavg_standing,           +├─ ▶ constructors t 6  avg(driver_standings.points)asavg_points +├─ ▶ driver_standings t 7  fromdriver_standings +├─ ▼ drivers t 8  joindriversondriver_standings.driverid=drivers.driverid +│  ├─ code s 9  joinracesondriver_standings.raceid=races.raceid +│  ├─ dob d10  groupby123 +│  ├─ driverId ##11  orderbyavg_standing asc                                     +│  ├─ driverRef s +│  ├─ forename s +│  ├─ nationality s +│  ├─ number s +│  ├─ surname s──────────────────────────────────────────────────────────────────────────────────────── +│  └─ url sX Limit 500Run Query +├─ ▶ lap_times t Query Results (850 Records) ────────────────────────────────────────────────────────── +├─ ▶ pit_stops t surname s forename s nationality s avg_standing #.# av +├─ ▶ qualifying t Hamilton                 Lewis              British            2.66                14 +├─ ▶ races t Prost                    Alain              French             3.51                33 +├─ ▶ results t Stewart                  Jackie             British            3.78                24 +├─ ▶ seasons t Schumacher               Michael            German             4.33                46 +├─ ▶ sprint_results t Verstappen               Max                Dutch              5.09                12 +├─ ▶ status t Fangio                   Juan               Argentine          5.22                16 +└─ ▶ tbl1 t Pablo Montoya            Juan               Colombian          5.25                27 + Farina                   Nino               Italian            5.27                11 + Hulme                    Denny              New Zealander      5.34                14 + Fagioli                  Luigi              Italian            5.67                9. + Clark                    Jim                British            5.81                17 + Vettel                   Sebastian          German             5.84                10 + Senna                    Ayrton             Brazilian          5.92                31 + +──────────────────────────────────────────────────────────────────────────────────────────────────────────────────── + CTRL+Q  Quit  F1  Help  diff --git a/docs/images/screenshots/memray.svg b/docs/images/screenshots/memray.svg new file mode 100644 index 0000000000..994b110748 --- /dev/null +++ b/docs/images/screenshots/memray.svg @@ -0,0 +1,200 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + TUIApp + + + + + + + + + + Memray live tracking      Tue Feb 20 13:53:11 2024 + (∩`-´)⊃━☆゚.*・。゚  Heap Usage ───────────────────── +PID: 77542CMD: memray run --live -m http.server                               ███ +TID: 0x1Thread 1 of 1                               ███ +Samples: 6Duration: 6.1 seconds                               ███ +                               ███ +── 1.501MB (100% of 1.501MB max)  + +                      Location                      Total Bytes% TotalOwn Bytes% OwnAllocations + _run_tracker                                           1.440MB 95.94%  1.111KB0.07%        440 memray.comman + _run_module_code                                       1.381MB 91.99%   0.000B0.00%        388 <frozen runpy + _find_and_load                                         1.364MB 90.86% 960.000B0.06%        361 <frozen impor + _load_unlocked                                         1.360MB 90.62%   0.000B0.00%        355 <frozen impor▄▄ + exec_module                                            1.355MB 90.28%  1.225KB0.08%        351 <frozen impor + run_module                                             1.351MB 90.00%  1.273KB0.08%        354 <frozen runpy + _run_code                                              1.334MB 88.90% 890.000B0.06%        341 <frozen runpy + _call_with_frames_removed                              1.298MB 86.49%   0.000B0.00%        283 <frozen impor + get_code                                               1.168MB 77.80%   0.000B0.00%        185 <frozen impor + <module>                                               1.095MB 72.96%  1.688KB0.11%         95 http.server   + _find_and_load_unlocked                               59.031KB  3.84%   1.000B0.00%         40 <frozen impor + test                                                  42.097KB  2.74%   0.000B0.00%         27 http.server   + __init__                                              41.565KB  2.70%   0.000B0.00%         20 socketserver  + getfqdn                                               40.933KB  2.66%  2.135KB0.14%         18 socket        + server_bind                                           40.933KB  2.66%   0.000B0.00%         18 http.server   + search_function                                       38.798KB  2.52%   0.000B0.00%         16 encodings     + _handle_fromlist                                      29.723KB  1.93%   0.000B0.00%         33 <frozen impor + <module>                                              24.617KB  1.60%  1.688KB0.11%          6 encodings.idn + _compile                                              23.629KB  1.54%   0.000B0.00%         11 re            + + Q  Quit  <  Previous Thread  >  Next Thread  T  Sort by Total  O  Sort by Own  A  Sort by Allocations  SPACE  Pause  + + + diff --git a/docs/images/screenshots/toolong.svg b/docs/images/screenshots/toolong.svg new file mode 100644 index 0000000000..8ac6c95d96 --- /dev/null +++ b/docs/images/screenshots/toolong.svg @@ -0,0 +1,199 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + UI + + + + + + + + + + +▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ +feedsX Case sensitiveX Regex +▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +162.71.236.120 - - [29/Jan/2024:13:34:58 +0000]"GET /feeds/posts/ HTTP/1.1"200107059"-""Net +52.70.240.171 - - [29/Jan/2024:13:35:33 +0000]"GET /2007/07/10/postmarkup-105/ HTTP/1.1"3010 +121.137.55.45 - - [29/Jan/2024:13:36:19 +0000]"GET /blog/rootblog/feeds/posts/ HTTP/1.1"20010 +98.207.26.211 - - [29/Jan/2024:13:36:37 +0000]"GET /feeds/posts HTTP/1.1"3070"-""Mozilla/5. +98.207.26.211 - - [29/Jan/2024:13:36:42 +0000]"GET /feeds/posts/ HTTP/1.1"20098063"-""Mozil +18.183.222.19 - - [29/Jan/2024:13:37:44 +0000]"GET /blog/rootblog/feeds/posts/ HTTP/1.1"20010 +66.249.64.164 - - [29/Jan/2024:13:37:46 +0000]"GET /blog/tech/post/a-texture-mapped-spinning-3d +116.203.207.165 - - [29/Jan/2024:13:37:55 +0000]"GET /blog/tech/feeds/posts/ HTTP/1.1"2001182 +128.65.195.158 - - [29/Jan/2024:13:38:44 +0000]"GET /feeds/posts/ HTTP/1.1"200107059"https:/ +128.65.195.158 - - [29/Jan/2024:13:38:46 +0000]"GET /feeds/posts/ HTTP/1.1"200107059"https:/ +51.222.253.12 - - [29/Jan/2024:13:41:17 +0000]"GET /blog/tech/post/css-in-the-terminal-with-pyt +154.159.237.77 - - [29/Jan/2024:13:42:28 +0000]"GET /feeds/posts/ HTTP/1.1"200107059"-""Moz +92.247.181.10 - - [29/Jan/2024:13:43:23 +0000]"GET /feed/ HTTP/1.1"200107059"https://www.wil +134.209.40.52 - - [29/Jan/2024:13:43:41 +0000]"GET /blog/tech/feeds/posts/ HTTP/1.1"200118238 +192.3.134.205 - - [29/Jan/2024:13:43:55 +0000]"GET /feeds/posts/ HTTP/1.1"200107059"-""Mozi +174.136.108.22 - - [29/Jan/2024:13:44:42 +0000]"GET /feeds/posts/ HTTP/1.1"200107059"-""Tin +64.71.157.117 - - [29/Jan/2024:13:45:16 +0000]"GET /feed/ HTTP/1.1"200107059"-""Feedbin fee +121.137.55.45 - - [29/Jan/2024:13:45:19 +0000]"GET /blog/rootblog/feeds/posts/ HTTP/1.1"20010 +216.244.66.233 - - [29/Jan/2024:13:45:22 +0000]"GET /robots.txt HTTP/1.1"200132"-""Mozilla/ +78.82.5.250 - - [29/Jan/2024:13:45:29 +0000]"GET /blog/tech/post/real-working-hyperlinks-in-the +78.82.5.250 - - [29/Jan/2024:13:45:30 +0000]"GET /favicon.ico HTTP/1.1"2005694"https://www.w▁▁ +46.244.252.112 - - [29/Jan/2024:13:46:44 +0000]"GET /blog/tech/feeds/posts/ HTTP/1.1"20011823▁▁ + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +f1 Help^t Tail^l Line nos.^g Go to Next PreviousTAIL29/01/2024 13:34:58 • 2540 + + + diff --git a/docs/index.md b/docs/index.md index 1c06781407..17875dc457 100644 --- a/docs/index.md +++ b/docs/index.md @@ -77,20 +77,102 @@ Build sophisticated user interfaces with a simple Python API. Run your apps in t +--- + +
+ +--8<-- "docs/images/screenshots/toolong.svg" + +
+ +--- + +
+ +--8<-- "docs/images/screenshots/frogmouth.svg" + +
+ +--- + +
+ +--8<-- "docs/images/screenshots/memray.svg" + +
+ +--- + + + + +![Dolphie](https://www.textualize.io/static/img/dolphie.png) + + + + +--- + +
+ +--8<-- "docs/images/screenshots/harlequin.svg" + +
+ -```{.textual path="examples/calculator.py" columns=100 lines=41 press="3,.,1,4,5,9,2,wait:400"} -``` +--- + + +=== "Stopwatch tutorial" + +
+ + +=== "stopwatch.py" + + ```python + --8<-- "docs/examples/tutorial/stopwatch.py" + ``` + +=== "stopwatch.tcss" + + ```css + --8<-- "examples/calculator.tcss" + ``` + + +--- + + +=== "Pride example" + + ```{.textual path="examples/pride.py"} + ``` + +=== "pride.py" + + ```py + --8<-- "examples/pride.py" + ``` + + + +--- + +=== "Calculator example" -```{.textual path="examples/pride.py"} -``` + ```{.textual path="examples/calculator.py" columns=100 lines=41 press="3,.,1,4,5,9,2,wait:400"} + ``` -```{.textual path="docs/examples/tutorial/stopwatch.py" columns="100" lines="30" press="d,tab,enter"} -``` +=== "calculator.py" + ```python + --8<-- "examples/calculator.py" + ``` -```{.textual path="docs/examples/guide/layout/combining_layouts.py" columns="100", lines="30"} -``` +=== "calculator.tcss" -```{.textual path="docs/examples/app/widgets01.py"} -``` + ```css + --8<-- "examples/calculator.tcss" + ``` diff --git a/docs/widgets/loading_indicator.md b/docs/widgets/loading_indicator.md index 0e2f6f43fe..bcd9910445 100644 --- a/docs/widgets/loading_indicator.md +++ b/docs/widgets/loading_indicator.md @@ -7,6 +7,15 @@ Displays pulsating dots to indicate when data is being loaded. - [ ] Focusable - [ ] Container + +!!! tip + + Widgets have a [`loading`][textual.widget.Widget.loading] reactive which + you can use to temporarily replace your widget with a `LoadingIndicator`. + See the [Loading Indicator](../guide/widgets.md#loading-indicator) section + in the Widgets guide for details. + + ## Example Simple usage example: diff --git a/docs/widgets/option_list.md b/docs/widgets/option_list.md index 100c3052aa..ee1eb22f1e 100644 --- a/docs/widgets/option_list.md +++ b/docs/widgets/option_list.md @@ -27,7 +27,7 @@ options: === "option_list.tcss" - ~~~python + ~~~css --8<-- "docs/examples/widgets/option_list.tcss" ~~~ @@ -50,7 +50,7 @@ class can be used to add separator lines between options. === "option_list.tcss" - ~~~python + ~~~css --8<-- "docs/examples/widgets/option_list.tcss" ~~~ @@ -75,7 +75,7 @@ tables](https://rich.readthedocs.io/en/latest/tables.html): === "option_list.tcss" - ~~~python + ~~~css --8<-- "docs/examples/widgets/option_list.tcss" ~~~ diff --git a/docs/widgets/selection_list.md b/docs/widgets/selection_list.md index f1c63b8d49..1ece006784 100644 --- a/docs/widgets/selection_list.md +++ b/docs/widgets/selection_list.md @@ -54,7 +54,7 @@ optionally contain a flag for the initial selected state of the option. === "selection_list.tcss" - ~~~python + ~~~css --8<-- "docs/examples/widgets/selection_list.tcss" ~~~ @@ -78,7 +78,7 @@ Alternatively, selections can be passed in as === "selection_list.tcss" - ~~~python + ~~~css --8<-- "docs/examples/widgets/selection_list.tcss" ~~~ @@ -105,7 +105,7 @@ collection of selected values: === "selection_list.tcss" - ~~~python + ~~~css --8<-- "docs/examples/widgets/selection_list_selected.tcss" ~~~ diff --git a/docs/widgets/tabbed_content.md b/docs/widgets/tabbed_content.md index 15164e7907..e6edb899cc 100644 --- a/docs/widgets/tabbed_content.md +++ b/docs/widgets/tabbed_content.md @@ -127,6 +127,7 @@ For example, to create a `TabbedContent` that has red and green labels: ## Messages +- [TabbedContent.Cleared][textual.widgets.TabbedContent.Cleared] - [TabbedContent.TabActivated][textual.widgets.TabbedContent.TabActivated] ## Bindings diff --git a/docs/widgets/text_area.md b/docs/widgets/text_area.md index bc3a5e25ad..c0a95c265a 100644 --- a/docs/widgets/text_area.md +++ b/docs/widgets/text_area.md @@ -1,17 +1,25 @@ - # TextArea -!!! tip "Added in version 0.38.0" +!!! tip + + Added in version 0.38.0. Soft wrapping added in version 0.48.0. A widget for editing text which may span multiple lines. -Supports syntax highlighting for a selection of languages. +Supports text selection, soft wrapping, optional syntax highlighting with tree-sitter +and a variety of keybindings. - [x] Focusable - [ ] Container - ## Guide +### Code editing vs plain text editing + +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`][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 To enable syntax highlighting, you'll need to install the `syntax` extra dependencies: @@ -29,7 +37,8 @@ To enable syntax highlighting, you'll need to install the `syntax` extra depende ``` This will install `tree-sitter` and `tree-sitter-languages`. -These packages are distributed as binary wheels, so it may limit your applications ability to run in environments where these wheels are not supported. +These packages are distributed as binary wheels, so it may limit your applications ability to run in environments where these wheels are not available. +After installing, you can set the [`language`][textual.widgets._text_area.TextArea.language] reactive attribute on the `TextArea` to enable highlighting. ### Loading text @@ -46,8 +55,7 @@ In this example we load some initial text into the `TextArea`, and set the langu --8<-- "docs/examples/widgets/text_area_example.py" ``` -To load content into the `TextArea` after it has already been created, -use the [`load_text`][textual.widgets._text_area.TextArea.load_text] method. +To update the content programmatically, set the [`text`][textual.widgets._text_area.TextArea.text] property to a string value. To update the parser used for syntax highlighting, set the [`language`][textual.widgets._text_area.TextArea.language] reactive attribute: @@ -59,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`: @@ -82,7 +89,7 @@ Some other convenient methods are available, such as [`insert`][textual.widgets. #### Moving the cursor The cursor location is available via the [`cursor_location`][textual.widgets._text_area.TextArea.cursor_location] property, which represents -the location of the cursor as a tuple `(row_index, column_index)`. These indices are zero-based. +the location of the cursor as a tuple `(row_index, column_index)`. These indices are zero-based and represent the position of the cursor in the content. Writing a new value to `cursor_location` will immediately update the location of the cursor. ```python @@ -127,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. @@ -164,13 +171,26 @@ There are some methods available which make common selections easier: Themes give you control over the look and feel, including syntax highlighting, the cursor, selection, gutter, and more. +#### Default theme + +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. + +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. + #### Using builtin themes 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. @@ -178,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] @@ -192,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]. @@ -223,6 +248,7 @@ my_theme = TextAreaTheme( Attributes like `cursor_style` and `cursor_line_style` apply general language-agnostic styling to the widget. +If you choose not to supply a value for one of these attributes, it will be taken from the CSS component styles. The `syntax_styles` attribute of `TextAreaTheme` is used for syntax highlighting and depends on the `language` currently in use. @@ -262,12 +288,41 @@ This immediately updates the appearance of the `TextArea`: ```{.textual path="docs/examples/widgets/text_area_custom_theme.py" columns="42" lines="8"} ``` +### 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_behavior` attribute to the string value `"indent"`. +While in this mode, you can shift focus by pressing the ++escape++ key. + ### Indentation The character(s) inserted when you press tab is controlled by setting the `indent_type` attribute to either `tabs` or `spaces`. 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 @@ -315,7 +370,7 @@ Let's extend `TextArea` to add a feature which automatically closes parentheses This intercepts the key handler when `"("` is pressed, and inserts `"()"` instead. It then moves the cursor so that it lands between the open and closing parentheses. -Typing `def hello(` into the `TextArea` results in the bracket automatically being closed: +Typing "`def hello(`" into the `TextArea` now results in the bracket automatically being closed: ```{.textual path="docs/examples/widgets/text_area_extended.py" columns="36" lines="4" press="d,e,f,space,h,e,l,l,o,left_parenthesis"} ``` @@ -430,17 +485,28 @@ 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. + +- `navigator` is a [`DocumentNavigator`][textual.widgets.text_area.DocumentNavigator] instance which can give us general information about the cursor's location within a document, as well as where the cursor will move to when certain actions are performed. +- `wrapped_document` is a [`WrappedDocument`][textual.widgets.text_area.WrappedDocument] instance which can be used to convert document locations to visual locations, taking wrapping into account. It also offers a variety of other convenience methods and properties. + +A detailed view of these classes is out of scope, but do note that a lot of the functionality of `TextArea` exists within them, so inspecting them could be worthwhile. + ## Reactive attributes -| 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. | -| `selection` | `Selection` | `Selection()` | The current selection. | -| `show_line_numbers` | `bool` | `True` | 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. | +| Name | Type | Default | Description | +|------------------------|--------------------------|---------------|------------------------------------------------------------------| +| `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 @@ -456,21 +522,31 @@ The `TextArea` widget defines the following bindings: show_root_heading: false show_root_toc_entry: false - ## Component classes -The `TextArea` widget defines no component classes. +The `TextArea` defines component classes that can style various aspects of the widget. +Styles from the `theme` attribute take priority. -Styling should be done exclusively via [`TextAreaTheme`][textual.widgets.text_area.TextAreaTheme]. +::: textual.widgets.TextArea.COMPONENT_CLASSES + options: + show_root_heading: false + show_root_toc_entry: false ## See also -- [`Input`][textual.widgets.Input] - for single-line text input. -- [`TextAreaTheme`][textual.widgets.text_area.TextAreaTheme] - for theming the `TextArea`. +- [`Input`][textual.widgets.Input] - single-line text input widget +- [`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. + --- ::: textual.widgets._text_area.TextArea diff --git a/examples/calculator.py b/examples/calculator.py index 90e566c694..9c8f2f9e76 100644 --- a/examples/calculator.py +++ b/examples/calculator.py @@ -1,11 +1,9 @@ """ - An implementation of a classic calculator, with a layout inspired by macOS calculator. - +Works like a real calculator. Click the buttons or press the equivalent keys. """ - from decimal import Decimal from textual import events, on @@ -28,6 +26,7 @@ class CalculatorApp(App): value = var("") operator = var("plus") + # Maps button IDs on to the corresponding key name NAME_MAP = { "asterisk": "multiply", "slash": "divide", diff --git a/mkdocs-nav.yml b/mkdocs-nav.yml index a5ab15880a..1ba90e4ee6 100644 --- a/mkdocs-nav.yml +++ b/mkdocs-nav.yml @@ -43,7 +43,10 @@ nav: - "css_types/vertical.md" - Events: - "events/index.md" + - "events/app_blur.md" + - "events/app_focus.md" - "events/blur.md" + - "events/click.md" - "events/descendant_blur.md" - "events/descendant_focus.md" - "events/enter.md" @@ -54,7 +57,6 @@ nav: - "events/load.md" - "events/mount.md" - "events/mouse_capture.md" - - "events/click.md" - "events/mouse_down.md" - "events/mouse_move.md" - "events/mouse_release.md" @@ -62,11 +64,13 @@ nav: - "events/mouse_scroll_up.md" - "events/mouse_up.md" - "events/paste.md" + - "events/print.md" - "events/resize.md" - "events/screen_resume.md" - "events/screen_suspend.md" - "events/show.md" - Styles: + - "styles/index.md" - "styles/align.md" - "styles/background.md" - "styles/border.md" @@ -83,8 +87,6 @@ nav: - "styles/content_align.md" - "styles/display.md" - "styles/dock.md" - - "styles/index.md" - - "styles/keyline.md" - Grid: - "styles/grid/index.md" - "styles/grid/column_span.md" @@ -94,6 +96,7 @@ nav: - "styles/grid/grid_size.md" - "styles/grid/row_span.md" - "styles/height.md" + - "styles/keyline.md" - "styles/layer.md" - "styles/layers.md" - "styles/layout.md" @@ -178,6 +181,7 @@ nav: - "api/cache.md" - "api/color.md" - "api/command.md" + - "api/constants.md" - "api/containers.md" - "api/content_switcher.md" - "api/coordinate.md" diff --git a/poetry.lock b/poetry.lock index 3dfffadb4c..94c07ff975 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,88 +1,88 @@ -# This file is automatically @generated by Poetry 1.7.1 and should not be changed by hand. +# This file is automatically @generated by Poetry 1.6.1 and should not be changed by hand. [[package]] name = "aiohttp" -version = "3.9.1" +version = "3.9.3" description = "Async http client/server framework (asyncio)" optional = false python-versions = ">=3.8" files = [ - {file = "aiohttp-3.9.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:e1f80197f8b0b846a8d5cf7b7ec6084493950d0882cc5537fb7b96a69e3c8590"}, - {file = "aiohttp-3.9.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:c72444d17777865734aa1a4d167794c34b63e5883abb90356a0364a28904e6c0"}, - {file = "aiohttp-3.9.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:9b05d5cbe9dafcdc733262c3a99ccf63d2f7ce02543620d2bd8db4d4f7a22f83"}, - {file = "aiohttp-3.9.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5c4fa235d534b3547184831c624c0b7c1e262cd1de847d95085ec94c16fddcd5"}, - {file = "aiohttp-3.9.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:289ba9ae8e88d0ba16062ecf02dd730b34186ea3b1e7489046fc338bdc3361c4"}, - {file = "aiohttp-3.9.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bff7e2811814fa2271be95ab6e84c9436d027a0e59665de60edf44e529a42c1f"}, - {file = "aiohttp-3.9.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:81b77f868814346662c96ab36b875d7814ebf82340d3284a31681085c051320f"}, - {file = "aiohttp-3.9.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3b9c7426923bb7bd66d409da46c41e3fb40f5caf679da624439b9eba92043fa6"}, - {file = "aiohttp-3.9.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:8d44e7bf06b0c0a70a20f9100af9fcfd7f6d9d3913e37754c12d424179b4e48f"}, - {file = "aiohttp-3.9.1-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:22698f01ff5653fe66d16ffb7658f582a0ac084d7da1323e39fd9eab326a1f26"}, - {file = "aiohttp-3.9.1-cp310-cp310-musllinux_1_1_ppc64le.whl", hash = "sha256:ca7ca5abfbfe8d39e653870fbe8d7710be7a857f8a8386fc9de1aae2e02ce7e4"}, - {file = "aiohttp-3.9.1-cp310-cp310-musllinux_1_1_s390x.whl", hash = "sha256:8d7f98fde213f74561be1d6d3fa353656197f75d4edfbb3d94c9eb9b0fc47f5d"}, - {file = "aiohttp-3.9.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:5216b6082c624b55cfe79af5d538e499cd5f5b976820eac31951fb4325974501"}, - {file = "aiohttp-3.9.1-cp310-cp310-win32.whl", hash = "sha256:0e7ba7ff228c0d9a2cd66194e90f2bca6e0abca810b786901a569c0de082f489"}, - {file = "aiohttp-3.9.1-cp310-cp310-win_amd64.whl", hash = "sha256:c7e939f1ae428a86e4abbb9a7c4732bf4706048818dfd979e5e2839ce0159f23"}, - {file = "aiohttp-3.9.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:df9cf74b9bc03d586fc53ba470828d7b77ce51b0582d1d0b5b2fb673c0baa32d"}, - {file = "aiohttp-3.9.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:ecca113f19d5e74048c001934045a2b9368d77b0b17691d905af18bd1c21275e"}, - {file = "aiohttp-3.9.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:8cef8710fb849d97c533f259103f09bac167a008d7131d7b2b0e3a33269185c0"}, - {file = "aiohttp-3.9.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bea94403a21eb94c93386d559bce297381609153e418a3ffc7d6bf772f59cc35"}, - {file = "aiohttp-3.9.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:91c742ca59045dce7ba76cab6e223e41d2c70d79e82c284a96411f8645e2afff"}, - {file = "aiohttp-3.9.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6c93b7c2e52061f0925c3382d5cb8980e40f91c989563d3d32ca280069fd6a87"}, - {file = "aiohttp-3.9.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ee2527134f95e106cc1653e9ac78846f3a2ec1004cf20ef4e02038035a74544d"}, - {file = "aiohttp-3.9.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:11ff168d752cb41e8492817e10fb4f85828f6a0142b9726a30c27c35a1835f01"}, - {file = "aiohttp-3.9.1-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:b8c3a67eb87394386847d188996920f33b01b32155f0a94f36ca0e0c635bf3e3"}, - {file = "aiohttp-3.9.1-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:c7b5d5d64e2a14e35a9240b33b89389e0035e6de8dbb7ffa50d10d8b65c57449"}, - {file = "aiohttp-3.9.1-cp311-cp311-musllinux_1_1_ppc64le.whl", hash = "sha256:69985d50a2b6f709412d944ffb2e97d0be154ea90600b7a921f95a87d6f108a2"}, - {file = "aiohttp-3.9.1-cp311-cp311-musllinux_1_1_s390x.whl", hash = "sha256:c9110c06eaaac7e1f5562caf481f18ccf8f6fdf4c3323feab28a93d34cc646bd"}, - {file = "aiohttp-3.9.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:d737e69d193dac7296365a6dcb73bbbf53bb760ab25a3727716bbd42022e8d7a"}, - {file = "aiohttp-3.9.1-cp311-cp311-win32.whl", hash = "sha256:4ee8caa925aebc1e64e98432d78ea8de67b2272252b0a931d2ac3bd876ad5544"}, - {file = "aiohttp-3.9.1-cp311-cp311-win_amd64.whl", hash = "sha256:a34086c5cc285be878622e0a6ab897a986a6e8bf5b67ecb377015f06ed316587"}, - {file = "aiohttp-3.9.1-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:f800164276eec54e0af5c99feb9494c295118fc10a11b997bbb1348ba1a52065"}, - {file = "aiohttp-3.9.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:500f1c59906cd142d452074f3811614be04819a38ae2b3239a48b82649c08821"}, - {file = "aiohttp-3.9.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0b0a6a36ed7e164c6df1e18ee47afbd1990ce47cb428739d6c99aaabfaf1b3af"}, - {file = "aiohttp-3.9.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:69da0f3ed3496808e8cbc5123a866c41c12c15baaaead96d256477edf168eb57"}, - {file = "aiohttp-3.9.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:176df045597e674fa950bf5ae536be85699e04cea68fa3a616cf75e413737eb5"}, - {file = "aiohttp-3.9.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b796b44111f0cab6bbf66214186e44734b5baab949cb5fb56154142a92989aeb"}, - {file = "aiohttp-3.9.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f27fdaadce22f2ef950fc10dcdf8048407c3b42b73779e48a4e76b3c35bca26c"}, - {file = "aiohttp-3.9.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:bcb6532b9814ea7c5a6a3299747c49de30e84472fa72821b07f5a9818bce0f66"}, - {file = "aiohttp-3.9.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:54631fb69a6e44b2ba522f7c22a6fb2667a02fd97d636048478db2fd8c4e98fe"}, - {file = "aiohttp-3.9.1-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:4b4c452d0190c5a820d3f5c0f3cd8a28ace48c54053e24da9d6041bf81113183"}, - {file = "aiohttp-3.9.1-cp312-cp312-musllinux_1_1_ppc64le.whl", hash = "sha256:cae4c0c2ca800c793cae07ef3d40794625471040a87e1ba392039639ad61ab5b"}, - {file = "aiohttp-3.9.1-cp312-cp312-musllinux_1_1_s390x.whl", hash = "sha256:565760d6812b8d78d416c3c7cfdf5362fbe0d0d25b82fed75d0d29e18d7fc30f"}, - {file = "aiohttp-3.9.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:54311eb54f3a0c45efb9ed0d0a8f43d1bc6060d773f6973efd90037a51cd0a3f"}, - {file = "aiohttp-3.9.1-cp312-cp312-win32.whl", hash = "sha256:85c3e3c9cb1d480e0b9a64c658cd66b3cfb8e721636ab8b0e746e2d79a7a9eed"}, - {file = "aiohttp-3.9.1-cp312-cp312-win_amd64.whl", hash = "sha256:11cb254e397a82efb1805d12561e80124928e04e9c4483587ce7390b3866d213"}, - {file = "aiohttp-3.9.1-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:8a22a34bc594d9d24621091d1b91511001a7eea91d6652ea495ce06e27381f70"}, - {file = "aiohttp-3.9.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:598db66eaf2e04aa0c8900a63b0101fdc5e6b8a7ddd805c56d86efb54eb66672"}, - {file = "aiohttp-3.9.1-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:2c9376e2b09895c8ca8b95362283365eb5c03bdc8428ade80a864160605715f1"}, - {file = "aiohttp-3.9.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:41473de252e1797c2d2293804e389a6d6986ef37cbb4a25208de537ae32141dd"}, - {file = "aiohttp-3.9.1-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9c5857612c9813796960c00767645cb5da815af16dafb32d70c72a8390bbf690"}, - {file = "aiohttp-3.9.1-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ffcd828e37dc219a72c9012ec44ad2e7e3066bec6ff3aaa19e7d435dbf4032ca"}, - {file = "aiohttp-3.9.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:219a16763dc0294842188ac8a12262b5671817042b35d45e44fd0a697d8c8361"}, - {file = "aiohttp-3.9.1-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f694dc8a6a3112059258a725a4ebe9acac5fe62f11c77ac4dcf896edfa78ca28"}, - {file = "aiohttp-3.9.1-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:bcc0ea8d5b74a41b621ad4a13d96c36079c81628ccc0b30cfb1603e3dfa3a014"}, - {file = "aiohttp-3.9.1-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:90ec72d231169b4b8d6085be13023ece8fa9b1bb495e4398d847e25218e0f431"}, - {file = "aiohttp-3.9.1-cp38-cp38-musllinux_1_1_ppc64le.whl", hash = "sha256:cf2a0ac0615842b849f40c4d7f304986a242f1e68286dbf3bd7a835e4f83acfd"}, - {file = "aiohttp-3.9.1-cp38-cp38-musllinux_1_1_s390x.whl", hash = "sha256:0e49b08eafa4f5707ecfb321ab9592717a319e37938e301d462f79b4e860c32a"}, - {file = "aiohttp-3.9.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:2c59e0076ea31c08553e868cec02d22191c086f00b44610f8ab7363a11a5d9d8"}, - {file = "aiohttp-3.9.1-cp38-cp38-win32.whl", hash = "sha256:4831df72b053b1eed31eb00a2e1aff6896fb4485301d4ccb208cac264b648db4"}, - {file = "aiohttp-3.9.1-cp38-cp38-win_amd64.whl", hash = "sha256:3135713c5562731ee18f58d3ad1bf41e1d8883eb68b363f2ffde5b2ea4b84cc7"}, - {file = "aiohttp-3.9.1-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:cfeadf42840c1e870dc2042a232a8748e75a36b52d78968cda6736de55582766"}, - {file = "aiohttp-3.9.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:70907533db712f7aa791effb38efa96f044ce3d4e850e2d7691abd759f4f0ae0"}, - {file = "aiohttp-3.9.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:cdefe289681507187e375a5064c7599f52c40343a8701761c802c1853a504558"}, - {file = "aiohttp-3.9.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d7481f581251bb5558ba9f635db70908819caa221fc79ee52a7f58392778c636"}, - {file = "aiohttp-3.9.1-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:49f0c1b3c2842556e5de35f122fc0f0b721334ceb6e78c3719693364d4af8499"}, - {file = "aiohttp-3.9.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0d406b01a9f5a7e232d1b0d161b40c05275ffbcbd772dc18c1d5a570961a1ca4"}, - {file = "aiohttp-3.9.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8d8e4450e7fe24d86e86b23cc209e0023177b6d59502e33807b732d2deb6975f"}, - {file = "aiohttp-3.9.1-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3c0266cd6f005e99f3f51e583012de2778e65af6b73860038b968a0a8888487a"}, - {file = "aiohttp-3.9.1-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:ab221850108a4a063c5b8a70f00dd7a1975e5a1713f87f4ab26a46e5feac5a0e"}, - {file = "aiohttp-3.9.1-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:c88a15f272a0ad3d7773cf3a37cc7b7d077cbfc8e331675cf1346e849d97a4e5"}, - {file = "aiohttp-3.9.1-cp39-cp39-musllinux_1_1_ppc64le.whl", hash = "sha256:237533179d9747080bcaad4d02083ce295c0d2eab3e9e8ce103411a4312991a0"}, - {file = "aiohttp-3.9.1-cp39-cp39-musllinux_1_1_s390x.whl", hash = "sha256:02ab6006ec3c3463b528374c4cdce86434e7b89ad355e7bf29e2f16b46c7dd6f"}, - {file = "aiohttp-3.9.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:04fa38875e53eb7e354ece1607b1d2fdee2d175ea4e4d745f6ec9f751fe20c7c"}, - {file = "aiohttp-3.9.1-cp39-cp39-win32.whl", hash = "sha256:82eefaf1a996060602f3cc1112d93ba8b201dbf5d8fd9611227de2003dddb3b7"}, - {file = "aiohttp-3.9.1-cp39-cp39-win_amd64.whl", hash = "sha256:9b05d33ff8e6b269e30a7957bd3244ffbce2a7a35a81b81c382629b80af1a8bf"}, - {file = "aiohttp-3.9.1.tar.gz", hash = "sha256:8fc49a87ac269d4529da45871e2ffb6874e87779c3d0e2ccd813c0899221239d"}, + {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] @@ -112,19 +112,20 @@ frozenlist = ">=1.1.0" [[package]] name = "anyio" -version = "4.1.0" +version = "4.3.0" description = "High level compatibility layer for multiple asynchronous event loop implementations" optional = false python-versions = ">=3.8" files = [ - {file = "anyio-4.1.0-py3-none-any.whl", hash = "sha256:56a415fbc462291813a94528a779597226619c8e78af7de0507333f700011e5f"}, - {file = "anyio-4.1.0.tar.gz", hash = "sha256:5a0bec7085176715be77df87fc66d6c9d70626bd752fcc85f57cdbee5b3760da"}, + {file = "anyio-4.3.0-py3-none-any.whl", hash = "sha256:048e05d0f6caeed70d731f3db756d35dcc1f35747c8c403364a8332c630441b8"}, + {file = "anyio-4.3.0.tar.gz", hash = "sha256:f75253795a87df48568485fd18cdd2a3fa5c4f7c5be8e5e36637733fce06fed6"}, ] [package.dependencies] exceptiongroup = {version = ">=1.0.2", markers = "python_version < \"3.11\""} idna = ">=2.8" sniffio = ">=1.1" +typing-extensions = {version = ">=4.1", markers = "python_version < \"3.11\""} [package.extras] doc = ["Sphinx (>=7)", "packaging", "sphinx-autodoc-typehints (>=1.2.0)", "sphinx-rtd-theme"] @@ -144,65 +145,69 @@ files = [ [[package]] name = "attrs" -version = "23.1.0" +version = "23.2.0" description = "Classes Without Boilerplate" optional = false python-versions = ">=3.7" files = [ - {file = "attrs-23.1.0-py3-none-any.whl", hash = "sha256:1f28b4522cdc2fb4256ac1a020c78acf9cba2c6b461ccd2c126f3aa8e8335d04"}, - {file = "attrs-23.1.0.tar.gz", hash = "sha256:6279836d581513a26f1bf235f9acd333bc9115683f14f7e8fae46c98fc50e015"}, + {file = "attrs-23.2.0-py3-none-any.whl", hash = "sha256:99b87a485a5820b23b879f04c2305b44b951b502fd64be915879d77a7e8fc6f1"}, + {file = "attrs-23.2.0.tar.gz", hash = "sha256:935dc3b529c262f6cf76e50877d35a4bd3c1de194fd41f47a2b7ae8f19971f30"}, ] [package.extras] cov = ["attrs[tests]", "coverage[toml] (>=5.3)"] -dev = ["attrs[docs,tests]", "pre-commit"] +dev = ["attrs[tests]", "pre-commit"] docs = ["furo", "myst-parser", "sphinx", "sphinx-notfound-page", "sphinxcontrib-towncrier", "towncrier", "zope-interface"] tests = ["attrs[tests-no-zope]", "zope-interface"] -tests-no-zope = ["cloudpickle", "hypothesis", "mypy (>=1.1.1)", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins", "pytest-xdist[psutil]"] +tests-mypy = ["mypy (>=1.6)", "pytest-mypy-plugins"] +tests-no-zope = ["attrs[tests-mypy]", "cloudpickle", "hypothesis", "pympler", "pytest (>=4.3.0)", "pytest-xdist[psutil]"] [[package]] name = "babel" -version = "2.13.1" +version = "2.14.0" description = "Internationalization utilities" optional = false python-versions = ">=3.7" files = [ - {file = "Babel-2.13.1-py3-none-any.whl", hash = "sha256:7077a4984b02b6727ac10f1f7294484f737443d7e2e66c5e4380e41a3ae0b4ed"}, - {file = "Babel-2.13.1.tar.gz", hash = "sha256:33e0952d7dd6374af8dbf6768cc4ddf3ccfefc244f9986d4074704f2fbd18900"}, + {file = "Babel-2.14.0-py3-none-any.whl", hash = "sha256:efb1a25b7118e67ce3a259bed20545c29cb68be8ad2c784c83689981b7a57287"}, + {file = "Babel-2.14.0.tar.gz", hash = "sha256:6919867db036398ba21eb5c7a0f6b28ab8cbc3ae7a73a44ebe34ae74a4e7d363"}, ] [package.dependencies] pytz = {version = ">=2015.7", markers = "python_version < \"3.9\""} -setuptools = {version = "*", markers = "python_version >= \"3.12\""} [package.extras] dev = ["freezegun (>=1.0,<2.0)", "pytest (>=6.0)", "pytest-cov"] [[package]] name = "black" -version = "23.11.0" +version = "24.1.1" description = "The uncompromising code formatter." optional = false python-versions = ">=3.8" files = [ - {file = "black-23.11.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:dbea0bb8575c6b6303cc65017b46351dc5953eea5c0a59d7b7e3a2d2f433a911"}, - {file = "black-23.11.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:412f56bab20ac85927f3a959230331de5614aecda1ede14b373083f62ec24e6f"}, - {file = "black-23.11.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d136ef5b418c81660ad847efe0e55c58c8208b77a57a28a503a5f345ccf01394"}, - {file = "black-23.11.0-cp310-cp310-win_amd64.whl", hash = "sha256:6c1cac07e64433f646a9a838cdc00c9768b3c362805afc3fce341af0e6a9ae9f"}, - {file = "black-23.11.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:cf57719e581cfd48c4efe28543fea3d139c6b6f1238b3f0102a9c73992cbb479"}, - {file = "black-23.11.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:698c1e0d5c43354ec5d6f4d914d0d553a9ada56c85415700b81dc90125aac244"}, - {file = "black-23.11.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:760415ccc20f9e8747084169110ef75d545f3b0932ee21368f63ac0fee86b221"}, - {file = "black-23.11.0-cp311-cp311-win_amd64.whl", hash = "sha256:58e5f4d08a205b11800332920e285bd25e1a75c54953e05502052738fe16b3b5"}, - {file = "black-23.11.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:45aa1d4675964946e53ab81aeec7a37613c1cb71647b5394779e6efb79d6d187"}, - {file = "black-23.11.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:4c44b7211a3a0570cc097e81135faa5f261264f4dfaa22bd5ee2875a4e773bd6"}, - {file = "black-23.11.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2a9acad1451632021ee0d146c8765782a0c3846e0e0ea46659d7c4f89d9b212b"}, - {file = "black-23.11.0-cp38-cp38-win_amd64.whl", hash = "sha256:fc7f6a44d52747e65a02558e1d807c82df1d66ffa80a601862040a43ec2e3142"}, - {file = "black-23.11.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:7f622b6822f02bfaf2a5cd31fdb7cd86fcf33dab6ced5185c35f5db98260b055"}, - {file = "black-23.11.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:250d7e60f323fcfc8ea6c800d5eba12f7967400eb6c2d21ae85ad31c204fb1f4"}, - {file = "black-23.11.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5133f5507007ba08d8b7b263c7aa0f931af5ba88a29beacc4b2dc23fcefe9c06"}, - {file = "black-23.11.0-cp39-cp39-win_amd64.whl", hash = "sha256:421f3e44aa67138ab1b9bfbc22ee3780b22fa5b291e4db8ab7eee95200726b07"}, - {file = "black-23.11.0-py3-none-any.whl", hash = "sha256:54caaa703227c6e0c87b76326d0862184729a69b73d3b7305b6288e1d830067e"}, - {file = "black-23.11.0.tar.gz", hash = "sha256:4c68855825ff432d197229846f971bc4d6666ce90492e5b02013bcaca4d9ab05"}, + {file = "black-24.1.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:2588021038bd5ada078de606f2a804cadd0a3cc6a79cb3e9bb3a8bf581325a4c"}, + {file = "black-24.1.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:1a95915c98d6e32ca43809d46d932e2abc5f1f7d582ffbe65a5b4d1588af7445"}, + {file = "black-24.1.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2fa6a0e965779c8f2afb286f9ef798df770ba2b6cee063c650b96adec22c056a"}, + {file = "black-24.1.1-cp310-cp310-win_amd64.whl", hash = "sha256:5242ecd9e990aeb995b6d03dc3b2d112d4a78f2083e5a8e86d566340ae80fec4"}, + {file = "black-24.1.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:fc1ec9aa6f4d98d022101e015261c056ddebe3da6a8ccfc2c792cbe0349d48b7"}, + {file = "black-24.1.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:0269dfdea12442022e88043d2910429bed717b2d04523867a85dacce535916b8"}, + {file = "black-24.1.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b3d64db762eae4a5ce04b6e3dd745dcca0fb9560eb931a5be97472e38652a161"}, + {file = "black-24.1.1-cp311-cp311-win_amd64.whl", hash = "sha256:5d7b06ea8816cbd4becfe5f70accae953c53c0e53aa98730ceccb0395520ee5d"}, + {file = "black-24.1.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:e2c8dfa14677f90d976f68e0c923947ae68fa3961d61ee30976c388adc0b02c8"}, + {file = "black-24.1.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:a21725862d0e855ae05da1dd25e3825ed712eaaccef6b03017fe0853a01aa45e"}, + {file = "black-24.1.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:07204d078e25327aad9ed2c64790d681238686bce254c910de640c7cc4fc3aa6"}, + {file = "black-24.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:a83fe522d9698d8f9a101b860b1ee154c1d25f8a82ceb807d319f085b2627c5b"}, + {file = "black-24.1.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:08b34e85170d368c37ca7bf81cf67ac863c9d1963b2c1780c39102187ec8dd62"}, + {file = "black-24.1.1-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:7258c27115c1e3b5de9ac6c4f9957e3ee2c02c0b39222a24dc7aa03ba0e986f5"}, + {file = "black-24.1.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:40657e1b78212d582a0edecafef133cf1dd02e6677f539b669db4746150d38f6"}, + {file = "black-24.1.1-cp38-cp38-win_amd64.whl", hash = "sha256:e298d588744efda02379521a19639ebcd314fba7a49be22136204d7ed1782717"}, + {file = "black-24.1.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:34afe9da5056aa123b8bfda1664bfe6fb4e9c6f311d8e4a6eb089da9a9173bf9"}, + {file = "black-24.1.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:854c06fb86fd854140f37fb24dbf10621f5dab9e3b0c29a690ba595e3d543024"}, + {file = "black-24.1.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3897ae5a21ca132efa219c029cce5e6bfc9c3d34ed7e892113d199c0b1b444a2"}, + {file = "black-24.1.1-cp39-cp39-win_amd64.whl", hash = "sha256:ecba2a15dfb2d97105be74bbfe5128bc5e9fa8477d8c46766505c1dda5883aac"}, + {file = "black-24.1.1-py3-none-any.whl", hash = "sha256:5cdc2e2195212208fbcae579b931407c1fa9997584f0a415421748aeafff1168"}, + {file = "black-24.1.1.tar.gz", hash = "sha256:48b5760dcbfe5cf97fd4fba23946681f3a81514c6ab8a45b50da67ac8fbc6c7b"}, ] [package.dependencies] @@ -216,19 +221,19 @@ typing-extensions = {version = ">=4.0.1", markers = "python_version < \"3.11\""} [package.extras] colorama = ["colorama (>=0.4.3)"] -d = ["aiohttp (>=3.7.4)"] +d = ["aiohttp (>=3.7.4)", "aiohttp (>=3.7.4,!=3.9.0)"] jupyter = ["ipython (>=7.8.0)", "tokenize-rt (>=3.2.0)"] 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]] @@ -378,63 +383,63 @@ files = [ [[package]] name = "coverage" -version = "7.3.2" +version = "7.4.3" description = "Code coverage measurement for Python" optional = false python-versions = ">=3.8" files = [ - {file = "coverage-7.3.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:d872145f3a3231a5f20fd48500274d7df222e291d90baa2026cc5152b7ce86bf"}, - {file = "coverage-7.3.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:310b3bb9c91ea66d59c53fa4989f57d2436e08f18fb2f421a1b0b6b8cc7fffda"}, - {file = "coverage-7.3.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f47d39359e2c3779c5331fc740cf4bce6d9d680a7b4b4ead97056a0ae07cb49a"}, - {file = "coverage-7.3.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:aa72dbaf2c2068404b9870d93436e6d23addd8bbe9295f49cbca83f6e278179c"}, - {file = "coverage-7.3.2-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:beaa5c1b4777f03fc63dfd2a6bd820f73f036bfb10e925fce067b00a340d0f3f"}, - {file = "coverage-7.3.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:dbc1b46b92186cc8074fee9d9fbb97a9dd06c6cbbef391c2f59d80eabdf0faa6"}, - {file = "coverage-7.3.2-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:315a989e861031334d7bee1f9113c8770472db2ac484e5b8c3173428360a9148"}, - {file = "coverage-7.3.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:d1bc430677773397f64a5c88cb522ea43175ff16f8bfcc89d467d974cb2274f9"}, - {file = "coverage-7.3.2-cp310-cp310-win32.whl", hash = "sha256:a889ae02f43aa45032afe364c8ae84ad3c54828c2faa44f3bfcafecb5c96b02f"}, - {file = "coverage-7.3.2-cp310-cp310-win_amd64.whl", hash = "sha256:c0ba320de3fb8c6ec16e0be17ee1d3d69adcda99406c43c0409cb5c41788a611"}, - {file = "coverage-7.3.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:ac8c802fa29843a72d32ec56d0ca792ad15a302b28ca6203389afe21f8fa062c"}, - {file = "coverage-7.3.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:89a937174104339e3a3ffcf9f446c00e3a806c28b1841c63edb2b369310fd074"}, - {file = "coverage-7.3.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e267e9e2b574a176ddb983399dec325a80dbe161f1a32715c780b5d14b5f583a"}, - {file = "coverage-7.3.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2443cbda35df0d35dcfb9bf8f3c02c57c1d6111169e3c85fc1fcc05e0c9f39a3"}, - {file = "coverage-7.3.2-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4175e10cc8dda0265653e8714b3174430b07c1dca8957f4966cbd6c2b1b8065a"}, - {file = "coverage-7.3.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:0cbf38419fb1a347aaf63481c00f0bdc86889d9fbf3f25109cf96c26b403fda1"}, - {file = "coverage-7.3.2-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:5c913b556a116b8d5f6ef834038ba983834d887d82187c8f73dec21049abd65c"}, - {file = "coverage-7.3.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:1981f785239e4e39e6444c63a98da3a1db8e971cb9ceb50a945ba6296b43f312"}, - {file = "coverage-7.3.2-cp311-cp311-win32.whl", hash = "sha256:43668cabd5ca8258f5954f27a3aaf78757e6acf13c17604d89648ecc0cc66640"}, - {file = "coverage-7.3.2-cp311-cp311-win_amd64.whl", hash = "sha256:e10c39c0452bf6e694511c901426d6b5ac005acc0f78ff265dbe36bf81f808a2"}, - {file = "coverage-7.3.2-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:4cbae1051ab791debecc4a5dcc4a1ff45fc27b91b9aee165c8a27514dd160836"}, - {file = "coverage-7.3.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:12d15ab5833a997716d76f2ac1e4b4d536814fc213c85ca72756c19e5a6b3d63"}, - {file = "coverage-7.3.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3c7bba973ebee5e56fe9251300c00f1579652587a9f4a5ed8404b15a0471f216"}, - {file = "coverage-7.3.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:fe494faa90ce6381770746077243231e0b83ff3f17069d748f645617cefe19d4"}, - {file = "coverage-7.3.2-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f6e9589bd04d0461a417562649522575d8752904d35c12907d8c9dfeba588faf"}, - {file = "coverage-7.3.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:d51ac2a26f71da1b57f2dc81d0e108b6ab177e7d30e774db90675467c847bbdf"}, - {file = "coverage-7.3.2-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:99b89d9f76070237975b315b3d5f4d6956ae354a4c92ac2388a5695516e47c84"}, - {file = "coverage-7.3.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:fa28e909776dc69efb6ed975a63691bc8172b64ff357e663a1bb06ff3c9b589a"}, - {file = "coverage-7.3.2-cp312-cp312-win32.whl", hash = "sha256:289fe43bf45a575e3ab10b26d7b6f2ddb9ee2dba447499f5401cfb5ecb8196bb"}, - {file = "coverage-7.3.2-cp312-cp312-win_amd64.whl", hash = "sha256:7dbc3ed60e8659bc59b6b304b43ff9c3ed858da2839c78b804973f613d3e92ed"}, - {file = "coverage-7.3.2-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:f94b734214ea6a36fe16e96a70d941af80ff3bfd716c141300d95ebc85339738"}, - {file = "coverage-7.3.2-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:af3d828d2c1cbae52d34bdbb22fcd94d1ce715d95f1a012354a75e5913f1bda2"}, - {file = "coverage-7.3.2-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:630b13e3036e13c7adc480ca42fa7afc2a5d938081d28e20903cf7fd687872e2"}, - {file = "coverage-7.3.2-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c9eacf273e885b02a0273bb3a2170f30e2d53a6d53b72dbe02d6701b5296101c"}, - {file = "coverage-7.3.2-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d8f17966e861ff97305e0801134e69db33b143bbfb36436efb9cfff6ec7b2fd9"}, - {file = "coverage-7.3.2-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:b4275802d16882cf9c8b3d057a0839acb07ee9379fa2749eca54efbce1535b82"}, - {file = "coverage-7.3.2-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:72c0cfa5250f483181e677ebc97133ea1ab3eb68645e494775deb6a7f6f83901"}, - {file = "coverage-7.3.2-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:cb536f0dcd14149425996821a168f6e269d7dcd2c273a8bff8201e79f5104e76"}, - {file = "coverage-7.3.2-cp38-cp38-win32.whl", hash = "sha256:307adb8bd3abe389a471e649038a71b4eb13bfd6b7dd9a129fa856f5c695cf92"}, - {file = "coverage-7.3.2-cp38-cp38-win_amd64.whl", hash = "sha256:88ed2c30a49ea81ea3b7f172e0269c182a44c236eb394718f976239892c0a27a"}, - {file = "coverage-7.3.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:b631c92dfe601adf8f5ebc7fc13ced6bb6e9609b19d9a8cd59fa47c4186ad1ce"}, - {file = "coverage-7.3.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:d3d9df4051c4a7d13036524b66ecf7a7537d14c18a384043f30a303b146164e9"}, - {file = "coverage-7.3.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5f7363d3b6a1119ef05015959ca24a9afc0ea8a02c687fe7e2d557705375c01f"}, - {file = "coverage-7.3.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2f11cc3c967a09d3695d2a6f03fb3e6236622b93be7a4b5dc09166a861be6d25"}, - {file = "coverage-7.3.2-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:149de1d2401ae4655c436a3dced6dd153f4c3309f599c3d4bd97ab172eaf02d9"}, - {file = "coverage-7.3.2-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:3a4006916aa6fee7cd38db3bfc95aa9c54ebb4ffbfc47c677c8bba949ceba0a6"}, - {file = "coverage-7.3.2-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:9028a3871280110d6e1aa2df1afd5ef003bab5fb1ef421d6dc748ae1c8ef2ebc"}, - {file = "coverage-7.3.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:9f805d62aec8eb92bab5b61c0f07329275b6f41c97d80e847b03eb894f38d083"}, - {file = "coverage-7.3.2-cp39-cp39-win32.whl", hash = "sha256:d1c88ec1a7ff4ebca0219f5b1ef863451d828cccf889c173e1253aa84b1e07ce"}, - {file = "coverage-7.3.2-cp39-cp39-win_amd64.whl", hash = "sha256:b4767da59464bb593c07afceaddea61b154136300881844768037fd5e859353f"}, - {file = "coverage-7.3.2-pp38.pp39.pp310-none-any.whl", hash = "sha256:ae97af89f0fbf373400970c0a21eef5aa941ffeed90aee43650b81f7d7f47637"}, - {file = "coverage-7.3.2.tar.gz", hash = "sha256:be32ad29341b0170e795ca590e1c07e81fc061cb5b10c74ce7203491484404ef"}, + {file = "coverage-7.4.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:8580b827d4746d47294c0e0b92854c85a92c2227927433998f0d3320ae8a71b6"}, + {file = "coverage-7.4.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:718187eeb9849fc6cc23e0d9b092bc2348821c5e1a901c9f8975df0bc785bfd4"}, + {file = "coverage-7.4.3-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:767b35c3a246bcb55b8044fd3a43b8cd553dd1f9f2c1eeb87a302b1f8daa0524"}, + {file = "coverage-7.4.3-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ae7f19afe0cce50039e2c782bff379c7e347cba335429678450b8fe81c4ef96d"}, + {file = "coverage-7.4.3-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ba3a8aaed13770e970b3df46980cb068d1c24af1a1968b7818b69af8c4347efb"}, + {file = "coverage-7.4.3-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:ee866acc0861caebb4f2ab79f0b94dbfbdbfadc19f82e6e9c93930f74e11d7a0"}, + {file = "coverage-7.4.3-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:506edb1dd49e13a2d4cac6a5173317b82a23c9d6e8df63efb4f0380de0fbccbc"}, + {file = "coverage-7.4.3-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:fd6545d97c98a192c5ac995d21c894b581f1fd14cf389be90724d21808b657e2"}, + {file = "coverage-7.4.3-cp310-cp310-win32.whl", hash = "sha256:f6a09b360d67e589236a44f0c39218a8efba2593b6abdccc300a8862cffc2f94"}, + {file = "coverage-7.4.3-cp310-cp310-win_amd64.whl", hash = "sha256:18d90523ce7553dd0b7e23cbb28865db23cddfd683a38fb224115f7826de78d0"}, + {file = "coverage-7.4.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:cbbe5e739d45a52f3200a771c6d2c7acf89eb2524890a4a3aa1a7fa0695d2a47"}, + {file = "coverage-7.4.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:489763b2d037b164846ebac0cbd368b8a4ca56385c4090807ff9fad817de4113"}, + {file = "coverage-7.4.3-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:451f433ad901b3bb00184d83fd83d135fb682d780b38af7944c9faeecb1e0bfe"}, + {file = "coverage-7.4.3-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:fcc66e222cf4c719fe7722a403888b1f5e1682d1679bd780e2b26c18bb648cdc"}, + {file = "coverage-7.4.3-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b3ec74cfef2d985e145baae90d9b1b32f85e1741b04cd967aaf9cfa84c1334f3"}, + {file = "coverage-7.4.3-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:abbbd8093c5229c72d4c2926afaee0e6e3140de69d5dcd918b2921f2f0c8baba"}, + {file = "coverage-7.4.3-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:35eb581efdacf7b7422af677b92170da4ef34500467381e805944a3201df2079"}, + {file = "coverage-7.4.3-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:8249b1c7334be8f8c3abcaaa996e1e4927b0e5a23b65f5bf6cfe3180d8ca7840"}, + {file = "coverage-7.4.3-cp311-cp311-win32.whl", hash = "sha256:cf30900aa1ba595312ae41978b95e256e419d8a823af79ce670835409fc02ad3"}, + {file = "coverage-7.4.3-cp311-cp311-win_amd64.whl", hash = "sha256:18c7320695c949de11a351742ee001849912fd57e62a706d83dfc1581897fa2e"}, + {file = "coverage-7.4.3-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:b51bfc348925e92a9bd9b2e48dad13431b57011fd1038f08316e6bf1df107d10"}, + {file = "coverage-7.4.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:d6cdecaedea1ea9e033d8adf6a0ab11107b49571bbb9737175444cea6eb72328"}, + {file = "coverage-7.4.3-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3b2eccb883368f9e972e216c7b4c7c06cabda925b5f06dde0650281cb7666a30"}, + {file = "coverage-7.4.3-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6c00cdc8fa4e50e1cc1f941a7f2e3e0f26cb2a1233c9696f26963ff58445bac7"}, + {file = "coverage-7.4.3-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b9a4a8dd3dcf4cbd3165737358e4d7dfbd9d59902ad11e3b15eebb6393b0446e"}, + {file = "coverage-7.4.3-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:062b0a75d9261e2f9c6d071753f7eef0fc9caf3a2c82d36d76667ba7b6470003"}, + {file = "coverage-7.4.3-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:ebe7c9e67a2d15fa97b77ea6571ce5e1e1f6b0db71d1d5e96f8d2bf134303c1d"}, + {file = "coverage-7.4.3-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:c0a120238dd71c68484f02562f6d446d736adcc6ca0993712289b102705a9a3a"}, + {file = "coverage-7.4.3-cp312-cp312-win32.whl", hash = "sha256:37389611ba54fd6d278fde86eb2c013c8e50232e38f5c68235d09d0a3f8aa352"}, + {file = "coverage-7.4.3-cp312-cp312-win_amd64.whl", hash = "sha256:d25b937a5d9ffa857d41be042b4238dd61db888533b53bc76dc082cb5a15e914"}, + {file = "coverage-7.4.3-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:28ca2098939eabab044ad68850aac8f8db6bf0b29bc7f2887d05889b17346454"}, + {file = "coverage-7.4.3-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:280459f0a03cecbe8800786cdc23067a8fc64c0bd51dc614008d9c36e1659d7e"}, + {file = "coverage-7.4.3-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6c0cdedd3500e0511eac1517bf560149764b7d8e65cb800d8bf1c63ebf39edd2"}, + {file = "coverage-7.4.3-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9a9babb9466fe1da12417a4aed923e90124a534736de6201794a3aea9d98484e"}, + {file = "coverage-7.4.3-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dec9de46a33cf2dd87a5254af095a409ea3bf952d85ad339751e7de6d962cde6"}, + {file = "coverage-7.4.3-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:16bae383a9cc5abab9bb05c10a3e5a52e0a788325dc9ba8499e821885928968c"}, + {file = "coverage-7.4.3-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:2c854ce44e1ee31bda4e318af1dbcfc929026d12c5ed030095ad98197eeeaed0"}, + {file = "coverage-7.4.3-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:ce8c50520f57ec57aa21a63ea4f325c7b657386b3f02ccaedeccf9ebe27686e1"}, + {file = "coverage-7.4.3-cp38-cp38-win32.whl", hash = "sha256:708a3369dcf055c00ddeeaa2b20f0dd1ce664eeabde6623e516c5228b753654f"}, + {file = "coverage-7.4.3-cp38-cp38-win_amd64.whl", hash = "sha256:1bf25fbca0c8d121a3e92a2a0555c7e5bc981aee5c3fdaf4bb7809f410f696b9"}, + {file = "coverage-7.4.3-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:3b253094dbe1b431d3a4ac2f053b6d7ede2664ac559705a704f621742e034f1f"}, + {file = "coverage-7.4.3-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:77fbfc5720cceac9c200054b9fab50cb2a7d79660609200ab83f5db96162d20c"}, + {file = "coverage-7.4.3-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6679060424faa9c11808598504c3ab472de4531c571ab2befa32f4971835788e"}, + {file = "coverage-7.4.3-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4af154d617c875b52651dd8dd17a31270c495082f3d55f6128e7629658d63765"}, + {file = "coverage-7.4.3-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8640f1fde5e1b8e3439fe482cdc2b0bb6c329f4bb161927c28d2e8879c6029ee"}, + {file = "coverage-7.4.3-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:69b9f6f66c0af29642e73a520b6fed25ff9fd69a25975ebe6acb297234eda501"}, + {file = "coverage-7.4.3-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:0842571634f39016a6c03e9d4aba502be652a6e4455fadb73cd3a3a49173e38f"}, + {file = "coverage-7.4.3-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:a78ed23b08e8ab524551f52953a8a05d61c3a760781762aac49f8de6eede8c45"}, + {file = "coverage-7.4.3-cp39-cp39-win32.whl", hash = "sha256:c0524de3ff096e15fcbfe8f056fdb4ea0bf497d584454f344d59fce069d3e6e9"}, + {file = "coverage-7.4.3-cp39-cp39-win_amd64.whl", hash = "sha256:0209a6369ccce576b43bb227dc8322d8ef9e323d089c6f3f26a597b09cb4d2aa"}, + {file = "coverage-7.4.3-pp38.pp39.pp310-none-any.whl", hash = "sha256:7cbde573904625509a3f37b6fecea974e363460b556a627c60dc2f47e2fffa51"}, + {file = "coverage-7.4.3.tar.gz", hash = "sha256:276f6077a5c61447a48d133ed13e759c09e62aff0dc84274a68dc18660104d52"}, ] [package.extras] @@ -442,13 +447,13 @@ toml = ["tomli"] [[package]] name = "distlib" -version = "0.3.7" +version = "0.3.8" description = "Distribution utilities" optional = false python-versions = "*" files = [ - {file = "distlib-0.3.7-py2.py3-none-any.whl", hash = "sha256:2e24928bc811348f0feb63014e97aaae3037f2cf48712d51ae61df7fd6075057"}, - {file = "distlib-0.3.7.tar.gz", hash = "sha256:9dafe54b34a028eafd95039d5e5d4851a13734540f1331060d31c9916e7147a8"}, + {file = "distlib-0.3.8-py2.py3-none-any.whl", hash = "sha256:034db59a0b96f8ca18035f36290806a9a6e6bd9d1ff91e45a7f172eb17e51784"}, + {file = "distlib-0.3.8.tar.gz", hash = "sha256:1530ea13e350031b6312d8580ddb6b27a104275a31106523b8f123787f494f64"}, ] [[package]] @@ -483,72 +488,88 @@ typing = ["typing-extensions (>=4.8)"] [[package]] name = "frozenlist" -version = "1.4.0" +version = "1.4.1" description = "A list-like structure which implements collections.abc.MutableSequence" optional = false python-versions = ">=3.8" files = [ - {file = "frozenlist-1.4.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:764226ceef3125e53ea2cb275000e309c0aa5464d43bd72abd661e27fffc26ab"}, - {file = "frozenlist-1.4.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:d6484756b12f40003c6128bfcc3fa9f0d49a687e171186c2d85ec82e3758c559"}, - {file = "frozenlist-1.4.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:9ac08e601308e41eb533f232dbf6b7e4cea762f9f84f6357136eed926c15d12c"}, - {file = "frozenlist-1.4.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d081f13b095d74b67d550de04df1c756831f3b83dc9881c38985834387487f1b"}, - {file = "frozenlist-1.4.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:71932b597f9895f011f47f17d6428252fc728ba2ae6024e13c3398a087c2cdea"}, - {file = "frozenlist-1.4.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:981b9ab5a0a3178ff413bca62526bb784249421c24ad7381e39d67981be2c326"}, - {file = "frozenlist-1.4.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e41f3de4df3e80de75845d3e743b3f1c4c8613c3997a912dbf0229fc61a8b963"}, - {file = "frozenlist-1.4.0-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6918d49b1f90821e93069682c06ffde41829c346c66b721e65a5c62b4bab0300"}, - {file = "frozenlist-1.4.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:0e5c8764c7829343d919cc2dfc587a8db01c4f70a4ebbc49abde5d4b158b007b"}, - {file = "frozenlist-1.4.0-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:8d0edd6b1c7fb94922bf569c9b092ee187a83f03fb1a63076e7774b60f9481a8"}, - {file = "frozenlist-1.4.0-cp310-cp310-musllinux_1_1_ppc64le.whl", hash = "sha256:e29cda763f752553fa14c68fb2195150bfab22b352572cb36c43c47bedba70eb"}, - {file = "frozenlist-1.4.0-cp310-cp310-musllinux_1_1_s390x.whl", hash = "sha256:0c7c1b47859ee2cac3846fde1c1dc0f15da6cec5a0e5c72d101e0f83dcb67ff9"}, - {file = "frozenlist-1.4.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:901289d524fdd571be1c7be054f48b1f88ce8dddcbdf1ec698b27d4b8b9e5d62"}, - {file = "frozenlist-1.4.0-cp310-cp310-win32.whl", hash = "sha256:1a0848b52815006ea6596c395f87449f693dc419061cc21e970f139d466dc0a0"}, - {file = "frozenlist-1.4.0-cp310-cp310-win_amd64.whl", hash = "sha256:b206646d176a007466358aa21d85cd8600a415c67c9bd15403336c331a10d956"}, - {file = "frozenlist-1.4.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:de343e75f40e972bae1ef6090267f8260c1446a1695e77096db6cfa25e759a95"}, - {file = "frozenlist-1.4.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:ad2a9eb6d9839ae241701d0918f54c51365a51407fd80f6b8289e2dfca977cc3"}, - {file = "frozenlist-1.4.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:bd7bd3b3830247580de99c99ea2a01416dfc3c34471ca1298bccabf86d0ff4dc"}, - {file = "frozenlist-1.4.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bdf1847068c362f16b353163391210269e4f0569a3c166bc6a9f74ccbfc7e839"}, - {file = "frozenlist-1.4.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:38461d02d66de17455072c9ba981d35f1d2a73024bee7790ac2f9e361ef1cd0c"}, - {file = "frozenlist-1.4.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d5a32087d720c608f42caed0ef36d2b3ea61a9d09ee59a5142d6070da9041b8f"}, - {file = "frozenlist-1.4.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:dd65632acaf0d47608190a71bfe46b209719bf2beb59507db08ccdbe712f969b"}, - {file = "frozenlist-1.4.0-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:261b9f5d17cac914531331ff1b1d452125bf5daa05faf73b71d935485b0c510b"}, - {file = "frozenlist-1.4.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:b89ac9768b82205936771f8d2eb3ce88503b1556324c9f903e7156669f521472"}, - {file = "frozenlist-1.4.0-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:008eb8b31b3ea6896da16c38c1b136cb9fec9e249e77f6211d479db79a4eaf01"}, - {file = "frozenlist-1.4.0-cp311-cp311-musllinux_1_1_ppc64le.whl", hash = "sha256:e74b0506fa5aa5598ac6a975a12aa8928cbb58e1f5ac8360792ef15de1aa848f"}, - {file = "frozenlist-1.4.0-cp311-cp311-musllinux_1_1_s390x.whl", hash = "sha256:490132667476f6781b4c9458298b0c1cddf237488abd228b0b3650e5ecba7467"}, - {file = "frozenlist-1.4.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:76d4711f6f6d08551a7e9ef28c722f4a50dd0fc204c56b4bcd95c6cc05ce6fbb"}, - {file = "frozenlist-1.4.0-cp311-cp311-win32.whl", hash = "sha256:a02eb8ab2b8f200179b5f62b59757685ae9987996ae549ccf30f983f40602431"}, - {file = "frozenlist-1.4.0-cp311-cp311-win_amd64.whl", hash = "sha256:515e1abc578dd3b275d6a5114030b1330ba044ffba03f94091842852f806f1c1"}, - {file = "frozenlist-1.4.0-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:f0ed05f5079c708fe74bf9027e95125334b6978bf07fd5ab923e9e55e5fbb9d3"}, - {file = "frozenlist-1.4.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:ca265542ca427bf97aed183c1676e2a9c66942e822b14dc6e5f42e038f92a503"}, - {file = "frozenlist-1.4.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:491e014f5c43656da08958808588cc6c016847b4360e327a62cb308c791bd2d9"}, - {file = "frozenlist-1.4.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:17ae5cd0f333f94f2e03aaf140bb762c64783935cc764ff9c82dff626089bebf"}, - {file = "frozenlist-1.4.0-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1e78fb68cf9c1a6aa4a9a12e960a5c9dfbdb89b3695197aa7064705662515de2"}, - {file = "frozenlist-1.4.0-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d5655a942f5f5d2c9ed93d72148226d75369b4f6952680211972a33e59b1dfdc"}, - {file = "frozenlist-1.4.0-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c11b0746f5d946fecf750428a95f3e9ebe792c1ee3b1e96eeba145dc631a9672"}, - {file = "frozenlist-1.4.0-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e66d2a64d44d50d2543405fb183a21f76b3b5fd16f130f5c99187c3fb4e64919"}, - {file = "frozenlist-1.4.0-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:88f7bc0fcca81f985f78dd0fa68d2c75abf8272b1f5c323ea4a01a4d7a614efc"}, - {file = "frozenlist-1.4.0-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:5833593c25ac59ede40ed4de6d67eb42928cca97f26feea219f21d0ed0959b79"}, - {file = "frozenlist-1.4.0-cp38-cp38-musllinux_1_1_ppc64le.whl", hash = "sha256:fec520865f42e5c7f050c2a79038897b1c7d1595e907a9e08e3353293ffc948e"}, - {file = "frozenlist-1.4.0-cp38-cp38-musllinux_1_1_s390x.whl", hash = "sha256:b826d97e4276750beca7c8f0f1a4938892697a6bcd8ec8217b3312dad6982781"}, - {file = "frozenlist-1.4.0-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:ceb6ec0a10c65540421e20ebd29083c50e6d1143278746a4ef6bcf6153171eb8"}, - {file = "frozenlist-1.4.0-cp38-cp38-win32.whl", hash = "sha256:2b8bcf994563466db019fab287ff390fffbfdb4f905fc77bc1c1d604b1c689cc"}, - {file = "frozenlist-1.4.0-cp38-cp38-win_amd64.whl", hash = "sha256:a6c8097e01886188e5be3e6b14e94ab365f384736aa1fca6a0b9e35bd4a30bc7"}, - {file = "frozenlist-1.4.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:6c38721585f285203e4b4132a352eb3daa19121a035f3182e08e437cface44bf"}, - {file = "frozenlist-1.4.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:a0c6da9aee33ff0b1a451e867da0c1f47408112b3391dd43133838339e410963"}, - {file = "frozenlist-1.4.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:93ea75c050c5bb3d98016b4ba2497851eadf0ac154d88a67d7a6816206f6fa7f"}, - {file = "frozenlist-1.4.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f61e2dc5ad442c52b4887f1fdc112f97caeff4d9e6ebe78879364ac59f1663e1"}, - {file = "frozenlist-1.4.0-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:aa384489fefeb62321b238e64c07ef48398fe80f9e1e6afeff22e140e0850eef"}, - {file = "frozenlist-1.4.0-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:10ff5faaa22786315ef57097a279b833ecab1a0bfb07d604c9cbb1c4cdc2ed87"}, - {file = "frozenlist-1.4.0-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:007df07a6e3eb3e33e9a1fe6a9db7af152bbd8a185f9aaa6ece10a3529e3e1c6"}, - {file = "frozenlist-1.4.0-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7f4f399d28478d1f604c2ff9119907af9726aed73680e5ed1ca634d377abb087"}, - {file = "frozenlist-1.4.0-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:c5374b80521d3d3f2ec5572e05adc94601985cc526fb276d0c8574a6d749f1b3"}, - {file = "frozenlist-1.4.0-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:ce31ae3e19f3c902de379cf1323d90c649425b86de7bbdf82871b8a2a0615f3d"}, - {file = "frozenlist-1.4.0-cp39-cp39-musllinux_1_1_ppc64le.whl", hash = "sha256:7211ef110a9194b6042449431e08c4d80c0481e5891e58d429df5899690511c2"}, - {file = "frozenlist-1.4.0-cp39-cp39-musllinux_1_1_s390x.whl", hash = "sha256:556de4430ce324c836789fa4560ca62d1591d2538b8ceb0b4f68fb7b2384a27a"}, - {file = "frozenlist-1.4.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:7645a8e814a3ee34a89c4a372011dcd817964ce8cb273c8ed6119d706e9613e3"}, - {file = "frozenlist-1.4.0-cp39-cp39-win32.whl", hash = "sha256:19488c57c12d4e8095a922f328df3f179c820c212940a498623ed39160bc3c2f"}, - {file = "frozenlist-1.4.0-cp39-cp39-win_amd64.whl", hash = "sha256:6221d84d463fb110bdd7619b69cb43878a11d51cbb9394ae3105d082d5199167"}, - {file = "frozenlist-1.4.0.tar.gz", hash = "sha256:09163bdf0b2907454042edb19f887c6d33806adc71fbd54afc14908bfdc22251"}, + {file = "frozenlist-1.4.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:f9aa1878d1083b276b0196f2dfbe00c9b7e752475ed3b682025ff20c1c1f51ac"}, + {file = "frozenlist-1.4.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:29acab3f66f0f24674b7dc4736477bcd4bc3ad4b896f5f45379a67bce8b96868"}, + {file = "frozenlist-1.4.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:74fb4bee6880b529a0c6560885fce4dc95936920f9f20f53d99a213f7bf66776"}, + {file = "frozenlist-1.4.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:590344787a90ae57d62511dd7c736ed56b428f04cd8c161fcc5e7232c130c69a"}, + {file = "frozenlist-1.4.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:068b63f23b17df8569b7fdca5517edef76171cf3897eb68beb01341131fbd2ad"}, + {file = "frozenlist-1.4.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5c849d495bf5154cd8da18a9eb15db127d4dba2968d88831aff6f0331ea9bd4c"}, + {file = "frozenlist-1.4.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9750cc7fe1ae3b1611bb8cfc3f9ec11d532244235d75901fb6b8e42ce9229dfe"}, + {file = "frozenlist-1.4.1-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a9b2de4cf0cdd5bd2dee4c4f63a653c61d2408055ab77b151c1957f221cabf2a"}, + {file = "frozenlist-1.4.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:0633c8d5337cb5c77acbccc6357ac49a1770b8c487e5b3505c57b949b4b82e98"}, + {file = "frozenlist-1.4.1-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:27657df69e8801be6c3638054e202a135c7f299267f1a55ed3a598934f6c0d75"}, + {file = "frozenlist-1.4.1-cp310-cp310-musllinux_1_1_ppc64le.whl", hash = "sha256:f9a3ea26252bd92f570600098783d1371354d89d5f6b7dfd87359d669f2109b5"}, + {file = "frozenlist-1.4.1-cp310-cp310-musllinux_1_1_s390x.whl", hash = "sha256:4f57dab5fe3407b6c0c1cc907ac98e8a189f9e418f3b6e54d65a718aaafe3950"}, + {file = "frozenlist-1.4.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:e02a0e11cf6597299b9f3bbd3f93d79217cb90cfd1411aec33848b13f5c656cc"}, + {file = "frozenlist-1.4.1-cp310-cp310-win32.whl", hash = "sha256:a828c57f00f729620a442881cc60e57cfcec6842ba38e1b19fd3e47ac0ff8dc1"}, + {file = "frozenlist-1.4.1-cp310-cp310-win_amd64.whl", hash = "sha256:f56e2333dda1fe0f909e7cc59f021eba0d2307bc6f012a1ccf2beca6ba362439"}, + {file = "frozenlist-1.4.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:a0cb6f11204443f27a1628b0e460f37fb30f624be6051d490fa7d7e26d4af3d0"}, + {file = "frozenlist-1.4.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:b46c8ae3a8f1f41a0d2ef350c0b6e65822d80772fe46b653ab6b6274f61d4a49"}, + {file = "frozenlist-1.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:fde5bd59ab5357e3853313127f4d3565fc7dad314a74d7b5d43c22c6a5ed2ced"}, + {file = "frozenlist-1.4.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:722e1124aec435320ae01ee3ac7bec11a5d47f25d0ed6328f2273d287bc3abb0"}, + {file = "frozenlist-1.4.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2471c201b70d58a0f0c1f91261542a03d9a5e088ed3dc6c160d614c01649c106"}, + {file = "frozenlist-1.4.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c757a9dd70d72b076d6f68efdbb9bc943665ae954dad2801b874c8c69e185068"}, + {file = "frozenlist-1.4.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f146e0911cb2f1da549fc58fc7bcd2b836a44b79ef871980d605ec392ff6b0d2"}, + {file = "frozenlist-1.4.1-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4f9c515e7914626b2a2e1e311794b4c35720a0be87af52b79ff8e1429fc25f19"}, + {file = "frozenlist-1.4.1-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:c302220494f5c1ebeb0912ea782bcd5e2f8308037b3c7553fad0e48ebad6ad82"}, + {file = "frozenlist-1.4.1-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:442acde1e068288a4ba7acfe05f5f343e19fac87bfc96d89eb886b0363e977ec"}, + {file = "frozenlist-1.4.1-cp311-cp311-musllinux_1_1_ppc64le.whl", hash = "sha256:1b280e6507ea8a4fa0c0a7150b4e526a8d113989e28eaaef946cc77ffd7efc0a"}, + {file = "frozenlist-1.4.1-cp311-cp311-musllinux_1_1_s390x.whl", hash = "sha256:fe1a06da377e3a1062ae5fe0926e12b84eceb8a50b350ddca72dc85015873f74"}, + {file = "frozenlist-1.4.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:db9e724bebd621d9beca794f2a4ff1d26eed5965b004a97f1f1685a173b869c2"}, + {file = "frozenlist-1.4.1-cp311-cp311-win32.whl", hash = "sha256:e774d53b1a477a67838a904131c4b0eef6b3d8a651f8b138b04f748fccfefe17"}, + {file = "frozenlist-1.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:fb3c2db03683b5767dedb5769b8a40ebb47d6f7f45b1b3e3b4b51ec8ad9d9825"}, + {file = "frozenlist-1.4.1-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:1979bc0aeb89b33b588c51c54ab0161791149f2461ea7c7c946d95d5f93b56ae"}, + {file = "frozenlist-1.4.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:cc7b01b3754ea68a62bd77ce6020afaffb44a590c2289089289363472d13aedb"}, + {file = "frozenlist-1.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:c9c92be9fd329ac801cc420e08452b70e7aeab94ea4233a4804f0915c14eba9b"}, + {file = "frozenlist-1.4.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5c3894db91f5a489fc8fa6a9991820f368f0b3cbdb9cd8849547ccfab3392d86"}, + {file = "frozenlist-1.4.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ba60bb19387e13597fb059f32cd4d59445d7b18b69a745b8f8e5db0346f33480"}, + {file = "frozenlist-1.4.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8aefbba5f69d42246543407ed2461db31006b0f76c4e32dfd6f42215a2c41d09"}, + {file = "frozenlist-1.4.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:780d3a35680ced9ce682fbcf4cb9c2bad3136eeff760ab33707b71db84664e3a"}, + {file = "frozenlist-1.4.1-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9acbb16f06fe7f52f441bb6f413ebae6c37baa6ef9edd49cdd567216da8600cd"}, + {file = "frozenlist-1.4.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:23b701e65c7b36e4bf15546a89279bd4d8675faabc287d06bbcfac7d3c33e1e6"}, + {file = "frozenlist-1.4.1-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:3e0153a805a98f5ada7e09826255ba99fb4f7524bb81bf6b47fb702666484ae1"}, + {file = "frozenlist-1.4.1-cp312-cp312-musllinux_1_1_ppc64le.whl", hash = "sha256:dd9b1baec094d91bf36ec729445f7769d0d0cf6b64d04d86e45baf89e2b9059b"}, + {file = "frozenlist-1.4.1-cp312-cp312-musllinux_1_1_s390x.whl", hash = "sha256:1a4471094e146b6790f61b98616ab8e44f72661879cc63fa1049d13ef711e71e"}, + {file = "frozenlist-1.4.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:5667ed53d68d91920defdf4035d1cdaa3c3121dc0b113255124bcfada1cfa1b8"}, + {file = "frozenlist-1.4.1-cp312-cp312-win32.whl", hash = "sha256:beee944ae828747fd7cb216a70f120767fc9f4f00bacae8543c14a6831673f89"}, + {file = "frozenlist-1.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:64536573d0a2cb6e625cf309984e2d873979709f2cf22839bf2d61790b448ad5"}, + {file = "frozenlist-1.4.1-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:20b51fa3f588ff2fe658663db52a41a4f7aa6c04f6201449c6c7c476bd255c0d"}, + {file = "frozenlist-1.4.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:410478a0c562d1a5bcc2f7ea448359fcb050ed48b3c6f6f4f18c313a9bdb1826"}, + {file = "frozenlist-1.4.1-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:c6321c9efe29975232da3bd0af0ad216800a47e93d763ce64f291917a381b8eb"}, + {file = "frozenlist-1.4.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:48f6a4533887e189dae092f1cf981f2e3885175f7a0f33c91fb5b7b682b6bab6"}, + {file = "frozenlist-1.4.1-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6eb73fa5426ea69ee0e012fb59cdc76a15b1283d6e32e4f8dc4482ec67d1194d"}, + {file = "frozenlist-1.4.1-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:fbeb989b5cc29e8daf7f976b421c220f1b8c731cbf22b9130d8815418ea45887"}, + {file = "frozenlist-1.4.1-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:32453c1de775c889eb4e22f1197fe3bdfe457d16476ea407472b9442e6295f7a"}, + {file = "frozenlist-1.4.1-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:693945278a31f2086d9bf3df0fe8254bbeaef1fe71e1351c3bd730aa7d31c41b"}, + {file = "frozenlist-1.4.1-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:1d0ce09d36d53bbbe566fe296965b23b961764c0bcf3ce2fa45f463745c04701"}, + {file = "frozenlist-1.4.1-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:3a670dc61eb0d0eb7080890c13de3066790f9049b47b0de04007090807c776b0"}, + {file = "frozenlist-1.4.1-cp38-cp38-musllinux_1_1_ppc64le.whl", hash = "sha256:dca69045298ce5c11fd539682cff879cc1e664c245d1c64da929813e54241d11"}, + {file = "frozenlist-1.4.1-cp38-cp38-musllinux_1_1_s390x.whl", hash = "sha256:a06339f38e9ed3a64e4c4e43aec7f59084033647f908e4259d279a52d3757d09"}, + {file = "frozenlist-1.4.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:b7f2f9f912dca3934c1baec2e4585a674ef16fe00218d833856408c48d5beee7"}, + {file = "frozenlist-1.4.1-cp38-cp38-win32.whl", hash = "sha256:e7004be74cbb7d9f34553a5ce5fb08be14fb33bc86f332fb71cbe5216362a497"}, + {file = "frozenlist-1.4.1-cp38-cp38-win_amd64.whl", hash = "sha256:5a7d70357e7cee13f470c7883a063aae5fe209a493c57d86eb7f5a6f910fae09"}, + {file = "frozenlist-1.4.1-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:bfa4a17e17ce9abf47a74ae02f32d014c5e9404b6d9ac7f729e01562bbee601e"}, + {file = "frozenlist-1.4.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:b7e3ed87d4138356775346e6845cccbe66cd9e207f3cd11d2f0b9fd13681359d"}, + {file = "frozenlist-1.4.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:c99169d4ff810155ca50b4da3b075cbde79752443117d89429595c2e8e37fed8"}, + {file = "frozenlist-1.4.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:edb678da49d9f72c9f6c609fbe41a5dfb9a9282f9e6a2253d5a91e0fc382d7c0"}, + {file = "frozenlist-1.4.1-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6db4667b187a6742b33afbbaf05a7bc551ffcf1ced0000a571aedbb4aa42fc7b"}, + {file = "frozenlist-1.4.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:55fdc093b5a3cb41d420884cdaf37a1e74c3c37a31f46e66286d9145d2063bd0"}, + {file = "frozenlist-1.4.1-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:82e8211d69a4f4bc360ea22cd6555f8e61a1bd211d1d5d39d3d228b48c83a897"}, + {file = "frozenlist-1.4.1-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:89aa2c2eeb20957be2d950b85974b30a01a762f3308cd02bb15e1ad632e22dc7"}, + {file = "frozenlist-1.4.1-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:9d3e0c25a2350080e9319724dede4f31f43a6c9779be48021a7f4ebde8b2d742"}, + {file = "frozenlist-1.4.1-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:7268252af60904bf52c26173cbadc3a071cece75f873705419c8681f24d3edea"}, + {file = "frozenlist-1.4.1-cp39-cp39-musllinux_1_1_ppc64le.whl", hash = "sha256:0c250a29735d4f15321007fb02865f0e6b6a41a6b88f1f523ca1596ab5f50bd5"}, + {file = "frozenlist-1.4.1-cp39-cp39-musllinux_1_1_s390x.whl", hash = "sha256:96ec70beabbd3b10e8bfe52616a13561e58fe84c0101dd031dc78f250d5128b9"}, + {file = "frozenlist-1.4.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:23b2d7679b73fe0e5a4560b672a39f98dfc6f60df63823b0a9970525325b95f6"}, + {file = "frozenlist-1.4.1-cp39-cp39-win32.whl", hash = "sha256:a7496bfe1da7fb1a4e1cc23bb67c58fab69311cc7d32b5a99c2007b4b2a0e932"}, + {file = "frozenlist-1.4.1-cp39-cp39-win_amd64.whl", hash = "sha256:e6a20a581f9ce92d389a8c7d7c3dd47c81fd5d6e655c8dddf341e14aa48659d0"}, + {file = "frozenlist-1.4.1-py3-none-any.whl", hash = "sha256:04ced3e6a46b4cfffe20f9ae482818e34eba9b5fb0ce4056e4cc9b6e212d09b7"}, + {file = "frozenlist-1.4.1.tar.gz", hash = "sha256:c037a86e8513059a2613aaba4d817bb90b9d9b6b69aace3ce9c877e8c8ed402b"}, ] [[package]] @@ -584,20 +605,20 @@ smmap = ">=3.0.1,<6" [[package]] name = "gitpython" -version = "3.1.40" +version = "3.1.42" description = "GitPython is a Python library used to interact with Git repositories" optional = false python-versions = ">=3.7" files = [ - {file = "GitPython-3.1.40-py3-none-any.whl", hash = "sha256:cf14627d5a8049ffbf49915732e5eddbe8134c3bdb9d476e6182b676fc573f8a"}, - {file = "GitPython-3.1.40.tar.gz", hash = "sha256:22b126e9ffb671fdd0c129796343a02bf67bf2994b35449ffc9321aa755e18a4"}, + {file = "GitPython-3.1.42-py3-none-any.whl", hash = "sha256:1bf9cd7c9e7255f77778ea54359e54ac22a72a5b51288c457c881057b7bb9ecd"}, + {file = "GitPython-3.1.42.tar.gz", hash = "sha256:2d99869e0fef71a73cbd242528105af1d6c1b108c60dfabd994bf292f76c3ceb"}, ] [package.dependencies] gitdb = ">=4.0.1,<5" [package.extras] -test = ["black", "coverage[toml]", "ddt (>=1.1.1,!=1.4.3)", "mock", "mypy", "pre-commit", "pytest", "pytest-cov", "pytest-instafail", "pytest-subtests", "pytest-sugar"] +test = ["black", "coverage[toml]", "ddt (>=1.1.1,!=1.4.3)", "mock", "mypy", "pre-commit", "pytest (>=7.3.1)", "pytest-cov", "pytest-instafail", "pytest-mock", "pytest-sugar"] [[package]] name = "griffe" @@ -670,13 +691,13 @@ socks = ["socksio (==1.*)"] [[package]] name = "identify" -version = "2.5.33" +version = "2.5.35" description = "File identification library for Python" optional = false python-versions = ">=3.8" files = [ - {file = "identify-2.5.33-py2.py3-none-any.whl", hash = "sha256:d40ce5fcd762817627670da8a7d8d8e65f24342d14539c59488dc603bf662e34"}, - {file = "identify-2.5.33.tar.gz", hash = "sha256:161558f9fe4559e1557e1bff323e8631f6a0e4837f7497767c1782832f16b62d"}, + {file = "identify-2.5.35-py2.py3-none-any.whl", hash = "sha256:c4de0081837b211594f8e877a6b4fad7ca32bbfc1a9307fdd61c28bfe923f13e"}, + {file = "identify-2.5.35.tar.gz", hash = "sha256:10a7ca245cfcd756a554a7288159f72ff105ad233c7c4b9c6f0f4d108f5f6791"}, ] [package.extras] @@ -695,13 +716,13 @@ files = [ [[package]] name = "importlib-metadata" -version = "7.0.0" +version = "7.0.1" description = "Read metadata from Python packages" optional = false python-versions = ">=3.8" files = [ - {file = "importlib_metadata-7.0.0-py3-none-any.whl", hash = "sha256:d97503976bb81f40a193d41ee6570868479c69d5068651eb039c40d850c59d67"}, - {file = "importlib_metadata-7.0.0.tar.gz", hash = "sha256:7fc841f8b8332803464e5dc1c63a2e59121f46ca186c0e2e182e80bf8c1319f7"}, + {file = "importlib_metadata-7.0.1-py3-none-any.whl", hash = "sha256:4805911c3a4ec7c3966410053e9ec6a1fecd629117df5adee56dfc9432a1081e"}, + {file = "importlib_metadata-7.0.1.tar.gz", hash = "sha256:f238736bb06590ae52ac1fab06a3a9ef1d8dce2b7a35b5ab329371d6c8f5d2cc"}, ] [package.dependencies] @@ -725,13 +746,13 @@ files = [ [[package]] name = "jinja2" -version = "3.1.2" +version = "3.1.3" description = "A very fast and expressive template engine." optional = false python-versions = ">=3.7" files = [ - {file = "Jinja2-3.1.2-py3-none-any.whl", hash = "sha256:6088930bfe239f0e6710546ab9c19c9ef35e29792895fed6e6e31a023a182a61"}, - {file = "Jinja2-3.1.2.tar.gz", hash = "sha256:31351a702a408a9e7595a8fc6150fc3f43bb6bf7e319770cbc0db9df9437e852"}, + {file = "Jinja2-3.1.3-py3-none-any.whl", hash = "sha256:7d6d50dd97d52cbc355597bd845fabfbac3f551e1f99619e39a35ce8c370b5fa"}, + {file = "Jinja2-3.1.3.tar.gz", hash = "sha256:ac8bd6544d4bb2c9792bf3a159e80bba8fda7f07e81bc3aed565432d5925ba90"}, ] [package.dependencies] @@ -742,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] @@ -762,13 +783,13 @@ test = ["coverage", "pytest", "pytest-cov"] [[package]] name = "markdown" -version = "3.5.1" +version = "3.5.2" description = "Python implementation of John Gruber's Markdown." optional = false python-versions = ">=3.8" files = [ - {file = "Markdown-3.5.1-py3-none-any.whl", hash = "sha256:5874b47d4ee3f0b14d764324d2c94c03ea66bee56f2d929da9f2508d65e722dc"}, - {file = "Markdown-3.5.1.tar.gz", hash = "sha256:b65d7beb248dc22f2e8a31fb706d93798093c308dc1aba295aedeb9d41a813bd"}, + {file = "Markdown-3.5.2-py3-none-any.whl", hash = "sha256:d43323865d89fc0cb9b20c75fc8ad313af307cc087e84b657d9eec768eddeadd"}, + {file = "Markdown-3.5.2.tar.gz", hash = "sha256:e1ac7b3dc550ee80e602e71c1d168002f062e49f1b11e26a36264dafd4df2ef8"}, ] [package.dependencies] @@ -806,71 +827,71 @@ testing = ["coverage", "pytest", "pytest-cov", "pytest-regressions"] [[package]] name = "markupsafe" -version = "2.1.3" +version = "2.1.5" description = "Safely add untrusted strings to HTML/XML markup." optional = false python-versions = ">=3.7" files = [ - {file = "MarkupSafe-2.1.3-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:cd0f502fe016460680cd20aaa5a76d241d6f35a1c3350c474bac1273803893fa"}, - {file = "MarkupSafe-2.1.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:e09031c87a1e51556fdcb46e5bd4f59dfb743061cf93c4d6831bf894f125eb57"}, - {file = "MarkupSafe-2.1.3-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:68e78619a61ecf91e76aa3e6e8e33fc4894a2bebe93410754bd28fce0a8a4f9f"}, - {file = "MarkupSafe-2.1.3-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:65c1a9bcdadc6c28eecee2c119465aebff8f7a584dd719facdd9e825ec61ab52"}, - {file = "MarkupSafe-2.1.3-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:525808b8019e36eb524b8c68acdd63a37e75714eac50e988180b169d64480a00"}, - {file = "MarkupSafe-2.1.3-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:962f82a3086483f5e5f64dbad880d31038b698494799b097bc59c2edf392fce6"}, - {file = "MarkupSafe-2.1.3-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:aa7bd130efab1c280bed0f45501b7c8795f9fdbeb02e965371bbef3523627779"}, - {file = "MarkupSafe-2.1.3-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:c9c804664ebe8f83a211cace637506669e7890fec1b4195b505c214e50dd4eb7"}, - {file = "MarkupSafe-2.1.3-cp310-cp310-win32.whl", hash = "sha256:10bbfe99883db80bdbaff2dcf681dfc6533a614f700da1287707e8a5d78a8431"}, - {file = "MarkupSafe-2.1.3-cp310-cp310-win_amd64.whl", hash = "sha256:1577735524cdad32f9f694208aa75e422adba74f1baee7551620e43a3141f559"}, - {file = "MarkupSafe-2.1.3-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:ad9e82fb8f09ade1c3e1b996a6337afac2b8b9e365f926f5a61aacc71adc5b3c"}, - {file = "MarkupSafe-2.1.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:3c0fae6c3be832a0a0473ac912810b2877c8cb9d76ca48de1ed31e1c68386575"}, - {file = "MarkupSafe-2.1.3-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b076b6226fb84157e3f7c971a47ff3a679d837cf338547532ab866c57930dbee"}, - {file = "MarkupSafe-2.1.3-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bfce63a9e7834b12b87c64d6b155fdd9b3b96191b6bd334bf37db7ff1fe457f2"}, - {file = "MarkupSafe-2.1.3-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:338ae27d6b8745585f87218a3f23f1512dbf52c26c28e322dbe54bcede54ccb9"}, - {file = "MarkupSafe-2.1.3-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:e4dd52d80b8c83fdce44e12478ad2e85c64ea965e75d66dbeafb0a3e77308fcc"}, - {file = "MarkupSafe-2.1.3-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:df0be2b576a7abbf737b1575f048c23fb1d769f267ec4358296f31c2479db8f9"}, - {file = "MarkupSafe-2.1.3-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:5bbe06f8eeafd38e5d0a4894ffec89378b6c6a625ff57e3028921f8ff59318ac"}, - {file = "MarkupSafe-2.1.3-cp311-cp311-win32.whl", hash = "sha256:dd15ff04ffd7e05ffcb7fe79f1b98041b8ea30ae9234aed2a9168b5797c3effb"}, - {file = "MarkupSafe-2.1.3-cp311-cp311-win_amd64.whl", hash = "sha256:134da1eca9ec0ae528110ccc9e48041e0828d79f24121a1a146161103c76e686"}, - {file = "MarkupSafe-2.1.3-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:f698de3fd0c4e6972b92290a45bd9b1536bffe8c6759c62471efaa8acb4c37bc"}, - {file = "MarkupSafe-2.1.3-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:aa57bd9cf8ae831a362185ee444e15a93ecb2e344c8e52e4d721ea3ab6ef1823"}, - {file = "MarkupSafe-2.1.3-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ffcc3f7c66b5f5b7931a5aa68fc9cecc51e685ef90282f4a82f0f5e9b704ad11"}, - {file = "MarkupSafe-2.1.3-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:47d4f1c5f80fc62fdd7777d0d40a2e9dda0a05883ab11374334f6c4de38adffd"}, - {file = "MarkupSafe-2.1.3-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1f67c7038d560d92149c060157d623c542173016c4babc0c1913cca0564b9939"}, - {file = "MarkupSafe-2.1.3-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:9aad3c1755095ce347e26488214ef77e0485a3c34a50c5a5e2471dff60b9dd9c"}, - {file = "MarkupSafe-2.1.3-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:14ff806850827afd6b07a5f32bd917fb7f45b046ba40c57abdb636674a8b559c"}, - {file = "MarkupSafe-2.1.3-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:8f9293864fe09b8149f0cc42ce56e3f0e54de883a9de90cd427f191c346eb2e1"}, - {file = "MarkupSafe-2.1.3-cp312-cp312-win32.whl", hash = "sha256:715d3562f79d540f251b99ebd6d8baa547118974341db04f5ad06d5ea3eb8007"}, - {file = "MarkupSafe-2.1.3-cp312-cp312-win_amd64.whl", hash = "sha256:1b8dd8c3fd14349433c79fa8abeb573a55fc0fdd769133baac1f5e07abf54aeb"}, - {file = "MarkupSafe-2.1.3-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:8e254ae696c88d98da6555f5ace2279cf7cd5b3f52be2b5cf97feafe883b58d2"}, - {file = "MarkupSafe-2.1.3-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cb0932dc158471523c9637e807d9bfb93e06a95cbf010f1a38b98623b929ef2b"}, - {file = "MarkupSafe-2.1.3-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9402b03f1a1b4dc4c19845e5c749e3ab82d5078d16a2a4c2cd2df62d57bb0707"}, - {file = "MarkupSafe-2.1.3-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ca379055a47383d02a5400cb0d110cef0a776fc644cda797db0c5696cfd7e18e"}, - {file = "MarkupSafe-2.1.3-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:b7ff0f54cb4ff66dd38bebd335a38e2c22c41a8ee45aa608efc890ac3e3931bc"}, - {file = "MarkupSafe-2.1.3-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:c011a4149cfbcf9f03994ec2edffcb8b1dc2d2aede7ca243746df97a5d41ce48"}, - {file = "MarkupSafe-2.1.3-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:56d9f2ecac662ca1611d183feb03a3fa4406469dafe241673d521dd5ae92a155"}, - {file = "MarkupSafe-2.1.3-cp37-cp37m-win32.whl", hash = "sha256:8758846a7e80910096950b67071243da3e5a20ed2546e6392603c096778d48e0"}, - {file = "MarkupSafe-2.1.3-cp37-cp37m-win_amd64.whl", hash = "sha256:787003c0ddb00500e49a10f2844fac87aa6ce977b90b0feaaf9de23c22508b24"}, - {file = "MarkupSafe-2.1.3-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:2ef12179d3a291be237280175b542c07a36e7f60718296278d8593d21ca937d4"}, - {file = "MarkupSafe-2.1.3-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:2c1b19b3aaacc6e57b7e25710ff571c24d6c3613a45e905b1fde04d691b98ee0"}, - {file = "MarkupSafe-2.1.3-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8afafd99945ead6e075b973fefa56379c5b5c53fd8937dad92c662da5d8fd5ee"}, - {file = "MarkupSafe-2.1.3-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8c41976a29d078bb235fea9b2ecd3da465df42a562910f9022f1a03107bd02be"}, - {file = "MarkupSafe-2.1.3-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d080e0a5eb2529460b30190fcfcc4199bd7f827663f858a226a81bc27beaa97e"}, - {file = "MarkupSafe-2.1.3-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:69c0f17e9f5a7afdf2cc9fb2d1ce6aabdb3bafb7f38017c0b77862bcec2bbad8"}, - {file = "MarkupSafe-2.1.3-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:504b320cd4b7eff6f968eddf81127112db685e81f7e36e75f9f84f0df46041c3"}, - {file = "MarkupSafe-2.1.3-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:42de32b22b6b804f42c5d98be4f7e5e977ecdd9ee9b660fda1a3edf03b11792d"}, - {file = "MarkupSafe-2.1.3-cp38-cp38-win32.whl", hash = "sha256:ceb01949af7121f9fc39f7d27f91be8546f3fb112c608bc4029aef0bab86a2a5"}, - {file = "MarkupSafe-2.1.3-cp38-cp38-win_amd64.whl", hash = "sha256:1b40069d487e7edb2676d3fbdb2b0829ffa2cd63a2ec26c4938b2d34391b4ecc"}, - {file = "MarkupSafe-2.1.3-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:8023faf4e01efadfa183e863fefde0046de576c6f14659e8782065bcece22198"}, - {file = "MarkupSafe-2.1.3-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:6b2b56950d93e41f33b4223ead100ea0fe11f8e6ee5f641eb753ce4b77a7042b"}, - {file = "MarkupSafe-2.1.3-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9dcdfd0eaf283af041973bff14a2e143b8bd64e069f4c383416ecd79a81aab58"}, - {file = "MarkupSafe-2.1.3-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:05fb21170423db021895e1ea1e1f3ab3adb85d1c2333cbc2310f2a26bc77272e"}, - {file = "MarkupSafe-2.1.3-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:282c2cb35b5b673bbcadb33a585408104df04f14b2d9b01d4c345a3b92861c2c"}, - {file = "MarkupSafe-2.1.3-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:ab4a0df41e7c16a1392727727e7998a467472d0ad65f3ad5e6e765015df08636"}, - {file = "MarkupSafe-2.1.3-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:7ef3cb2ebbf91e330e3bb937efada0edd9003683db6b57bb108c4001f37a02ea"}, - {file = "MarkupSafe-2.1.3-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:0a4e4a1aff6c7ac4cd55792abf96c915634c2b97e3cc1c7129578aa68ebd754e"}, - {file = "MarkupSafe-2.1.3-cp39-cp39-win32.whl", hash = "sha256:fec21693218efe39aa7f8599346e90c705afa52c5b31ae019b2e57e8f6542bb2"}, - {file = "MarkupSafe-2.1.3-cp39-cp39-win_amd64.whl", hash = "sha256:3fd4abcb888d15a94f32b75d8fd18ee162ca0c064f35b11134be77050296d6ba"}, - {file = "MarkupSafe-2.1.3.tar.gz", hash = "sha256:af598ed32d6ae86f1b747b82783958b1a4ab8f617b06fe68795c7f026abbdcad"}, + {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]] @@ -947,17 +968,18 @@ min-versions = ["babel (==2.9.0)", "click (==7.0)", "colorama (==0.4)", "ghp-imp [[package]] name = "mkdocs-autorefs" -version = "0.5.0" +version = "1.0.1" description = "Automatically link across pages in MkDocs." optional = false python-versions = ">=3.8" files = [ - {file = "mkdocs_autorefs-0.5.0-py3-none-any.whl", hash = "sha256:7930fcb8ac1249f10e683967aeaddc0af49d90702af111a5e390e8b20b3d97ff"}, - {file = "mkdocs_autorefs-0.5.0.tar.gz", hash = "sha256:9a5054a94c08d28855cfab967ada10ed5be76e2bfad642302a610b252c3274c0"}, + {file = "mkdocs_autorefs-1.0.1-py3-none-any.whl", hash = "sha256:aacdfae1ab197780fb7a2dac92ad8a3d8f7ca8049a9cbe56a4218cd52e8da570"}, + {file = "mkdocs_autorefs-1.0.1.tar.gz", hash = "sha256:f684edf847eced40b570b57846b15f0bf57fb93ac2c510450775dcf16accb971"}, ] [package.dependencies] Markdown = ">=3.3" +markupsafe = ">=2.0.1" mkdocs = ">=1.1" [[package]] @@ -975,13 +997,13 @@ mkdocs = "*" [[package]] name = "mkdocs-material" -version = "9.5.1" +version = "9.5.12" description = "Documentation that simply works" optional = false python-versions = ">=3.8" files = [ - {file = "mkdocs_material-9.5.1-py3-none-any.whl", hash = "sha256:2e01249bc41813afe2479a4a659f8ba899c3355ccaf9310b5b782952df9c1dea"}, - {file = "mkdocs_material-9.5.1.tar.gz", hash = "sha256:7ec5d20ed5eee97bb090823a33b33e177ad0704d74bad5937b53acca571ddb3d"}, + {file = "mkdocs_material-9.5.12-py3-none-any.whl", hash = "sha256:d6f0c269f015e48c76291cdc79efb70f7b33bbbf42d649cfe475522ebee61b1f"}, + {file = "mkdocs_material-9.5.12.tar.gz", hash = "sha256:5f69cef6a8aaa4050b812f72b1094fda3d079b9a51cf27a247244c03ec455e97"}, ] [package.dependencies] @@ -989,7 +1011,7 @@ babel = ">=2.10,<3.0" colorama = ">=0.4,<1.0" jinja2 = ">=3.0,<4.0" markdown = ">=3.2,<4.0" -mkdocs = ">=1.5.3,<2.0" +mkdocs = ">=1.5.3,<1.6.0" mkdocs-material-extensions = ">=1.3,<2.0" paginate = ">=0.5,<1.0" pygments = ">=2.16,<3.0" @@ -998,8 +1020,8 @@ 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)"] -imaging = ["cairosvg (>=2.6,<3.0)", "pillow (>=9.4,<10.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)"] [[package]] @@ -1015,13 +1037,13 @@ files = [ [[package]] name = "mkdocs-rss-plugin" -version = "1.9.0" +version = "1.12.1" description = "MkDocs plugin which generates a static RSS feed using git log and page.meta." optional = false python-versions = ">=3.8, <4" files = [ - {file = "mkdocs-rss-plugin-1.9.0.tar.gz", hash = "sha256:eeb576945d3d9990cdf8aa3545062669892ea4410e5a960072d44cec867dba42"}, - {file = "mkdocs_rss_plugin-1.9.0-py2.py3-none-any.whl", hash = "sha256:8c3eda30ec59e6b51c6c0ed2b27e6f5c907583d8828122c81140b4505f42b72c"}, + {file = "mkdocs-rss-plugin-1.12.1.tar.gz", hash = "sha256:5df9bddfdc1465623def1b14c2656c5e8f62fa7c8bd1c0c667e01fc86105d415"}, + {file = "mkdocs_rss_plugin-1.12.1-py2.py3-none-any.whl", hash = "sha256:acfb8eec95f1db389b36770baf99af3b87e38484cc37993194a35aa173eb3fe8"}, ] [package.dependencies] @@ -1031,9 +1053,9 @@ pytz = {version = "==2022.*", markers = "python_version < \"3.9\""} tzdata = {version = "==2023.*", markers = "python_version >= \"3.9\" and sys_platform == \"win32\""} [package.extras] -dev = ["black", "flake8 (>=6,<7)", "flake8-bugbear (>=23.12)", "flake8-builtins (>=2.1)", "flake8-eradicate (>=1)", "flake8-isort (>=6)", "pre-commit (>=3,<4)"] -doc = ["mkdocs-bootswatch (>=1,<2)", "mkdocs-minify-plugin (==0.7.*)", "pygments (>=2.5,<3)", "pymdown-extensions (>=10,<11)"] -test = ["feedparser (>=6.0,<6.1)", "mkdocs-material (>=9)", "pytest-cov (>=4,<4.2)", "validator-collection (>=1.5,<1.6)"] +dev = ["black", "flake8 (>=6,<8)", "flake8-bugbear (>=23.12)", "flake8-builtins (>=2.1)", "flake8-eradicate (>=1)", "flake8-isort (>=6)", "pre-commit (>=3,<4)"] +doc = ["mkdocs-git-committers-plugin-2 (>=1.2,<2.3)", "mkdocs-git-revision-date-localized-plugin (>=1,<1.3)", "mkdocs-material[imaging] (>=9.5.1,<10)", "mkdocs-minify-plugin (==0.8.*)", "mkdocstrings[python] (>=0.18,<1)", "termynal (>=0.11.1,<0.12)"] +test = ["feedparser (>=6.0.11,<6.1)", "jsonfeed-util (>=1.1.2,<2)", "mkdocs-material[imaging] (>=9)", "pytest-cov (>=4,<4.2)", "validator-collection (>=1.5,<1.6)"] [[package]] name = "mkdocstrings" @@ -1077,186 +1099,202 @@ mkdocstrings = ">=0.20" [[package]] name = "msgpack" -version = "1.0.7" +version = "1.0.8" description = "MessagePack serializer" optional = false python-versions = ">=3.8" files = [ - {file = "msgpack-1.0.7-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:04ad6069c86e531682f9e1e71b71c1c3937d6014a7c3e9edd2aa81ad58842862"}, - {file = "msgpack-1.0.7-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:cca1b62fe70d761a282496b96a5e51c44c213e410a964bdffe0928e611368329"}, - {file = "msgpack-1.0.7-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e50ebce52f41370707f1e21a59514e3375e3edd6e1832f5e5235237db933c98b"}, - {file = "msgpack-1.0.7-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4a7b4f35de6a304b5533c238bee86b670b75b03d31b7797929caa7a624b5dda6"}, - {file = "msgpack-1.0.7-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:28efb066cde83c479dfe5a48141a53bc7e5f13f785b92ddde336c716663039ee"}, - {file = "msgpack-1.0.7-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4cb14ce54d9b857be9591ac364cb08dc2d6a5c4318c1182cb1d02274029d590d"}, - {file = "msgpack-1.0.7-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:b573a43ef7c368ba4ea06050a957c2a7550f729c31f11dd616d2ac4aba99888d"}, - {file = "msgpack-1.0.7-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:ccf9a39706b604d884d2cb1e27fe973bc55f2890c52f38df742bc1d79ab9f5e1"}, - {file = "msgpack-1.0.7-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:cb70766519500281815dfd7a87d3a178acf7ce95390544b8c90587d76b227681"}, - {file = "msgpack-1.0.7-cp310-cp310-win32.whl", hash = "sha256:b610ff0f24e9f11c9ae653c67ff8cc03c075131401b3e5ef4b82570d1728f8a9"}, - {file = "msgpack-1.0.7-cp310-cp310-win_amd64.whl", hash = "sha256:a40821a89dc373d6427e2b44b572efc36a2778d3f543299e2f24eb1a5de65415"}, - {file = "msgpack-1.0.7-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:576eb384292b139821c41995523654ad82d1916da6a60cff129c715a6223ea84"}, - {file = "msgpack-1.0.7-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:730076207cb816138cf1af7f7237b208340a2c5e749707457d70705715c93b93"}, - {file = "msgpack-1.0.7-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:85765fdf4b27eb5086f05ac0491090fc76f4f2b28e09d9350c31aac25a5aaff8"}, - {file = "msgpack-1.0.7-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3476fae43db72bd11f29a5147ae2f3cb22e2f1a91d575ef130d2bf49afd21c46"}, - {file = "msgpack-1.0.7-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6d4c80667de2e36970ebf74f42d1088cc9ee7ef5f4e8c35eee1b40eafd33ca5b"}, - {file = "msgpack-1.0.7-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5b0bf0effb196ed76b7ad883848143427a73c355ae8e569fa538365064188b8e"}, - {file = "msgpack-1.0.7-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:f9a7c509542db4eceed3dcf21ee5267ab565a83555c9b88a8109dcecc4709002"}, - {file = "msgpack-1.0.7-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:84b0daf226913133f899ea9b30618722d45feffa67e4fe867b0b5ae83a34060c"}, - {file = "msgpack-1.0.7-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:ec79ff6159dffcc30853b2ad612ed572af86c92b5168aa3fc01a67b0fa40665e"}, - {file = "msgpack-1.0.7-cp311-cp311-win32.whl", hash = "sha256:3e7bf4442b310ff154b7bb9d81eb2c016b7d597e364f97d72b1acc3817a0fdc1"}, - {file = "msgpack-1.0.7-cp311-cp311-win_amd64.whl", hash = "sha256:3f0c8c6dfa6605ab8ff0611995ee30d4f9fcff89966cf562733b4008a3d60d82"}, - {file = "msgpack-1.0.7-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:f0936e08e0003f66bfd97e74ee530427707297b0d0361247e9b4f59ab78ddc8b"}, - {file = "msgpack-1.0.7-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:98bbd754a422a0b123c66a4c341de0474cad4a5c10c164ceed6ea090f3563db4"}, - {file = "msgpack-1.0.7-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b291f0ee7961a597cbbcc77709374087fa2a9afe7bdb6a40dbbd9b127e79afee"}, - {file = "msgpack-1.0.7-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ebbbba226f0a108a7366bf4b59bf0f30a12fd5e75100c630267d94d7f0ad20e5"}, - {file = "msgpack-1.0.7-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1e2d69948e4132813b8d1131f29f9101bc2c915f26089a6d632001a5c1349672"}, - {file = "msgpack-1.0.7-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:bdf38ba2d393c7911ae989c3bbba510ebbcdf4ecbdbfec36272abe350c454075"}, - {file = "msgpack-1.0.7-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:993584fc821c58d5993521bfdcd31a4adf025c7d745bbd4d12ccfecf695af5ba"}, - {file = "msgpack-1.0.7-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:52700dc63a4676669b341ba33520f4d6e43d3ca58d422e22ba66d1736b0a6e4c"}, - {file = "msgpack-1.0.7-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:e45ae4927759289c30ccba8d9fdce62bb414977ba158286b5ddaf8df2cddb5c5"}, - {file = "msgpack-1.0.7-cp312-cp312-win32.whl", hash = "sha256:27dcd6f46a21c18fa5e5deed92a43d4554e3df8d8ca5a47bf0615d6a5f39dbc9"}, - {file = "msgpack-1.0.7-cp312-cp312-win_amd64.whl", hash = "sha256:7687e22a31e976a0e7fc99c2f4d11ca45eff652a81eb8c8085e9609298916dcf"}, - {file = "msgpack-1.0.7-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:5b6ccc0c85916998d788b295765ea0e9cb9aac7e4a8ed71d12e7d8ac31c23c95"}, - {file = "msgpack-1.0.7-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:235a31ec7db685f5c82233bddf9858748b89b8119bf4538d514536c485c15fe0"}, - {file = "msgpack-1.0.7-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:cab3db8bab4b7e635c1c97270d7a4b2a90c070b33cbc00c99ef3f9be03d3e1f7"}, - {file = "msgpack-1.0.7-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0bfdd914e55e0d2c9e1526de210f6fe8ffe9705f2b1dfcc4aecc92a4cb4b533d"}, - {file = "msgpack-1.0.7-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:36e17c4592231a7dbd2ed09027823ab295d2791b3b1efb2aee874b10548b7524"}, - {file = "msgpack-1.0.7-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:38949d30b11ae5f95c3c91917ee7a6b239f5ec276f271f28638dec9156f82cfc"}, - {file = "msgpack-1.0.7-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:ff1d0899f104f3921d94579a5638847f783c9b04f2d5f229392ca77fba5b82fc"}, - {file = "msgpack-1.0.7-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:dc43f1ec66eb8440567186ae2f8c447d91e0372d793dfe8c222aec857b81a8cf"}, - {file = "msgpack-1.0.7-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:dd632777ff3beaaf629f1ab4396caf7ba0bdd075d948a69460d13d44357aca4c"}, - {file = "msgpack-1.0.7-cp38-cp38-win32.whl", hash = "sha256:4e71bc4416de195d6e9b4ee93ad3f2f6b2ce11d042b4d7a7ee00bbe0358bd0c2"}, - {file = "msgpack-1.0.7-cp38-cp38-win_amd64.whl", hash = "sha256:8f5b234f567cf76ee489502ceb7165c2a5cecec081db2b37e35332b537f8157c"}, - {file = "msgpack-1.0.7-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:bfef2bb6ef068827bbd021017a107194956918ab43ce4d6dc945ffa13efbc25f"}, - {file = "msgpack-1.0.7-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:484ae3240666ad34cfa31eea7b8c6cd2f1fdaae21d73ce2974211df099a95d81"}, - {file = "msgpack-1.0.7-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:3967e4ad1aa9da62fd53e346ed17d7b2e922cba5ab93bdd46febcac39be636fc"}, - {file = "msgpack-1.0.7-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8dd178c4c80706546702c59529ffc005681bd6dc2ea234c450661b205445a34d"}, - {file = "msgpack-1.0.7-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f6ffbc252eb0d229aeb2f9ad051200668fc3a9aaa8994e49f0cb2ffe2b7867e7"}, - {file = "msgpack-1.0.7-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:822ea70dc4018c7e6223f13affd1c5c30c0f5c12ac1f96cd8e9949acddb48a61"}, - {file = "msgpack-1.0.7-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:384d779f0d6f1b110eae74cb0659d9aa6ff35aaf547b3955abf2ab4c901c4819"}, - {file = "msgpack-1.0.7-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:f64e376cd20d3f030190e8c32e1c64582eba56ac6dc7d5b0b49a9d44021b52fd"}, - {file = "msgpack-1.0.7-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:5ed82f5a7af3697b1c4786053736f24a0efd0a1b8a130d4c7bfee4b9ded0f08f"}, - {file = "msgpack-1.0.7-cp39-cp39-win32.whl", hash = "sha256:f26a07a6e877c76a88e3cecac8531908d980d3d5067ff69213653649ec0f60ad"}, - {file = "msgpack-1.0.7-cp39-cp39-win_amd64.whl", hash = "sha256:1dc93e8e4653bdb5910aed79f11e165c85732067614f180f70534f056da97db3"}, - {file = "msgpack-1.0.7.tar.gz", hash = "sha256:572efc93db7a4d27e404501975ca6d2d9775705c2d922390d878fcf768d92c87"}, + {file = "msgpack-1.0.8-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:505fe3d03856ac7d215dbe005414bc28505d26f0c128906037e66d98c4e95868"}, + {file = "msgpack-1.0.8-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:e6b7842518a63a9f17107eb176320960ec095a8ee3b4420b5f688e24bf50c53c"}, + {file = "msgpack-1.0.8-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:376081f471a2ef24828b83a641a02c575d6103a3ad7fd7dade5486cad10ea659"}, + {file = "msgpack-1.0.8-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5e390971d082dba073c05dbd56322427d3280b7cc8b53484c9377adfbae67dc2"}, + {file = "msgpack-1.0.8-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:00e073efcba9ea99db5acef3959efa45b52bc67b61b00823d2a1a6944bf45982"}, + {file = "msgpack-1.0.8-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:82d92c773fbc6942a7a8b520d22c11cfc8fd83bba86116bfcf962c2f5c2ecdaa"}, + {file = "msgpack-1.0.8-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:9ee32dcb8e531adae1f1ca568822e9b3a738369b3b686d1477cbc643c4a9c128"}, + {file = "msgpack-1.0.8-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:e3aa7e51d738e0ec0afbed661261513b38b3014754c9459508399baf14ae0c9d"}, + {file = "msgpack-1.0.8-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:69284049d07fce531c17404fcba2bb1df472bc2dcdac642ae71a2d079d950653"}, + {file = "msgpack-1.0.8-cp310-cp310-win32.whl", hash = "sha256:13577ec9e247f8741c84d06b9ece5f654920d8365a4b636ce0e44f15e07ec693"}, + {file = "msgpack-1.0.8-cp310-cp310-win_amd64.whl", hash = "sha256:e532dbd6ddfe13946de050d7474e3f5fb6ec774fbb1a188aaf469b08cf04189a"}, + {file = "msgpack-1.0.8-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:9517004e21664f2b5a5fd6333b0731b9cf0817403a941b393d89a2f1dc2bd836"}, + {file = "msgpack-1.0.8-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:d16a786905034e7e34098634b184a7d81f91d4c3d246edc6bd7aefb2fd8ea6ad"}, + {file = "msgpack-1.0.8-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e2872993e209f7ed04d963e4b4fbae72d034844ec66bc4ca403329db2074377b"}, + {file = "msgpack-1.0.8-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5c330eace3dd100bdb54b5653b966de7f51c26ec4a7d4e87132d9b4f738220ba"}, + {file = "msgpack-1.0.8-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:83b5c044f3eff2a6534768ccfd50425939e7a8b5cf9a7261c385de1e20dcfc85"}, + {file = "msgpack-1.0.8-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1876b0b653a808fcd50123b953af170c535027bf1d053b59790eebb0aeb38950"}, + {file = "msgpack-1.0.8-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:dfe1f0f0ed5785c187144c46a292b8c34c1295c01da12e10ccddfc16def4448a"}, + {file = "msgpack-1.0.8-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:3528807cbbb7f315bb81959d5961855e7ba52aa60a3097151cb21956fbc7502b"}, + {file = "msgpack-1.0.8-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:e2f879ab92ce502a1e65fce390eab619774dda6a6ff719718069ac94084098ce"}, + {file = "msgpack-1.0.8-cp311-cp311-win32.whl", hash = "sha256:26ee97a8261e6e35885c2ecd2fd4a6d38252246f94a2aec23665a4e66d066305"}, + {file = "msgpack-1.0.8-cp311-cp311-win_amd64.whl", hash = "sha256:eadb9f826c138e6cf3c49d6f8de88225a3c0ab181a9b4ba792e006e5292d150e"}, + {file = "msgpack-1.0.8-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:114be227f5213ef8b215c22dde19532f5da9652e56e8ce969bf0a26d7c419fee"}, + {file = "msgpack-1.0.8-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:d661dc4785affa9d0edfdd1e59ec056a58b3dbb9f196fa43587f3ddac654ac7b"}, + {file = "msgpack-1.0.8-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:d56fd9f1f1cdc8227d7b7918f55091349741904d9520c65f0139a9755952c9e8"}, + {file = "msgpack-1.0.8-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0726c282d188e204281ebd8de31724b7d749adebc086873a59efb8cf7ae27df3"}, + {file = "msgpack-1.0.8-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8db8e423192303ed77cff4dce3a4b88dbfaf43979d280181558af5e2c3c71afc"}, + {file = "msgpack-1.0.8-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:99881222f4a8c2f641f25703963a5cefb076adffd959e0558dc9f803a52d6a58"}, + {file = "msgpack-1.0.8-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:b5505774ea2a73a86ea176e8a9a4a7c8bf5d521050f0f6f8426afe798689243f"}, + {file = "msgpack-1.0.8-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:ef254a06bcea461e65ff0373d8a0dd1ed3aa004af48839f002a0c994a6f72d04"}, + {file = "msgpack-1.0.8-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:e1dd7839443592d00e96db831eddb4111a2a81a46b028f0facd60a09ebbdd543"}, + {file = "msgpack-1.0.8-cp312-cp312-win32.whl", hash = "sha256:64d0fcd436c5683fdd7c907eeae5e2cbb5eb872fafbc03a43609d7941840995c"}, + {file = "msgpack-1.0.8-cp312-cp312-win_amd64.whl", hash = "sha256:74398a4cf19de42e1498368c36eed45d9528f5fd0155241e82c4082b7e16cffd"}, + {file = "msgpack-1.0.8-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:0ceea77719d45c839fd73abcb190b8390412a890df2f83fb8cf49b2a4b5c2f40"}, + {file = "msgpack-1.0.8-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:1ab0bbcd4d1f7b6991ee7c753655b481c50084294218de69365f8f1970d4c151"}, + {file = "msgpack-1.0.8-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:1cce488457370ffd1f953846f82323cb6b2ad2190987cd4d70b2713e17268d24"}, + {file = "msgpack-1.0.8-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3923a1778f7e5ef31865893fdca12a8d7dc03a44b33e2a5f3295416314c09f5d"}, + {file = "msgpack-1.0.8-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a22e47578b30a3e199ab067a4d43d790249b3c0587d9a771921f86250c8435db"}, + {file = "msgpack-1.0.8-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:bd739c9251d01e0279ce729e37b39d49a08c0420d3fee7f2a4968c0576678f77"}, + {file = "msgpack-1.0.8-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:d3420522057ebab1728b21ad473aa950026d07cb09da41103f8e597dfbfaeb13"}, + {file = "msgpack-1.0.8-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:5845fdf5e5d5b78a49b826fcdc0eb2e2aa7191980e3d2cfd2a30303a74f212e2"}, + {file = "msgpack-1.0.8-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:6a0e76621f6e1f908ae52860bdcb58e1ca85231a9b0545e64509c931dd34275a"}, + {file = "msgpack-1.0.8-cp38-cp38-win32.whl", hash = "sha256:374a8e88ddab84b9ada695d255679fb99c53513c0a51778796fcf0944d6c789c"}, + {file = "msgpack-1.0.8-cp38-cp38-win_amd64.whl", hash = "sha256:f3709997b228685fe53e8c433e2df9f0cdb5f4542bd5114ed17ac3c0129b0480"}, + {file = "msgpack-1.0.8-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:f51bab98d52739c50c56658cc303f190785f9a2cd97b823357e7aeae54c8f68a"}, + {file = "msgpack-1.0.8-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:73ee792784d48aa338bba28063e19a27e8d989344f34aad14ea6e1b9bd83f596"}, + {file = "msgpack-1.0.8-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:f9904e24646570539a8950400602d66d2b2c492b9010ea7e965025cb71d0c86d"}, + {file = "msgpack-1.0.8-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e75753aeda0ddc4c28dce4c32ba2f6ec30b1b02f6c0b14e547841ba5b24f753f"}, + {file = "msgpack-1.0.8-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5dbf059fb4b7c240c873c1245ee112505be27497e90f7c6591261c7d3c3a8228"}, + {file = "msgpack-1.0.8-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4916727e31c28be8beaf11cf117d6f6f188dcc36daae4e851fee88646f5b6b18"}, + {file = "msgpack-1.0.8-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:7938111ed1358f536daf311be244f34df7bf3cdedb3ed883787aca97778b28d8"}, + {file = "msgpack-1.0.8-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:493c5c5e44b06d6c9268ce21b302c9ca055c1fd3484c25ba41d34476c76ee746"}, + {file = "msgpack-1.0.8-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:5fbb160554e319f7b22ecf530a80a3ff496d38e8e07ae763b9e82fadfe96f273"}, + {file = "msgpack-1.0.8-cp39-cp39-win32.whl", hash = "sha256:f9af38a89b6a5c04b7d18c492c8ccf2aee7048aff1ce8437c4683bb5a1df893d"}, + {file = "msgpack-1.0.8-cp39-cp39-win_amd64.whl", hash = "sha256:ed59dd52075f8fc91da6053b12e8c89e37aa043f8986efd89e61fae69dc1b011"}, + {file = "msgpack-1.0.8-py3-none-any.whl", hash = "sha256:24f727df1e20b9876fa6e95f840a2a2651e34c0ad147676356f4bf5fbb0206ca"}, ] [[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]] name = "mypy" -version = "1.7.1" +version = "1.8.0" description = "Optional static typing for Python" optional = false python-versions = ">=3.8" files = [ - {file = "mypy-1.7.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:12cce78e329838d70a204293e7b29af9faa3ab14899aec397798a4b41be7f340"}, - {file = "mypy-1.7.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:1484b8fa2c10adf4474f016e09d7a159602f3239075c7bf9f1627f5acf40ad49"}, - {file = "mypy-1.7.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:31902408f4bf54108bbfb2e35369877c01c95adc6192958684473658c322c8a5"}, - {file = "mypy-1.7.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:f2c2521a8e4d6d769e3234350ba7b65ff5d527137cdcde13ff4d99114b0c8e7d"}, - {file = "mypy-1.7.1-cp310-cp310-win_amd64.whl", hash = "sha256:fcd2572dd4519e8a6642b733cd3a8cfc1ef94bafd0c1ceed9c94fe736cb65b6a"}, - {file = "mypy-1.7.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:4b901927f16224d0d143b925ce9a4e6b3a758010673eeded9b748f250cf4e8f7"}, - {file = "mypy-1.7.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:2f7f6985d05a4e3ce8255396df363046c28bea790e40617654e91ed580ca7c51"}, - {file = "mypy-1.7.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:944bdc21ebd620eafefc090cdf83158393ec2b1391578359776c00de00e8907a"}, - {file = "mypy-1.7.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:9c7ac372232c928fff0645d85f273a726970c014749b924ce5710d7d89763a28"}, - {file = "mypy-1.7.1-cp311-cp311-win_amd64.whl", hash = "sha256:f6efc9bd72258f89a3816e3a98c09d36f079c223aa345c659622f056b760ab42"}, - {file = "mypy-1.7.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:6dbdec441c60699288adf051f51a5d512b0d818526d1dcfff5a41f8cd8b4aaf1"}, - {file = "mypy-1.7.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:4fc3d14ee80cd22367caaaf6e014494415bf440980a3045bf5045b525680ac33"}, - {file = "mypy-1.7.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2c6e4464ed5f01dc44dc9821caf67b60a4e5c3b04278286a85c067010653a0eb"}, - {file = "mypy-1.7.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:d9b338c19fa2412f76e17525c1b4f2c687a55b156320acb588df79f2e6fa9fea"}, - {file = "mypy-1.7.1-cp312-cp312-win_amd64.whl", hash = "sha256:204e0d6de5fd2317394a4eff62065614c4892d5a4d1a7ee55b765d7a3d9e3f82"}, - {file = "mypy-1.7.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:84860e06ba363d9c0eeabd45ac0fde4b903ad7aa4f93cd8b648385a888e23200"}, - {file = "mypy-1.7.1-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:8c5091ebd294f7628eb25ea554852a52058ac81472c921150e3a61cdd68f75a7"}, - {file = "mypy-1.7.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:40716d1f821b89838589e5b3106ebbc23636ffdef5abc31f7cd0266db936067e"}, - {file = "mypy-1.7.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:5cf3f0c5ac72139797953bd50bc6c95ac13075e62dbfcc923571180bebb662e9"}, - {file = "mypy-1.7.1-cp38-cp38-win_amd64.whl", hash = "sha256:78e25b2fd6cbb55ddfb8058417df193f0129cad5f4ee75d1502248e588d9e0d7"}, - {file = "mypy-1.7.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:75c4d2a6effd015786c87774e04331b6da863fc3fc4e8adfc3b40aa55ab516fe"}, - {file = "mypy-1.7.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:2643d145af5292ee956aa0a83c2ce1038a3bdb26e033dadeb2f7066fb0c9abce"}, - {file = "mypy-1.7.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:75aa828610b67462ffe3057d4d8a4112105ed211596b750b53cbfe182f44777a"}, - {file = "mypy-1.7.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:ee5d62d28b854eb61889cde4e1dbc10fbaa5560cb39780c3995f6737f7e82120"}, - {file = "mypy-1.7.1-cp39-cp39-win_amd64.whl", hash = "sha256:72cf32ce7dd3562373f78bd751f73c96cfb441de147cc2448a92c1a308bd0ca6"}, - {file = "mypy-1.7.1-py3-none-any.whl", hash = "sha256:f7c5d642db47376a0cc130f0de6d055056e010debdaf0707cd2b0fc7e7ef30ea"}, - {file = "mypy-1.7.1.tar.gz", hash = "sha256:fcb6d9afb1b6208b4c712af0dafdc650f518836065df0d4fb1d800f5d6773db2"}, + {file = "mypy-1.8.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:485a8942f671120f76afffff70f259e1cd0f0cfe08f81c05d8816d958d4577d3"}, + {file = "mypy-1.8.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:df9824ac11deaf007443e7ed2a4a26bebff98d2bc43c6da21b2b64185da011c4"}, + {file = "mypy-1.8.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2afecd6354bbfb6e0160f4e4ad9ba6e4e003b767dd80d85516e71f2e955ab50d"}, + {file = "mypy-1.8.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:8963b83d53ee733a6e4196954502b33567ad07dfd74851f32be18eb932fb1cb9"}, + {file = "mypy-1.8.0-cp310-cp310-win_amd64.whl", hash = "sha256:e46f44b54ebddbeedbd3d5b289a893219065ef805d95094d16a0af6630f5d410"}, + {file = "mypy-1.8.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:855fe27b80375e5c5878492f0729540db47b186509c98dae341254c8f45f42ae"}, + {file = "mypy-1.8.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4c886c6cce2d070bd7df4ec4a05a13ee20c0aa60cb587e8d1265b6c03cf91da3"}, + {file = "mypy-1.8.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d19c413b3c07cbecf1f991e2221746b0d2a9410b59cb3f4fb9557f0365a1a817"}, + {file = "mypy-1.8.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:9261ed810972061388918c83c3f5cd46079d875026ba97380f3e3978a72f503d"}, + {file = "mypy-1.8.0-cp311-cp311-win_amd64.whl", hash = "sha256:51720c776d148bad2372ca21ca29256ed483aa9a4cdefefcef49006dff2a6835"}, + {file = "mypy-1.8.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:52825b01f5c4c1c4eb0db253ec09c7aa17e1a7304d247c48b6f3599ef40db8bd"}, + {file = "mypy-1.8.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f5ac9a4eeb1ec0f1ccdc6f326bcdb464de5f80eb07fb38b5ddd7b0de6bc61e55"}, + {file = "mypy-1.8.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:afe3fe972c645b4632c563d3f3eff1cdca2fa058f730df2b93a35e3b0c538218"}, + {file = "mypy-1.8.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:42c6680d256ab35637ef88891c6bd02514ccb7e1122133ac96055ff458f93fc3"}, + {file = "mypy-1.8.0-cp312-cp312-win_amd64.whl", hash = "sha256:720a5ca70e136b675af3af63db533c1c8c9181314d207568bbe79051f122669e"}, + {file = "mypy-1.8.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:028cf9f2cae89e202d7b6593cd98db6759379f17a319b5faf4f9978d7084cdc6"}, + {file = "mypy-1.8.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:4e6d97288757e1ddba10dd9549ac27982e3e74a49d8d0179fc14d4365c7add66"}, + {file = "mypy-1.8.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7f1478736fcebb90f97e40aff11a5f253af890c845ee0c850fe80aa060a267c6"}, + {file = "mypy-1.8.0-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:42419861b43e6962a649068a61f4a4839205a3ef525b858377a960b9e2de6e0d"}, + {file = "mypy-1.8.0-cp38-cp38-win_amd64.whl", hash = "sha256:2b5b6c721bd4aabaadead3a5e6fa85c11c6c795e0c81a7215776ef8afc66de02"}, + {file = "mypy-1.8.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:5c1538c38584029352878a0466f03a8ee7547d7bd9f641f57a0f3017a7c905b8"}, + {file = "mypy-1.8.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:4ef4be7baf08a203170f29e89d79064463b7fc7a0908b9d0d5114e8009c3a259"}, + {file = "mypy-1.8.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7178def594014aa6c35a8ff411cf37d682f428b3b5617ca79029d8ae72f5402b"}, + {file = "mypy-1.8.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:ab3c84fa13c04aeeeabb2a7f67a25ef5d77ac9d6486ff33ded762ef353aa5592"}, + {file = "mypy-1.8.0-cp39-cp39-win_amd64.whl", hash = "sha256:99b00bc72855812a60d253420d8a2eae839b0afa4938f09f4d2aa9bb4654263a"}, + {file = "mypy-1.8.0-py3-none-any.whl", hash = "sha256:538fd81bb5e430cc1381a443971c0475582ff9f434c16cd46d2c66763ce85d9d"}, + {file = "mypy-1.8.0.tar.gz", hash = "sha256:6ff8b244d7085a0b425b56d327b480c3b29cafbd2eff27316a004f9a7391ae07"}, ] [package.dependencies] @@ -1318,39 +1356,39 @@ files = [ [[package]] name = "pathspec" -version = "0.11.2" +version = "0.12.1" description = "Utility library for gitignore style pattern matching of file paths." optional = false -python-versions = ">=3.7" +python-versions = ">=3.8" files = [ - {file = "pathspec-0.11.2-py3-none-any.whl", hash = "sha256:1d6ed233af05e679efb96b1851550ea95bbb64b7c490b0f5aa52996c11e92a20"}, - {file = "pathspec-0.11.2.tar.gz", hash = "sha256:e0d8d0ac2f12da61956eb2306b69f9469b42f4deb0f3cb6ed47b9cce9996ced3"}, + {file = "pathspec-0.12.1-py3-none-any.whl", hash = "sha256:a0d503e138a4c123b27490a4f7beda6a01c6f288df0e4a8b79c7eb0dc7b4cc08"}, + {file = "pathspec-0.12.1.tar.gz", hash = "sha256:a482d51503a1ab33b1c67a6c3813a26953dbdc71c31dacaef9a838c4e29f5712"}, ] [[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" -version = "1.3.0" +version = "1.4.0" description = "plugin and hook calling mechanisms for python" optional = false python-versions = ">=3.8" files = [ - {file = "pluggy-1.3.0-py3-none-any.whl", hash = "sha256:d89c696a773f8bd377d18e5ecda92b7a3793cbe66c87060a6fb58c7b6e1061f7"}, - {file = "pluggy-1.3.0.tar.gz", hash = "sha256:cf61ae8f126ac6f7c451172cf30e3e43d3ca77615509771b3a984a0730651e12"}, + {file = "pluggy-1.4.0-py3-none-any.whl", hash = "sha256:7db9f7b503d67d1c5b95f59773ebb58a8c1c288129a88665838012cfb07b8981"}, + {file = "pluggy-1.4.0.tar.gz", hash = "sha256:8c85c2876142a764e5b7548e7d9a0e0ddb46f5185161049a79b7e974454223be"}, ] [package.extras] @@ -1392,13 +1430,13 @@ windows-terminal = ["colorama (>=0.4.6)"] [[package]] name = "pymdown-extensions" -version = "10.5" +version = "10.7" description = "Extension pack for Python Markdown." optional = false python-versions = ">=3.8" files = [ - {file = "pymdown_extensions-10.5-py3-none-any.whl", hash = "sha256:1f0ca8bb5beff091315f793ee17683bc1390731f6ac4c5eb01e27464b80fe879"}, - {file = "pymdown_extensions-10.5.tar.gz", hash = "sha256:1b60f1e462adbec5a1ed79dac91f666c9c0d241fa294de1989f29d20096cfd0b"}, + {file = "pymdown_extensions-10.7-py3-none-any.whl", hash = "sha256:6ca215bc57bc12bf32b414887a68b810637d039124ed9b2e5bd3325cbb2c050c"}, + {file = "pymdown_extensions-10.7.tar.gz", hash = "sha256:c0d64d5cf62566f59e6b2b690a4095c931107c250a8c8e1351c1de5f6b036deb"}, ] [package.dependencies] @@ -1410,13 +1448,13 @@ extra = ["pygments (>=2.12)"] [[package]] name = "pytest" -version = "7.4.3" +version = "7.4.4" description = "pytest: simple powerful testing with Python" optional = false python-versions = ">=3.7" files = [ - {file = "pytest-7.4.3-py3-none-any.whl", hash = "sha256:0d009c083ea859a71b76adf7c1d502e4bc170b80a8ef002da5806527b9591fac"}, - {file = "pytest-7.4.3.tar.gz", hash = "sha256:d989d136982de4e3b29dabcc838ad581c64e8ed52c11fbe86ddebd9da0818cd5"}, + {file = "pytest-7.4.4-py3-none-any.whl", hash = "sha256:b090cdf5ed60bf4c45261be03239c2c1c22df034fbffe691abe93cd80cea01d8"}, + {file = "pytest-7.4.4.tar.gz", hash = "sha256:2cf0005922c6ace4a3e2ec8b4080eb0d9753fdc93107415332f50ce9e7994280"}, ] [package.dependencies] @@ -1432,17 +1470,17 @@ testing = ["argcomplete", "attrs (>=19.2.0)", "hypothesis (>=3.56)", "mock", "no [[package]] name = "pytest-asyncio" -version = "0.23.2" +version = "0.23.5" description = "Pytest support for asyncio" optional = false python-versions = ">=3.8" files = [ - {file = "pytest-asyncio-0.23.2.tar.gz", hash = "sha256:c16052382554c7b22d48782ab3438d5b10f8cf7a4bdcae7f0f67f097d95beecc"}, - {file = "pytest_asyncio-0.23.2-py3-none-any.whl", hash = "sha256:ea9021364e32d58f0be43b91c6233fb8d2224ccef2398d6837559e587682808f"}, + {file = "pytest-asyncio-0.23.5.tar.gz", hash = "sha256:3a048872a9c4ba14c3e90cc1aa20cbc2def7d01c7c8db3777ec281ba9c057675"}, + {file = "pytest_asyncio-0.23.5-py3-none-any.whl", hash = "sha256:4e7093259ba018d58ede7d5315131d21923a60f8a6e9ee266ce1589685c89eac"}, ] [package.dependencies] -pytest = ">=7.0.0" +pytest = ">=7.0.0,<9" [package.extras] docs = ["sphinx (>=5.3)", "sphinx-rtd-theme (>=1.0)"] @@ -1487,13 +1525,13 @@ textual = ">=0.28.0" [[package]] name = "python-dateutil" -version = "2.8.2" +version = "2.9.0" description = "Extensions to the standard Python datetime module" optional = false python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7" files = [ - {file = "python-dateutil-2.8.2.tar.gz", hash = "sha256:0123cacc1627ae19ddf3c27a5de5bd67ee4586fbdd6440d9748f8abb483d3e86"}, - {file = "python_dateutil-2.8.2-py2.py3-none-any.whl", hash = "sha256:961d03dc3453ebbc59dbdea9e4e11c5651520a876d0f4db161e8674aae935da9"}, + {file = "python-dateutil-2.9.0.tar.gz", hash = "sha256:78e73e19c63f5b20ffa567001531680d939dc042bf7850431877645523c66709"}, + {file = "python_dateutil-2.9.0-py2.py3-none-any.whl", hash = "sha256:cbf2f1da5e6083ac2fbfd4da39a25f34312230110440f424a14c7558bb85d82e"}, ] [package.dependencies] @@ -1522,7 +1560,6 @@ 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"}, @@ -1530,15 +1567,8 @@ 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"}, @@ -1555,7 +1585,6 @@ 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"}, @@ -1563,7 +1592,6 @@ 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"}, @@ -1585,99 +1613,104 @@ pyyaml = "*" [[package]] name = "regex" -version = "2023.10.3" +version = "2023.12.25" description = "Alternative regular expression module, to replace re." optional = false python-versions = ">=3.7" files = [ - {file = "regex-2023.10.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:4c34d4f73ea738223a094d8e0ffd6d2c1a1b4c175da34d6b0de3d8d69bee6bcc"}, - {file = "regex-2023.10.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:a8f4e49fc3ce020f65411432183e6775f24e02dff617281094ba6ab079ef0915"}, - {file = "regex-2023.10.3-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4cd1bccf99d3ef1ab6ba835308ad85be040e6a11b0977ef7ea8c8005f01a3c29"}, - {file = "regex-2023.10.3-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:81dce2ddc9f6e8f543d94b05d56e70d03a0774d32f6cca53e978dc01e4fc75b8"}, - {file = "regex-2023.10.3-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9c6b4d23c04831e3ab61717a707a5d763b300213db49ca680edf8bf13ab5d91b"}, - {file = "regex-2023.10.3-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c15ad0aee158a15e17e0495e1e18741573d04eb6da06d8b84af726cfc1ed02ee"}, - {file = "regex-2023.10.3-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6239d4e2e0b52c8bd38c51b760cd870069f0bdf99700a62cd509d7a031749a55"}, - {file = "regex-2023.10.3-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:4a8bf76e3182797c6b1afa5b822d1d5802ff30284abe4599e1247be4fd6b03be"}, - {file = "regex-2023.10.3-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:d9c727bbcf0065cbb20f39d2b4f932f8fa1631c3e01fcedc979bd4f51fe051c5"}, - {file = "regex-2023.10.3-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:3ccf2716add72f80714b9a63899b67fa711b654be3fcdd34fa391d2d274ce767"}, - {file = "regex-2023.10.3-cp310-cp310-musllinux_1_1_ppc64le.whl", hash = "sha256:107ac60d1bfdc3edb53be75e2a52aff7481b92817cfdddd9b4519ccf0e54a6ff"}, - {file = "regex-2023.10.3-cp310-cp310-musllinux_1_1_s390x.whl", hash = "sha256:00ba3c9818e33f1fa974693fb55d24cdc8ebafcb2e4207680669d8f8d7cca79a"}, - {file = "regex-2023.10.3-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:f0a47efb1dbef13af9c9a54a94a0b814902e547b7f21acb29434504d18f36e3a"}, - {file = "regex-2023.10.3-cp310-cp310-win32.whl", hash = "sha256:36362386b813fa6c9146da6149a001b7bd063dabc4d49522a1f7aa65b725c7ec"}, - {file = "regex-2023.10.3-cp310-cp310-win_amd64.whl", hash = "sha256:c65a3b5330b54103e7d21cac3f6bf3900d46f6d50138d73343d9e5b2900b2353"}, - {file = "regex-2023.10.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:90a79bce019c442604662d17bf69df99090e24cdc6ad95b18b6725c2988a490e"}, - {file = "regex-2023.10.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:c7964c2183c3e6cce3f497e3a9f49d182e969f2dc3aeeadfa18945ff7bdd7051"}, - {file = "regex-2023.10.3-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4ef80829117a8061f974b2fda8ec799717242353bff55f8a29411794d635d964"}, - {file = "regex-2023.10.3-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5addc9d0209a9afca5fc070f93b726bf7003bd63a427f65ef797a931782e7edc"}, - {file = "regex-2023.10.3-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c148bec483cc4b421562b4bcedb8e28a3b84fcc8f0aa4418e10898f3c2c0eb9b"}, - {file = "regex-2023.10.3-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8d1f21af4c1539051049796a0f50aa342f9a27cde57318f2fc41ed50b0dbc4ac"}, - {file = "regex-2023.10.3-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0b9ac09853b2a3e0d0082104036579809679e7715671cfbf89d83c1cb2a30f58"}, - {file = "regex-2023.10.3-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:ebedc192abbc7fd13c5ee800e83a6df252bec691eb2c4bedc9f8b2e2903f5e2a"}, - {file = "regex-2023.10.3-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:d8a993c0a0ffd5f2d3bda23d0cd75e7086736f8f8268de8a82fbc4bd0ac6791e"}, - {file = "regex-2023.10.3-cp311-cp311-musllinux_1_1_ppc64le.whl", hash = "sha256:be6b7b8d42d3090b6c80793524fa66c57ad7ee3fe9722b258aec6d0672543fd0"}, - {file = "regex-2023.10.3-cp311-cp311-musllinux_1_1_s390x.whl", hash = "sha256:4023e2efc35a30e66e938de5aef42b520c20e7eda7bb5fb12c35e5d09a4c43f6"}, - {file = "regex-2023.10.3-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:0d47840dc05e0ba04fe2e26f15126de7c755496d5a8aae4a08bda4dd8d646c54"}, - {file = "regex-2023.10.3-cp311-cp311-win32.whl", hash = "sha256:9145f092b5d1977ec8c0ab46e7b3381b2fd069957b9862a43bd383e5c01d18c2"}, - {file = "regex-2023.10.3-cp311-cp311-win_amd64.whl", hash = "sha256:b6104f9a46bd8743e4f738afef69b153c4b8b592d35ae46db07fc28ae3d5fb7c"}, - {file = "regex-2023.10.3-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:bff507ae210371d4b1fe316d03433ac099f184d570a1a611e541923f78f05037"}, - {file = "regex-2023.10.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:be5e22bbb67924dea15039c3282fa4cc6cdfbe0cbbd1c0515f9223186fc2ec5f"}, - {file = "regex-2023.10.3-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4a992f702c9be9c72fa46f01ca6e18d131906a7180950958f766c2aa294d4b41"}, - {file = "regex-2023.10.3-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7434a61b158be563c1362d9071358f8ab91b8d928728cd2882af060481244c9e"}, - {file = "regex-2023.10.3-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c2169b2dcabf4e608416f7f9468737583ce5f0a6e8677c4efbf795ce81109d7c"}, - {file = "regex-2023.10.3-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a9e908ef5889cda4de038892b9accc36d33d72fb3e12c747e2799a0e806ec841"}, - {file = "regex-2023.10.3-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:12bd4bc2c632742c7ce20db48e0d99afdc05e03f0b4c1af90542e05b809a03d9"}, - {file = "regex-2023.10.3-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:bc72c231f5449d86d6c7d9cc7cd819b6eb30134bb770b8cfdc0765e48ef9c420"}, - {file = "regex-2023.10.3-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:bce8814b076f0ce5766dc87d5a056b0e9437b8e0cd351b9a6c4e1134a7dfbda9"}, - {file = "regex-2023.10.3-cp312-cp312-musllinux_1_1_ppc64le.whl", hash = "sha256:ba7cd6dc4d585ea544c1412019921570ebd8a597fabf475acc4528210d7c4a6f"}, - {file = "regex-2023.10.3-cp312-cp312-musllinux_1_1_s390x.whl", hash = "sha256:b0c7d2f698e83f15228ba41c135501cfe7d5740181d5903e250e47f617eb4292"}, - {file = "regex-2023.10.3-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:5a8f91c64f390ecee09ff793319f30a0f32492e99f5dc1c72bc361f23ccd0a9a"}, - {file = "regex-2023.10.3-cp312-cp312-win32.whl", hash = "sha256:ad08a69728ff3c79866d729b095872afe1e0557251da4abb2c5faff15a91d19a"}, - {file = "regex-2023.10.3-cp312-cp312-win_amd64.whl", hash = "sha256:39cdf8d141d6d44e8d5a12a8569d5a227f645c87df4f92179bd06e2e2705e76b"}, - {file = "regex-2023.10.3-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:4a3ee019a9befe84fa3e917a2dd378807e423d013377a884c1970a3c2792d293"}, - {file = "regex-2023.10.3-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:76066d7ff61ba6bf3cb5efe2428fc82aac91802844c022d849a1f0f53820502d"}, - {file = "regex-2023.10.3-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:bfe50b61bab1b1ec260fa7cd91106fa9fece57e6beba05630afe27c71259c59b"}, - {file = "regex-2023.10.3-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9fd88f373cb71e6b59b7fa597e47e518282455c2734fd4306a05ca219a1991b0"}, - {file = "regex-2023.10.3-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b3ab05a182c7937fb374f7e946f04fb23a0c0699c0450e9fb02ef567412d2fa3"}, - {file = "regex-2023.10.3-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:dac37cf08fcf2094159922edc7a2784cfcc5c70f8354469f79ed085f0328ebdf"}, - {file = "regex-2023.10.3-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:e54ddd0bb8fb626aa1f9ba7b36629564544954fff9669b15da3610c22b9a0991"}, - {file = "regex-2023.10.3-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:3367007ad1951fde612bf65b0dffc8fd681a4ab98ac86957d16491400d661302"}, - {file = "regex-2023.10.3-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:16f8740eb6dbacc7113e3097b0a36065a02e37b47c936b551805d40340fb9971"}, - {file = "regex-2023.10.3-cp37-cp37m-musllinux_1_1_ppc64le.whl", hash = "sha256:f4f2ca6df64cbdd27f27b34f35adb640b5d2d77264228554e68deda54456eb11"}, - {file = "regex-2023.10.3-cp37-cp37m-musllinux_1_1_s390x.whl", hash = "sha256:39807cbcbe406efca2a233884e169d056c35aa7e9f343d4e78665246a332f597"}, - {file = "regex-2023.10.3-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:7eece6fbd3eae4a92d7c748ae825cbc1ee41a89bb1c3db05b5578ed3cfcfd7cb"}, - {file = "regex-2023.10.3-cp37-cp37m-win32.whl", hash = "sha256:ce615c92d90df8373d9e13acddd154152645c0dc060871abf6bd43809673d20a"}, - {file = "regex-2023.10.3-cp37-cp37m-win_amd64.whl", hash = "sha256:0f649fa32fe734c4abdfd4edbb8381c74abf5f34bc0b3271ce687b23729299ed"}, - {file = "regex-2023.10.3-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:9b98b7681a9437262947f41c7fac567c7e1f6eddd94b0483596d320092004533"}, - {file = "regex-2023.10.3-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:91dc1d531f80c862441d7b66c4505cd6ea9d312f01fb2f4654f40c6fdf5cc37a"}, - {file = "regex-2023.10.3-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:82fcc1f1cc3ff1ab8a57ba619b149b907072e750815c5ba63e7aa2e1163384a4"}, - {file = "regex-2023.10.3-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7979b834ec7a33aafae34a90aad9f914c41fd6eaa8474e66953f3f6f7cbd4368"}, - {file = "regex-2023.10.3-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ef71561f82a89af6cfcbee47f0fabfdb6e63788a9258e913955d89fdd96902ab"}, - {file = "regex-2023.10.3-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dd829712de97753367153ed84f2de752b86cd1f7a88b55a3a775eb52eafe8a94"}, - {file = "regex-2023.10.3-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:00e871d83a45eee2f8688d7e6849609c2ca2a04a6d48fba3dff4deef35d14f07"}, - {file = "regex-2023.10.3-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:706e7b739fdd17cb89e1fbf712d9dc21311fc2333f6d435eac2d4ee81985098c"}, - {file = "regex-2023.10.3-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:cc3f1c053b73f20c7ad88b0d1d23be7e7b3901229ce89f5000a8399746a6e039"}, - {file = "regex-2023.10.3-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:6f85739e80d13644b981a88f529d79c5bdf646b460ba190bffcaf6d57b2a9863"}, - {file = "regex-2023.10.3-cp38-cp38-musllinux_1_1_ppc64le.whl", hash = "sha256:741ba2f511cc9626b7561a440f87d658aabb3d6b744a86a3c025f866b4d19e7f"}, - {file = "regex-2023.10.3-cp38-cp38-musllinux_1_1_s390x.whl", hash = "sha256:e77c90ab5997e85901da85131fd36acd0ed2221368199b65f0d11bca44549711"}, - {file = "regex-2023.10.3-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:979c24cbefaf2420c4e377ecd1f165ea08cc3d1fbb44bdc51bccbbf7c66a2cb4"}, - {file = "regex-2023.10.3-cp38-cp38-win32.whl", hash = "sha256:58837f9d221744d4c92d2cf7201c6acd19623b50c643b56992cbd2b745485d3d"}, - {file = "regex-2023.10.3-cp38-cp38-win_amd64.whl", hash = "sha256:c55853684fe08d4897c37dfc5faeff70607a5f1806c8be148f1695be4a63414b"}, - {file = "regex-2023.10.3-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:2c54e23836650bdf2c18222c87f6f840d4943944146ca479858404fedeb9f9af"}, - {file = "regex-2023.10.3-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:69c0771ca5653c7d4b65203cbfc5e66db9375f1078689459fe196fe08b7b4930"}, - {file = "regex-2023.10.3-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6ac965a998e1388e6ff2e9781f499ad1eaa41e962a40d11c7823c9952c77123e"}, - {file = "regex-2023.10.3-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1c0e8fae5b27caa34177bdfa5a960c46ff2f78ee2d45c6db15ae3f64ecadde14"}, - {file = "regex-2023.10.3-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6c56c3d47da04f921b73ff9415fbaa939f684d47293f071aa9cbb13c94afc17d"}, - {file = "regex-2023.10.3-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7ef1e014eed78ab650bef9a6a9cbe50b052c0aebe553fb2881e0453717573f52"}, - {file = "regex-2023.10.3-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d29338556a59423d9ff7b6eb0cb89ead2b0875e08fe522f3e068b955c3e7b59b"}, - {file = "regex-2023.10.3-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:9c6d0ced3c06d0f183b73d3c5920727268d2201aa0fe6d55c60d68c792ff3588"}, - {file = "regex-2023.10.3-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:994645a46c6a740ee8ce8df7911d4aee458d9b1bc5639bc968226763d07f00fa"}, - {file = "regex-2023.10.3-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:66e2fe786ef28da2b28e222c89502b2af984858091675044d93cb50e6f46d7af"}, - {file = "regex-2023.10.3-cp39-cp39-musllinux_1_1_ppc64le.whl", hash = "sha256:11175910f62b2b8c055f2b089e0fedd694fe2be3941b3e2633653bc51064c528"}, - {file = "regex-2023.10.3-cp39-cp39-musllinux_1_1_s390x.whl", hash = "sha256:06e9abc0e4c9ab4779c74ad99c3fc10d3967d03114449acc2c2762ad4472b8ca"}, - {file = "regex-2023.10.3-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:fb02e4257376ae25c6dd95a5aec377f9b18c09be6ebdefa7ad209b9137b73d48"}, - {file = "regex-2023.10.3-cp39-cp39-win32.whl", hash = "sha256:3b2c3502603fab52d7619b882c25a6850b766ebd1b18de3df23b2f939360e1bd"}, - {file = "regex-2023.10.3-cp39-cp39-win_amd64.whl", hash = "sha256:adbccd17dcaff65704c856bd29951c58a1bd4b2b0f8ad6b826dbd543fe740988"}, - {file = "regex-2023.10.3.tar.gz", hash = "sha256:3fef4f844d2290ee0ba57addcec17eec9e3df73f10a2748485dfd6a3a188cc0f"}, + {file = "regex-2023.12.25-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:0694219a1d54336fd0445ea382d49d36882415c0134ee1e8332afd1529f0baa5"}, + {file = "regex-2023.12.25-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:b014333bd0217ad3d54c143de9d4b9a3ca1c5a29a6d0d554952ea071cff0f1f8"}, + {file = "regex-2023.12.25-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:d865984b3f71f6d0af64d0d88f5733521698f6c16f445bb09ce746c92c97c586"}, + {file = "regex-2023.12.25-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1e0eabac536b4cc7f57a5f3d095bfa557860ab912f25965e08fe1545e2ed8b4c"}, + {file = "regex-2023.12.25-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c25a8ad70e716f96e13a637802813f65d8a6760ef48672aa3502f4c24ea8b400"}, + {file = "regex-2023.12.25-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a9b6d73353f777630626f403b0652055ebfe8ff142a44ec2cf18ae470395766e"}, + {file = "regex-2023.12.25-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a9cc99d6946d750eb75827cb53c4371b8b0fe89c733a94b1573c9dd16ea6c9e4"}, + {file = "regex-2023.12.25-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:88d1f7bef20c721359d8675f7d9f8e414ec5003d8f642fdfd8087777ff7f94b5"}, + {file = "regex-2023.12.25-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:cb3fe77aec8f1995611f966d0c656fdce398317f850d0e6e7aebdfe61f40e1cd"}, + {file = "regex-2023.12.25-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:7aa47c2e9ea33a4a2a05f40fcd3ea36d73853a2aae7b4feab6fc85f8bf2c9704"}, + {file = "regex-2023.12.25-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:df26481f0c7a3f8739fecb3e81bc9da3fcfae34d6c094563b9d4670b047312e1"}, + {file = "regex-2023.12.25-cp310-cp310-musllinux_1_1_ppc64le.whl", hash = "sha256:c40281f7d70baf6e0db0c2f7472b31609f5bc2748fe7275ea65a0b4601d9b392"}, + {file = "regex-2023.12.25-cp310-cp310-musllinux_1_1_s390x.whl", hash = "sha256:d94a1db462d5690ebf6ae86d11c5e420042b9898af5dcf278bd97d6bda065423"}, + {file = "regex-2023.12.25-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:ba1b30765a55acf15dce3f364e4928b80858fa8f979ad41f862358939bdd1f2f"}, + {file = "regex-2023.12.25-cp310-cp310-win32.whl", hash = "sha256:150c39f5b964e4d7dba46a7962a088fbc91f06e606f023ce57bb347a3b2d4630"}, + {file = "regex-2023.12.25-cp310-cp310-win_amd64.whl", hash = "sha256:09da66917262d9481c719599116c7dc0c321ffcec4b1f510c4f8a066f8768105"}, + {file = "regex-2023.12.25-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:1b9d811f72210fa9306aeb88385b8f8bcef0dfbf3873410413c00aa94c56c2b6"}, + {file = "regex-2023.12.25-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:d902a43085a308cef32c0d3aea962524b725403fd9373dea18110904003bac97"}, + {file = "regex-2023.12.25-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d166eafc19f4718df38887b2bbe1467a4f74a9830e8605089ea7a30dd4da8887"}, + {file = "regex-2023.12.25-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c7ad32824b7f02bb3c9f80306d405a1d9b7bb89362d68b3c5a9be53836caebdb"}, + {file = "regex-2023.12.25-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:636ba0a77de609d6510235b7f0e77ec494d2657108f777e8765efc060094c98c"}, + {file = "regex-2023.12.25-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0fda75704357805eb953a3ee15a2b240694a9a514548cd49b3c5124b4e2ad01b"}, + {file = "regex-2023.12.25-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f72cbae7f6b01591f90814250e636065850c5926751af02bb48da94dfced7baa"}, + {file = "regex-2023.12.25-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:db2a0b1857f18b11e3b0e54ddfefc96af46b0896fb678c85f63fb8c37518b3e7"}, + {file = "regex-2023.12.25-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:7502534e55c7c36c0978c91ba6f61703faf7ce733715ca48f499d3dbbd7657e0"}, + {file = "regex-2023.12.25-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:e8c7e08bb566de4faaf11984af13f6bcf6a08f327b13631d41d62592681d24fe"}, + {file = "regex-2023.12.25-cp311-cp311-musllinux_1_1_ppc64le.whl", hash = "sha256:283fc8eed679758de38fe493b7d7d84a198b558942b03f017b1f94dda8efae80"}, + {file = "regex-2023.12.25-cp311-cp311-musllinux_1_1_s390x.whl", hash = "sha256:f44dd4d68697559d007462b0a3a1d9acd61d97072b71f6d1968daef26bc744bd"}, + {file = "regex-2023.12.25-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:67d3ccfc590e5e7197750fcb3a2915b416a53e2de847a728cfa60141054123d4"}, + {file = "regex-2023.12.25-cp311-cp311-win32.whl", hash = "sha256:68191f80a9bad283432385961d9efe09d783bcd36ed35a60fb1ff3f1ec2efe87"}, + {file = "regex-2023.12.25-cp311-cp311-win_amd64.whl", hash = "sha256:7d2af3f6b8419661a0c421584cfe8aaec1c0e435ce7e47ee2a97e344b98f794f"}, + {file = "regex-2023.12.25-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:8a0ccf52bb37d1a700375a6b395bff5dd15c50acb745f7db30415bae3c2b0715"}, + {file = "regex-2023.12.25-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:c3c4a78615b7762740531c27cf46e2f388d8d727d0c0c739e72048beb26c8a9d"}, + {file = "regex-2023.12.25-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ad83e7545b4ab69216cef4cc47e344d19622e28aabec61574b20257c65466d6a"}, + {file = "regex-2023.12.25-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b7a635871143661feccce3979e1727c4e094f2bdfd3ec4b90dfd4f16f571a87a"}, + {file = "regex-2023.12.25-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d498eea3f581fbe1b34b59c697512a8baef88212f92e4c7830fcc1499f5b45a5"}, + {file = "regex-2023.12.25-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:43f7cd5754d02a56ae4ebb91b33461dc67be8e3e0153f593c509e21d219c5060"}, + {file = "regex-2023.12.25-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:51f4b32f793812714fd5307222a7f77e739b9bc566dc94a18126aba3b92b98a3"}, + {file = "regex-2023.12.25-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ba99d8077424501b9616b43a2d208095746fb1284fc5ba490139651f971d39d9"}, + {file = "regex-2023.12.25-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:4bfc2b16e3ba8850e0e262467275dd4d62f0d045e0e9eda2bc65078c0110a11f"}, + {file = "regex-2023.12.25-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:8c2c19dae8a3eb0ea45a8448356ed561be843b13cbc34b840922ddf565498c1c"}, + {file = "regex-2023.12.25-cp312-cp312-musllinux_1_1_ppc64le.whl", hash = "sha256:60080bb3d8617d96f0fb7e19796384cc2467447ef1c491694850ebd3670bc457"}, + {file = "regex-2023.12.25-cp312-cp312-musllinux_1_1_s390x.whl", hash = "sha256:b77e27b79448e34c2c51c09836033056a0547aa360c45eeeb67803da7b0eedaf"}, + {file = "regex-2023.12.25-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:518440c991f514331f4850a63560321f833979d145d7d81186dbe2f19e27ae3d"}, + {file = "regex-2023.12.25-cp312-cp312-win32.whl", hash = "sha256:e2610e9406d3b0073636a3a2e80db05a02f0c3169b5632022b4e81c0364bcda5"}, + {file = "regex-2023.12.25-cp312-cp312-win_amd64.whl", hash = "sha256:cc37b9aeebab425f11f27e5e9e6cf580be7206c6582a64467a14dda211abc232"}, + {file = "regex-2023.12.25-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:da695d75ac97cb1cd725adac136d25ca687da4536154cdc2815f576e4da11c69"}, + {file = "regex-2023.12.25-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d126361607b33c4eb7b36debc173bf25d7805847346dd4d99b5499e1fef52bc7"}, + {file = "regex-2023.12.25-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4719bb05094d7d8563a450cf8738d2e1061420f79cfcc1fa7f0a44744c4d8f73"}, + {file = "regex-2023.12.25-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5dd58946bce44b53b06d94aa95560d0b243eb2fe64227cba50017a8d8b3cd3e2"}, + {file = "regex-2023.12.25-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:22a86d9fff2009302c440b9d799ef2fe322416d2d58fc124b926aa89365ec482"}, + {file = "regex-2023.12.25-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2aae8101919e8aa05ecfe6322b278f41ce2994c4a430303c4cd163fef746e04f"}, + {file = "regex-2023.12.25-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:e692296c4cc2873967771345a876bcfc1c547e8dd695c6b89342488b0ea55cd8"}, + {file = "regex-2023.12.25-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:263ef5cc10979837f243950637fffb06e8daed7f1ac1e39d5910fd29929e489a"}, + {file = "regex-2023.12.25-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:d6f7e255e5fa94642a0724e35406e6cb7001c09d476ab5fce002f652b36d0c39"}, + {file = "regex-2023.12.25-cp37-cp37m-musllinux_1_1_ppc64le.whl", hash = "sha256:88ad44e220e22b63b0f8f81f007e8abbb92874d8ced66f32571ef8beb0643b2b"}, + {file = "regex-2023.12.25-cp37-cp37m-musllinux_1_1_s390x.whl", hash = "sha256:3a17d3ede18f9cedcbe23d2daa8a2cd6f59fe2bf082c567e43083bba3fb00347"}, + {file = "regex-2023.12.25-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:d15b274f9e15b1a0b7a45d2ac86d1f634d983ca40d6b886721626c47a400bf39"}, + {file = "regex-2023.12.25-cp37-cp37m-win32.whl", hash = "sha256:ed19b3a05ae0c97dd8f75a5d8f21f7723a8c33bbc555da6bbe1f96c470139d3c"}, + {file = "regex-2023.12.25-cp37-cp37m-win_amd64.whl", hash = "sha256:a6d1047952c0b8104a1d371f88f4ab62e6275567d4458c1e26e9627ad489b445"}, + {file = "regex-2023.12.25-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:b43523d7bc2abd757119dbfb38af91b5735eea45537ec6ec3a5ec3f9562a1c53"}, + {file = "regex-2023.12.25-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:efb2d82f33b2212898f1659fb1c2e9ac30493ac41e4d53123da374c3b5541e64"}, + {file = "regex-2023.12.25-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:b7fca9205b59c1a3d5031f7e64ed627a1074730a51c2a80e97653e3e9fa0d415"}, + {file = "regex-2023.12.25-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:086dd15e9435b393ae06f96ab69ab2d333f5d65cbe65ca5a3ef0ec9564dfe770"}, + {file = "regex-2023.12.25-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e81469f7d01efed9b53740aedd26085f20d49da65f9c1f41e822a33992cb1590"}, + {file = "regex-2023.12.25-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:34e4af5b27232f68042aa40a91c3b9bb4da0eeb31b7632e0091afc4310afe6cb"}, + {file = "regex-2023.12.25-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9852b76ab558e45b20bf1893b59af64a28bd3820b0c2efc80e0a70a4a3ea51c1"}, + {file = "regex-2023.12.25-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ff100b203092af77d1a5a7abe085b3506b7eaaf9abf65b73b7d6905b6cb76988"}, + {file = "regex-2023.12.25-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:cc038b2d8b1470364b1888a98fd22d616fba2b6309c5b5f181ad4483e0017861"}, + {file = "regex-2023.12.25-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:094ba386bb5c01e54e14434d4caabf6583334090865b23ef58e0424a6286d3dc"}, + {file = "regex-2023.12.25-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:5cd05d0f57846d8ba4b71d9c00f6f37d6b97d5e5ef8b3c3840426a475c8f70f4"}, + {file = "regex-2023.12.25-cp38-cp38-musllinux_1_1_ppc64le.whl", hash = "sha256:9aa1a67bbf0f957bbe096375887b2505f5d8ae16bf04488e8b0f334c36e31360"}, + {file = "regex-2023.12.25-cp38-cp38-musllinux_1_1_s390x.whl", hash = "sha256:98a2636994f943b871786c9e82bfe7883ecdaba2ef5df54e1450fa9869d1f756"}, + {file = "regex-2023.12.25-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:37f8e93a81fc5e5bd8db7e10e62dc64261bcd88f8d7e6640aaebe9bc180d9ce2"}, + {file = "regex-2023.12.25-cp38-cp38-win32.whl", hash = "sha256:d78bd484930c1da2b9679290a41cdb25cc127d783768a0369d6b449e72f88beb"}, + {file = "regex-2023.12.25-cp38-cp38-win_amd64.whl", hash = "sha256:b521dcecebc5b978b447f0f69b5b7f3840eac454862270406a39837ffae4e697"}, + {file = "regex-2023.12.25-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:f7bc09bc9c29ebead055bcba136a67378f03d66bf359e87d0f7c759d6d4ffa31"}, + {file = "regex-2023.12.25-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:e14b73607d6231f3cc4622809c196b540a6a44e903bcfad940779c80dffa7be7"}, + {file = "regex-2023.12.25-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:9eda5f7a50141291beda3edd00abc2d4a5b16c29c92daf8d5bd76934150f3edc"}, + {file = "regex-2023.12.25-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cc6bb9aa69aacf0f6032c307da718f61a40cf970849e471254e0e91c56ffca95"}, + {file = "regex-2023.12.25-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:298dc6354d414bc921581be85695d18912bea163a8b23cac9a2562bbcd5088b1"}, + {file = "regex-2023.12.25-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2f4e475a80ecbd15896a976aa0b386c5525d0ed34d5c600b6d3ebac0a67c7ddf"}, + {file = "regex-2023.12.25-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:531ac6cf22b53e0696f8e1d56ce2396311254eb806111ddd3922c9d937151dae"}, + {file = "regex-2023.12.25-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:22f3470f7524b6da61e2020672df2f3063676aff444db1daa283c2ea4ed259d6"}, + {file = "regex-2023.12.25-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:89723d2112697feaa320c9d351e5f5e7b841e83f8b143dba8e2d2b5f04e10923"}, + {file = "regex-2023.12.25-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:0ecf44ddf9171cd7566ef1768047f6e66975788258b1c6c6ca78098b95cf9a3d"}, + {file = "regex-2023.12.25-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:905466ad1702ed4acfd67a902af50b8db1feeb9781436372261808df7a2a7bca"}, + {file = "regex-2023.12.25-cp39-cp39-musllinux_1_1_ppc64le.whl", hash = "sha256:4558410b7a5607a645e9804a3e9dd509af12fb72b9825b13791a37cd417d73a5"}, + {file = "regex-2023.12.25-cp39-cp39-musllinux_1_1_s390x.whl", hash = "sha256:7e316026cc1095f2a3e8cc012822c99f413b702eaa2ca5408a513609488cb62f"}, + {file = "regex-2023.12.25-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:3b1de218d5375cd6ac4b5493e0b9f3df2be331e86520f23382f216c137913d20"}, + {file = "regex-2023.12.25-cp39-cp39-win32.whl", hash = "sha256:11a963f8e25ab5c61348d090bf1b07f1953929c13bd2309a0662e9ff680763c9"}, + {file = "regex-2023.12.25-cp39-cp39-win_amd64.whl", hash = "sha256:e693e233ac92ba83a87024e1d32b5f9ab15ca55ddd916d878146f4e3406b5c91"}, + {file = "regex-2023.12.25.tar.gz", hash = "sha256:29171aa128da69afdf4bde412d5bedc335f2ca8fcfe4489038577d05f16181e5"}, ] [[package]] @@ -1720,13 +1753,13 @@ idna2008 = ["idna"] [[package]] name = "rich" -version = "13.7.0" +version = "13.7.1" description = "Render rich text, tables, progress bars, syntax highlighting, markdown and more to the terminal" optional = false python-versions = ">=3.7.0" files = [ - {file = "rich-13.7.0-py3-none-any.whl", hash = "sha256:6da14c108c4866ee9520bbffa71f6fe3962e193b7da68720583850cd4548e235"}, - {file = "rich-13.7.0.tar.gz", hash = "sha256:5cb5123b5cf9ee70584244246816e9114227e0b98ad9176eede6ad54bf5403fa"}, + {file = "rich-13.7.1-py3-none-any.whl", hash = "sha256:4edbae314f59eb482f54e9e30bf00d33350aaa94f4bfcd4e9e3110e64d0d7222"}, + {file = "rich-13.7.1.tar.gz", hash = "sha256:9be308cb1fe2f1f57d67ce99e95af38a1e2bc71ad9813b0e247cf7ffbcc3a432"}, ] [package.dependencies] @@ -1739,19 +1772,19 @@ jupyter = ["ipywidgets (>=7.5.1,<9)"] [[package]] name = "setuptools" -version = "69.0.2" +version = "69.1.1" description = "Easily download, build, install, upgrade, and uninstall Python packages" optional = false python-versions = ">=3.8" files = [ - {file = "setuptools-69.0.2-py3-none-any.whl", hash = "sha256:1e8fdff6797d3865f37397be788a4e3cba233608e9b509382a2777d25ebde7f2"}, - {file = "setuptools-69.0.2.tar.gz", hash = "sha256:735896e78a4742605974de002ac60562d286fa8051a7e2299445e8e8fbb01aa6"}, + {file = "setuptools-69.1.1-py3-none-any.whl", hash = "sha256:02fa291a0471b3a18b2b2481ed902af520c69e8ae0919c13da936542754b4c56"}, + {file = "setuptools-69.1.1.tar.gz", hash = "sha256:5c0806c7d9af348e6dd3777b4f4dbb42c7ad85b190104837488eab9a7c945cf8"}, ] [package.extras] docs = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "pygments-github-lexers (==0.0.5)", "rst.linker (>=1.9)", "sphinx (<7.2.5)", "sphinx (>=3.5)", "sphinx-favicon", "sphinx-inline-tabs", "sphinx-lint", "sphinx-notfound-page (>=1,<2)", "sphinx-reredirects", "sphinxcontrib-towncrier"] -testing = ["build[virtualenv]", "filelock (>=3.4.0)", "flake8-2020", "ini2toml[lite] (>=0.9)", "jaraco.develop (>=7.21)", "jaraco.envs (>=2.2)", "jaraco.path (>=3.2.0)", "pip (>=19.1)", "pytest (>=6)", "pytest-black (>=0.3.7)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=2.2)", "pytest-mypy (>=0.9.1)", "pytest-perf", "pytest-ruff", "pytest-timeout", "pytest-xdist", "tomli-w (>=1.0.0)", "virtualenv (>=13.0.0)", "wheel"] -testing-integration = ["build[virtualenv] (>=1.0.3)", "filelock (>=3.4.0)", "jaraco.envs (>=2.2)", "jaraco.path (>=3.2.0)", "packaging (>=23.1)", "pytest", "pytest-enabler", "pytest-xdist", "tomli", "virtualenv (>=13.0.0)", "wheel"] +testing = ["build[virtualenv]", "filelock (>=3.4.0)", "flake8-2020", "ini2toml[lite] (>=0.9)", "jaraco.develop (>=7.21)", "jaraco.envs (>=2.2)", "jaraco.path (>=3.2.0)", "packaging (>=23.2)", "pip (>=19.1)", "pytest (>=6)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=2.2)", "pytest-home (>=0.5)", "pytest-mypy (>=0.9.1)", "pytest-perf", "pytest-ruff (>=0.2.1)", "pytest-timeout", "pytest-xdist", "tomli-w (>=1.0.0)", "virtualenv (>=13.0.0)", "wheel"] +testing-integration = ["build[virtualenv] (>=1.0.3)", "filelock (>=3.4.0)", "jaraco.envs (>=2.2)", "jaraco.path (>=3.2.0)", "packaging (>=23.2)", "pytest", "pytest-enabler", "pytest-xdist", "tomli", "virtualenv (>=13.0.0)", "wheel"] [[package]] name = "six" @@ -1777,13 +1810,13 @@ files = [ [[package]] name = "sniffio" -version = "1.3.0" +version = "1.3.1" description = "Sniff out which async library your code is running under" optional = false python-versions = ">=3.7" files = [ - {file = "sniffio-1.3.0-py3-none-any.whl", hash = "sha256:eecefdce1e5bbfb7ad2eeaabf7c1eeb404d7757c379bd1f7e5cce9d8bf425384"}, - {file = "sniffio-1.3.0.tar.gz", hash = "sha256:e60305c5e5d314f5389259b7f22aaa33d8f7dee49763119234af3755c55b9101"}, + {file = "sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2"}, + {file = "sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc"}, ] [[package]] @@ -1803,90 +1836,22 @@ pytest = ">=5.1.0,<8.0.0" [[package]] name = "textual-dev" -version = "1.2.1" +version = "1.5.1" description = "Development tools for working with Textual" optional = false -python-versions = ">=3.7,<4.0" +python-versions = ">=3.8,<4.0" files = [ - {file = "textual_dev-1.2.1-py3-none-any.whl", hash = "sha256:a96ff43841cadf853dd689d68c2fc920a23ad71cfa9a33917ca53e96d1cc81f3"}, - {file = "textual_dev-1.2.1.tar.gz", hash = "sha256:0bda11adfc541e0cc9e49bdf37a8b852281dc2387bb6ff3d01f40c7a3f841684"}, + {file = "textual_dev-1.5.1-py3-none-any.whl", hash = "sha256:bb37dd769ae6b67e1422aa97f6d6ef952e0a6d2aafe08327449e8bdd70474776"}, + {file = "textual_dev-1.5.1.tar.gz", hash = "sha256:e0366ab6f42c128d7daa37a7c418e61fe7aa83731983da990808e4bf2de922a1"}, ] [package.dependencies] aiohttp = ">=3.8.1" click = ">=8.1.2" msgpack = ">=1.0.3" -textual = ">=0.33.0" +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" @@ -1999,69 +1964,70 @@ setuptools = {version = ">=60.0.0", markers = "python_version >= \"3.12\""} [[package]] name = "tree-sitter-languages" -version = "1.8.0" +version = "1.10.2" description = "Binary Python wheels for all tree sitter languages." optional = true python-versions = "*" files = [ - {file = "tree_sitter_languages-1.8.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:a20045f0c7a8394ac0c085c3a7da88438f9e62c6a8b661ebf63c3edb8c3f2bf6"}, - {file = "tree_sitter_languages-1.8.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:4ef80d5896b420d434f7322abbc2c5a5548a37b3821c5486ed0612d2bd760d5a"}, - {file = "tree_sitter_languages-1.8.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:19e7c7100c7b4a364035417e811ab8d43c8ee4e38d0c6ab9cad9c4d8133c0abd"}, - {file = "tree_sitter_languages-1.8.0-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9618bfb5874c43fcb4da43cd71bc24f01f4f94cd55bb9923c4210c7f9e977eb5"}, - {file = "tree_sitter_languages-1.8.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c7b0b606be0c61155bde8e913528b7dc038e8476891f5b198996f780c678ecc0"}, - {file = "tree_sitter_languages-1.8.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:306b49d60afb8c08f95a55e38744687521aa9350a97e9d6d1512db47ea401c51"}, - {file = "tree_sitter_languages-1.8.0-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:b561b979d1dc15a0b2bc35586fe4ccf95049812944042ea5760d8450b63c3fe0"}, - {file = "tree_sitter_languages-1.8.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:2c46c82a5649c41fd4ce7483534fe548a98af6ef6490b5c9f066e2df43e40aa9"}, - {file = "tree_sitter_languages-1.8.0-cp310-cp310-win32.whl", hash = "sha256:4d84b2bf63f8dc51188f83a6dfc7d70365e1c720310c1222f44d0cd2ec76e4d0"}, - {file = "tree_sitter_languages-1.8.0-cp310-cp310-win_amd64.whl", hash = "sha256:c59b81123fa73e7d66d3a8bc0e64af2f2a8fcbbce1b08676d9188ec5edb4fb49"}, - {file = "tree_sitter_languages-1.8.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:06a5816a1e394d717a86b9f5cbb0af08ad92a9badbb4b95678d75052e6bd7402"}, - {file = "tree_sitter_languages-1.8.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:912a12a56361077715b231f1931cf7d472f7d6cfdc76abb806e6b1bdf11d3835"}, - {file = "tree_sitter_languages-1.8.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:33838baa8583b2c9f9df4d672237158dcc9d845782413569b51cc8dfed2fb4de"}, - {file = "tree_sitter_languages-1.8.0-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8b6f148e459e8af180be68e9f9c8f8c4db0db170850482b083fd078fba3f4076"}, - {file = "tree_sitter_languages-1.8.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:96dbdaff9d317d193451bc5b566098717096381d67674f9e65fb8f0ebe98c847"}, - {file = "tree_sitter_languages-1.8.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:8c719535ebdd39f94c26f2182b0d16c45a2996b03b5ad7b78a863178eca1546d"}, - {file = "tree_sitter_languages-1.8.0-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:d5c4cb2f4231135d038155787c96f4ecdf44f63eeee8d9e36b100b96a80a7764"}, - {file = "tree_sitter_languages-1.8.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:524bfa0bcbf0fe8cbb93712336d1de0a3073f08c004bb920270d69c0c3eaaf14"}, - {file = "tree_sitter_languages-1.8.0-cp311-cp311-win32.whl", hash = "sha256:26a0b923c47eeed551e4c307b7badb337564523cca36f9c40e188a308f471c72"}, - {file = "tree_sitter_languages-1.8.0-cp311-cp311-win_amd64.whl", hash = "sha256:3f0ed6297878f9f335f652843e9ab48c561f9a5b312a41a868b5fc127567447b"}, - {file = "tree_sitter_languages-1.8.0-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:0f18d0d98b92bfa40ec15fc4cc5eb5e1f39b9f2f8986cf4cb3e1f8a8e31b06cf"}, - {file = "tree_sitter_languages-1.8.0-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c742b0733be6d057d323252c56b8419fa2e120510baf601f710363971ae99ae7"}, - {file = "tree_sitter_languages-1.8.0-cp36-cp36m-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4417710db978edf6bad1e1e59efba04693919ed45c4115bae7da359354d9d8af"}, - {file = "tree_sitter_languages-1.8.0-cp36-cp36m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a051e1cceddd1126ce0fa0d3faa12873e5b52cafae0893cc82d22b21348fc83c"}, - {file = "tree_sitter_languages-1.8.0-cp36-cp36m-musllinux_1_1_aarch64.whl", hash = "sha256:2665768f7ef6d00ab3847c5a3a5fdd54fbc62a9abf80475bff26dcc7a4e8544f"}, - {file = "tree_sitter_languages-1.8.0-cp36-cp36m-musllinux_1_1_i686.whl", hash = "sha256:76be6fd0d1e514e496eb3430b05ce0efd2f7d09fc3dfe47cc99afc653313c36a"}, - {file = "tree_sitter_languages-1.8.0-cp36-cp36m-musllinux_1_1_x86_64.whl", hash = "sha256:510c5ba5dd3ce502f2963c46cc56ad4a0acd1b776be9b119da03f392bda9f8bf"}, - {file = "tree_sitter_languages-1.8.0-cp36-cp36m-win32.whl", hash = "sha256:f852ff7b77df5c7a3f8b825c31673aee59456a93347b58cfa43fdda81fe1cb63"}, - {file = "tree_sitter_languages-1.8.0-cp36-cp36m-win_amd64.whl", hash = "sha256:53934c8b09650e576ad5724b84c6891d84b69508ad71a78bb2d4dc88b63543fc"}, - {file = "tree_sitter_languages-1.8.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:400ba190fd08cec9412d70efa09e2f1791a0db82a3e9b31f677e145ad2e48a9a"}, - {file = "tree_sitter_languages-1.8.0-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:937b0e8cc07fb6574b475fcaded8dd16fa445c66f40bf449b4e50684fd8c380b"}, - {file = "tree_sitter_languages-1.8.0-cp37-cp37m-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c165c5d13ee335c74a2b6dc6edfcf85045839fa2f7254d2aae3ae9f76020e87d"}, - {file = "tree_sitter_languages-1.8.0-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:124117c6184653cdd381c70a16e5d6a45a41c3f6470d9d756452ea50aa6bb472"}, - {file = "tree_sitter_languages-1.8.0-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:4c12232c93d4c5c8b3b6324850085971fa93c2226842778f07fe3fba9a7683c1"}, - {file = "tree_sitter_languages-1.8.0-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:b9baf99c00366fe2c8e61bf7489d86eaab4c884f669abdb30ba2450cfabb77f7"}, - {file = "tree_sitter_languages-1.8.0-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:f97baf3d574fc44872c1de8c941888c940a0376c8f80a15ec6931d19b4fe2091"}, - {file = "tree_sitter_languages-1.8.0-cp37-cp37m-win32.whl", hash = "sha256:c40267904f734d8a7e9a05ce60f04ea95db59cad183207c4af34e6bc1f5bbd1f"}, - {file = "tree_sitter_languages-1.8.0-cp37-cp37m-win_amd64.whl", hash = "sha256:06b8d11ea550d3c4f0ce0774d6b521c44f2e83d1a77d50f85bea3ed150e66c28"}, - {file = "tree_sitter_languages-1.8.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:9a151d4f2637309f1780b9a0422cdeea3c0a8a6209800f587fe4374ebe13e6a1"}, - {file = "tree_sitter_languages-1.8.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:1a3afb35a316495ff1b848aadeb4f9f7ef6522e9b730a7a35cfe28361398404e"}, - {file = "tree_sitter_languages-1.8.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d22eb91d745b96936c13fc1c100d78e6dcbaa14e9fbe54e180cdc6ca1b262c0f"}, - {file = "tree_sitter_languages-1.8.0-cp38-cp38-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:54a3a83474d3abb44a178aa1f0a5ef73002c014e7e489977fd39624c1ac0a476"}, - {file = "tree_sitter_languages-1.8.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e5a13aa1e6f0fc76268e8fed282fb433ca4b8f6644bb75476a10d28cc19d6cf3"}, - {file = "tree_sitter_languages-1.8.0-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:68872fcea16f7ddbfeec52120b7070e18a820407d16f6b513ec95ede4110df82"}, - {file = "tree_sitter_languages-1.8.0-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:43928c43d8a25204297c43bbaab0c4b567a7e85901a19ef9317a3964ad8eb76e"}, - {file = "tree_sitter_languages-1.8.0-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:cca84cacd5530f23ae5d05e4904c2d42f7479fd80541eda34c27cadbf9611d6b"}, - {file = "tree_sitter_languages-1.8.0-cp38-cp38-win32.whl", hash = "sha256:9d043fdbaf260d0f36f8843acf43096765bed913be71ad705265dccb8e381e1c"}, - {file = "tree_sitter_languages-1.8.0-cp38-cp38-win_amd64.whl", hash = "sha256:f5bbccf1250dc07e74fd86f08a9ed614efd64986a48c142846cd21e84267d46b"}, - {file = "tree_sitter_languages-1.8.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:10046058a4213304e3ba78a52ab88d8d5a2703f5d193e7e976d0a53c2fa12f4b"}, - {file = "tree_sitter_languages-1.8.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:2fc84bb37ca0bb1f45f808a38733f6bb9c2e8fc8a02712fe8658fe3d31ed74e7"}, - {file = "tree_sitter_languages-1.8.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:36b13199282d71d2a841f404f58ccf914b3917b27a99917b0a79b80c93f8a24e"}, - {file = "tree_sitter_languages-1.8.0-cp39-cp39-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a94f5f5ac57591004823385bd7f4cc1b62c7b0b08efc1c39a5e33fb2f8c201bf"}, - {file = "tree_sitter_languages-1.8.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8a796a359bd6fb4f2b67e29f86c9130bd6ae840d75d31d356594f92d5505f43d"}, - {file = "tree_sitter_languages-1.8.0-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:45a6edf0106ff653940fe52fb8a47f8c03d0c5981312ac036888d44102840452"}, - {file = "tree_sitter_languages-1.8.0-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:f077fe6099bb310a247514b68d7103c6dbafef552856fcd225d0867f78b620b7"}, - {file = "tree_sitter_languages-1.8.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:3842ef8d05e3368c227fd5a57f08f636374b4b870070916d08c4aafb99d04cd1"}, - {file = "tree_sitter_languages-1.8.0-cp39-cp39-win32.whl", hash = "sha256:3e9eafc7079114783b5385a769fd190c93525bcae3cf6791fd819c617067394e"}, - {file = "tree_sitter_languages-1.8.0-cp39-cp39-win_amd64.whl", hash = "sha256:9d30b7f48f18a60eea9a0f9494e0f0ea6f560d861770a84c3faab8d7a446fc55"}, + {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] @@ -2080,24 +2046,24 @@ files = [ [[package]] name = "types-tree-sitter" -version = "0.20.1.6" +version = "0.20.1.20240106" description = "Typing stubs for tree-sitter" optional = false -python-versions = ">=3.7" +python-versions = ">=3.8" files = [ - {file = "types-tree-sitter-0.20.1.6.tar.gz", hash = "sha256:310a97916adf73553fd1bda8107884da9b638550ddc76085ae0875c8f520520c"}, - {file = "types_tree_sitter-0.20.1.6-py3-none-any.whl", hash = "sha256:40eae13bc44f4e36d4e97b52db674fe808c6ccb3036a7aed9a736313411fd057"}, + {file = "types-tree-sitter-0.20.1.20240106.tar.gz", hash = "sha256:b0866a74942af5e223ceda9d1665befab9d55e0ccaa6704215efeaa2b02b0ca6"}, + {file = "types_tree_sitter-0.20.1.20240106-py3-none-any.whl", hash = "sha256:3b38b2500d3235f07644bc7148c5c9dbc929fdcb5d10d36709601028c9e0ebe2"}, ] [[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] @@ -2105,35 +2071,35 @@ types-tree-sitter = "*" [[package]] name = "typing-extensions" -version = "4.8.0" +version = "4.10.0" description = "Backported and Experimental Type Hints for Python 3.8+" optional = false python-versions = ">=3.8" files = [ - {file = "typing_extensions-4.8.0-py3-none-any.whl", hash = "sha256:8f92fc8806f9a6b641eaa5318da32b44d401efaac0f6678c9bc448ba3605faa0"}, - {file = "typing_extensions-4.8.0.tar.gz", hash = "sha256:df8e4339e9cb77357558cbdbceca33c303714cf861d1eef15e1070055ae8b7ef"}, + {file = "typing_extensions-4.10.0-py3-none-any.whl", hash = "sha256:69b1a937c3a517342112fb4c6df7e72fc39a38e7891a5730ed4985b5214b5475"}, + {file = "typing_extensions-4.10.0.tar.gz", hash = "sha256:b0abd7c89e8fb96f98db18d86106ff1d90ab692004eb746cf6eda2682f91b3cb"}, ] [[package]] name = "tzdata" -version = "2023.3" +version = "2023.4" description = "Provider of IANA time zone data" optional = false python-versions = ">=2" files = [ - {file = "tzdata-2023.3-py2.py3-none-any.whl", hash = "sha256:7e65763eef3120314099b6939b5546db7adce1e7d6f2e179e3df563c70511eda"}, - {file = "tzdata-2023.3.tar.gz", hash = "sha256:11ef1e08e54acb0d4f95bdb1be05da659673de4acbd21bf9c69e94cc5e907a3a"}, + {file = "tzdata-2023.4-py2.py3-none-any.whl", hash = "sha256:aa3ace4329eeacda5b7beb7ea08ece826c28d761cda36e747cfbf97996d39bf3"}, + {file = "tzdata-2023.4.tar.gz", hash = "sha256:dd54c94f294765522c77399649b4fefd95522479a664a0cec87f41bebc6148c9"}, ] [[package]] name = "uc-micro-py" -version = "1.0.2" +version = "1.0.3" description = "Micro subset of unicode data files for linkify-it-py projects." optional = false python-versions = ">=3.7" files = [ - {file = "uc-micro-py-1.0.2.tar.gz", hash = "sha256:30ae2ac9c49f39ac6dce743bd187fcd2b574b16ca095fa74cd9396795c954c54"}, - {file = "uc_micro_py-1.0.2-py3-none-any.whl", hash = "sha256:8c9110c309db9d9e87302e2f4ad2c3152770930d88ab385cd544e7a7e75f3de0"}, + {file = "uc-micro-py-1.0.3.tar.gz", hash = "sha256:d321b92cff673ec58027c04015fcaa8bb1e005478643ff4a500882eaab88c48a"}, + {file = "uc_micro_py-1.0.3-py3-none-any.whl", hash = "sha256:db1dffff340817673d7b466ec86114a9dc0e9d4d9b5ba229d9d60e5c12600cd5"}, ] [package.extras] @@ -2141,29 +2107,30 @@ test = ["coverage", "pytest", "pytest-cov"] [[package]] name = "urllib3" -version = "2.1.0" +version = "2.2.1" 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.1-py3-none-any.whl", hash = "sha256:450b20ec296a467077128bff42b73080516e71b56ff59a60a02bef2232c4fa9d"}, + {file = "urllib3-2.2.1.tar.gz", hash = "sha256:d0570876c61ab9e520d776c38acbbb5b05a776d3f9ff98a5c8fd5162a444cf19"}, ] [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)"] [[package]] name = "virtualenv" -version = "20.25.0" +version = "20.25.1" description = "Virtual Python Environment builder" optional = false python-versions = ">=3.7" files = [ - {file = "virtualenv-20.25.0-py3-none-any.whl", hash = "sha256:4238949c5ffe6876362d9c0180fc6c3a824a7b12b80604eeb8085f2ed7460de3"}, - {file = "virtualenv-20.25.0.tar.gz", hash = "sha256:bf51c0d9c7dd63ea8e44086fa1e4fb1093a31e963b86959257378aef020e1f1b"}, + {file = "virtualenv-20.25.1-py3-none-any.whl", hash = "sha256:961c026ac520bac5f69acb8ea063e8a4f071bcc9457b9c1f28f6b085c511583a"}, + {file = "virtualenv-20.25.1.tar.gz", hash = "sha256:e08e13ecdca7a0bd53798f356d5831434afa5b07b93f0abdf0797b7a06ffe197"}, ] [package.dependencies] @@ -2177,38 +2144,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] @@ -2338,4 +2307,4 @@ syntax = ["tree-sitter", "tree_sitter_languages"] [metadata] lock-version = "2.0" python-versions = "^3.8" -content-hash = "c4c26f6d0bd1266a7a38b9236c99cf51bf658447a18ffc2c96fb5da442762d6a" +content-hash = "014186a223d4236fb0ace86bef24fb030d6921cf714a8b4d2b889ba066a26375" diff --git a/pyproject.toml b/pyproject.toml index a0ae58265e..5d8ebf16de 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "textual" -version = "0.47.1" +version = "0.53.1" homepage = "https://github.com/Textualize/textual" repository = "https://github.com/Textualize/textual" documentation = "https://textual.textualize.io/" @@ -41,8 +41,8 @@ include = [ [tool.poetry.dependencies] python = "^3.8" -rich = ">=13.3.3" markdown-it-py = { extras = ["plugins", "linkify"], version = ">=2.1.0" } +rich = ">=13.3.3" #rich = {path="../rich", develop=true} typing-extensions = "^4.4.0" tree-sitter = { version = "^0.20.1", optional = true } @@ -53,7 +53,7 @@ syntax = ["tree-sitter", "tree_sitter_languages"] [tool.poetry.group.dev.dependencies] pytest = "^7.1.3" -black = "^23.1.0" +black = "24.1.1" mypy = "^1.0.0" pytest-cov = "^2.12.1" mkdocs = "^1.3.0" @@ -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/questions/README.md b/questions/README.md index f4b1f622d0..2a784461fb 100644 --- a/questions/README.md +++ b/questions/README.md @@ -5,10 +5,11 @@ Your questions should go in this directory. Question files should be named with the extension ".question.md". -To build the FAQ, install [faqtory](https://github.com/willmcgugan/faqtory) if you haven't already: +To build the FAQ, install [faqtory](https://github.com/willmcgugan/faqtory) if you haven't already. +Faqtory is best installed via [pipx](https://github.com/pypa/pipx) to avoid any dependency conflicts: ``` -pip install faqtory +pipx install faqtory ``` Then run the following from the top of the repository: diff --git a/src/textual/__init__.py b/src/textual/__init__.py index 0b93010125..64fa99601b 100644 --- a/src/textual/__init__.py +++ b/src/textual/__init__.py @@ -27,13 +27,21 @@ LogCallable: TypeAlias = "Callable" -def __getattr__(name: str) -> str: - """Lazily get the version.""" - if name == "__version__": - from importlib.metadata import version +if TYPE_CHECKING: + + from importlib.metadata import version + + __version__ = version("textual") + +else: + + def __getattr__(name: str) -> str: + """Lazily get the version.""" + if name == "__version__": + from importlib.metadata import version - return version("textual") - raise AttributeError(f"module {__name__!r} has no attribute {name!r}") + return version("textual") + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") class LoggerError(Exception): diff --git a/src/textual/_animator.py b/src/textual/_animator.py index 3561b1ba58..27d6c96ce5 100644 --- a/src/textual/_animator.py +++ b/src/textual/_animator.py @@ -11,7 +11,7 @@ from . import _time from ._callback import invoke from ._easing import DEFAULT_EASING, EASING -from ._types import CallbackType +from ._types import AnimationLevel, CallbackType from .timer import Timer if TYPE_CHECKING: @@ -53,7 +53,11 @@ class Animation(ABC): """Callback to run after animation completes""" @abstractmethod - def __call__(self, time: float) -> bool: # pragma: no cover + def __call__( + self, + time: float, + app_animation_level: AnimationLevel = "full", + ) -> bool: # pragma: no cover """Call the animation, return a boolean indicating whether animation is in-progress or complete. Args: @@ -93,9 +97,18 @@ class SimpleAnimation(Animation): final_value: object easing: EasingFunction on_complete: CallbackType | None = None + level: AnimationLevel = "full" + """Minimum level required for the animation to take place (inclusive).""" - def __call__(self, time: float) -> bool: - if self.duration == 0: + def __call__( + self, time: float, app_animation_level: AnimationLevel = "full" + ) -> bool: + if ( + self.duration == 0 + or app_animation_level == "none" + or app_animation_level == "basic" + and self.level == "full" + ): setattr(self.obj, self.attribute, self.final_value) return True @@ -170,6 +183,7 @@ def __call__( delay: float = 0.0, easing: EasingFunction | str = DEFAULT_EASING, on_complete: CallbackType | None = None, + level: AnimationLevel = "full", ) -> None: """Animate an attribute. @@ -182,6 +196,7 @@ def __call__( delay: A delay (in seconds) before the animation starts. easing: An easing method. on_complete: A callable to invoke when the animation is finished. + level: Minimum level required for the animation to take place (inclusive). """ start_value = getattr(self._obj, attribute) if isinstance(value, str) and hasattr(start_value, "parse"): @@ -200,6 +215,7 @@ def __call__( delay=delay, easing=easing_function, on_complete=on_complete, + level=level, ) @@ -284,6 +300,7 @@ def animate( easing: EasingFunction | str = DEFAULT_EASING, delay: float = 0.0, on_complete: CallbackType | None = None, + level: AnimationLevel = "full", ) -> None: """Animate an attribute to a new value. @@ -297,6 +314,7 @@ def animate( easing: An easing function. delay: Number of seconds to delay the start of the animation by. on_complete: Callback to run after the animation completes. + level: Minimum level required for the animation to take place (inclusive). """ animate_callback = partial( self._animate, @@ -308,6 +326,7 @@ def animate( speed=speed, easing=easing, on_complete=on_complete, + level=level, ) if delay: self._complete_event.clear() @@ -328,7 +347,8 @@ def _animate( speed: float | None = None, easing: EasingFunction | str = DEFAULT_EASING, on_complete: CallbackType | None = None, - ): + level: AnimationLevel = "full", + ) -> None: """Animate an attribute to a new value. Args: @@ -340,6 +360,7 @@ def _animate( speed: The speed of the animation. easing: An easing function. on_complete: Callback to run after the animation completes. + level: Minimum level required for the animation to take place (inclusive). """ if not hasattr(obj, attribute): raise AttributeError( @@ -373,6 +394,7 @@ def _animate( speed=speed, easing=easing_function, on_complete=on_complete, + level=level, ) if animation is None: @@ -409,7 +431,12 @@ def _animate( end_value=value, final_value=final_value, easing=easing_function, - on_complete=on_complete, + on_complete=( + partial(self.app.call_later, on_complete) + if on_complete is not None + else None + ), + level=level, ) assert animation is not None, "animation expected to be non-None" @@ -483,21 +510,50 @@ async def stop_animation( elif key in self._animations: await self._stop_running_animation(key, complete) - async def __call__(self) -> None: + def force_stop_animation(self, obj: object, attribute: str) -> None: + """Force stop an animation on an attribute. This will immediately stop the animation, + without running any associated callbacks, setting the attribute to its final value. + + Args: + obj: The object containing the attribute. + attribute: The name of the attribute. + + Note: + If there is no animation scheduled or running, this is a no-op. + """ + from .css.scalar_animation import ScalarAnimation + + animation_key = (id(obj), attribute) + try: + animation = self._animations.pop(animation_key) + except KeyError: + return + + if isinstance(animation, SimpleAnimation): + setattr(obj, attribute, animation.end_value) + elif isinstance(animation, ScalarAnimation): + setattr(obj, attribute, animation.final_value) + + if animation.on_complete is not None: + animation.on_complete() + + def __call__(self) -> None: if not self._animations: self._timer.pause() self._idle_event.set() if not self._scheduled: self._complete_event.set() else: + app_animation_level = self.app.animation_level animation_time = self._get_time() animation_keys = list(self._animations.keys()) for animation_key in animation_keys: animation = self._animations[animation_key] - animation_complete = animation(animation_time) + animation_complete = animation(animation_time, app_animation_level) if animation_complete: del self._animations[animation_key] - await animation.invoke_callback() + if animation.on_complete is not None: + animation.on_complete() def _get_time(self) -> float: """Get the current wall clock time, via the internal Timer. @@ -506,7 +562,7 @@ def _get_time(self) -> float: The wall clock time. """ # N.B. We could remove this method and always call `self._timer.get_time()` internally, - # but it's handy to have in mocking situations + # but it's handy to have in mocking situations. return _time.get_time() async def wait_for_idle(self) -> None: diff --git a/src/textual/_ansi_theme.py b/src/textual/_ansi_theme.py new file mode 100644 index 0000000000..f53f40ca78 --- /dev/null +++ b/src/textual/_ansi_theme.py @@ -0,0 +1,53 @@ +from rich.terminal_theme import TerminalTheme + +MONOKAI = TerminalTheme( + (12, 12, 12), + (217, 217, 217), + [ + (26, 26, 26), + (244, 0, 95), + (152, 224, 36), + (253, 151, 31), + (157, 101, 255), + (244, 0, 95), + (88, 209, 235), + (196, 197, 181), + (98, 94, 76), + ], + [ + (244, 0, 95), + (152, 224, 36), + (224, 213, 97), + (157, 101, 255), + (244, 0, 95), + (88, 209, 235), + (246, 246, 239), + ], +) + +ALABASTER = TerminalTheme( + (247, 247, 247), + (0, 0, 0), + [ + (0, 0, 0), + (170, 55, 49), + (68, 140, 39), + (203, 144, 0), + (50, 92, 192), + (122, 62, 157), + (0, 131, 178), + (247, 247, 247), + (119, 119, 119), + ], + [ + (240, 80, 80), + (96, 203, 0), + (255, 188, 93), + (0, 122, 204), + (230, 76, 230), + (0, 170, 203), + (247, 247, 247), + ], +) + +DEFAULT_TERMINAL_THEME = MONOKAI diff --git a/src/textual/_arrange.py b/src/textual/_arrange.py index 87879573af..97d8abbe4a 100644 --- a/src/textual/_arrange.py +++ b/src/textual/_arrange.py @@ -160,5 +160,4 @@ def _arrange_dock_widgets( _WidgetPlacement(dock_region, null_spacing, dock_widget, top_z, True) ) dock_spacing = Spacing(top, right, bottom, left) - return (placements, dock_spacing) diff --git a/src/textual/_context.py b/src/textual/_context.py index 33d8369d49..b1b20b4d29 100644 --- a/src/textual/_context.py +++ b/src/textual/_context.py @@ -14,12 +14,14 @@ class NoActiveAppError(RuntimeError): """Runtime error raised if we try to retrieve the active app when there is none.""" -active_app: ContextVar["App"] = ContextVar("active_app") +active_app: ContextVar["App[object]"] = ContextVar("active_app") active_message_pump: ContextVar["MessagePump"] = ContextVar("active_message_pump") prevent_message_types_stack: ContextVar[list[set[type[Message]]]] = ContextVar( "prevent_message_types_stack" ) -visible_screen_stack: ContextVar[list[Screen]] = ContextVar("visible_screen_stack") +visible_screen_stack: ContextVar[list[Screen[object]]] = ContextVar( + "visible_screen_stack" +) """A stack of visible screens (with background alpha < 1), used in the screen render process.""" message_hook: ContextVar[Callable[[Message], None]] = ContextVar("message_hook") """A callable that accepts a message. Used by App.run_test.""" diff --git a/src/textual/_immutable_sequence_view.py b/src/textual/_immutable_sequence_view.py index b9c34f0f03..2f01ddd2d5 100644 --- a/src/textual/_immutable_sequence_view.py +++ b/src/textual/_immutable_sequence_view.py @@ -20,12 +20,10 @@ def __init__(self, wrap: Sequence[T]) -> None: self._wrap = wrap @overload - def __getitem__(self, index: int) -> T: - ... + def __getitem__(self, index: int) -> T: ... @overload - def __getitem__(self, index: slice) -> ImmutableSequenceView[T]: - ... + def __getitem__(self, index: slice) -> ImmutableSequenceView[T]: ... def __getitem__(self, index: int | slice) -> T | ImmutableSequenceView[T]: return ( diff --git a/src/textual/_layout.py b/src/textual/_layout.py index c147a0e419..8048a0f303 100644 --- a/src/textual/_layout.py +++ b/src/textual/_layout.py @@ -160,8 +160,7 @@ def get_content_width(self, widget: Widget, container: Size, viewport: Size) -> if not widget._nodes: width = 0 else: - # Use a size of 0, 0 to ignore relative sizes, since those are flexible anyway - arrangement = widget._arrange(Size(0, 0)) + arrangement = widget._arrange(Size(container.width, 0)) return arrangement.total_region.right return width diff --git a/src/textual/_node_list.py b/src/textual/_node_list.py index 695276f998..ac9b425f32 100644 --- a/src/textual/_node_list.py +++ b/src/textual/_node_list.py @@ -1,11 +1,14 @@ from __future__ import annotations import sys -from typing import TYPE_CHECKING, Any, Iterator, Sequence, overload +from operator import attrgetter +from typing import TYPE_CHECKING, Any, Callable, Iterator, Sequence, overload import rich.repr if TYPE_CHECKING: + from _typeshed import SupportsRichComparison + from .widget import Widget @@ -49,6 +52,25 @@ def __len__(self) -> int: def __contains__(self, widget: object) -> bool: return widget in self._nodes + def _sort( + self, + *, + key: Callable[[Widget], SupportsRichComparison] | None = None, + reverse: bool = False, + ): + """Sort nodes. + + Args: + key: A key function which accepts a widget, or `None` for no key function. + reverse: Sort in descending order. + """ + if key is None: + self._nodes.sort(key=attrgetter("sort_order"), reverse=reverse) + else: + self._nodes.sort(key=key, reverse=reverse) + + self._updates += 1 + def index(self, widget: Any, start: int = 0, stop: int = sys.maxsize) -> int: """Return the index of the given widget. @@ -136,12 +158,10 @@ def __reversed__(self) -> Iterator[Widget]: return reversed(self._nodes) @overload - def __getitem__(self, index: int) -> Widget: - ... + def __getitem__(self, index: int) -> Widget: ... @overload - def __getitem__(self, index: slice) -> list[Widget]: - ... + def __getitem__(self, index: slice) -> list[Widget]: ... def __getitem__(self, index: int | slice) -> Widget | list[Widget]: return self._nodes[index] diff --git a/src/textual/_styles_cache.py b/src/textual/_styles_cache.py index adea09045b..674b2a52bf 100644 --- a/src/textual/_styles_cache.py +++ b/src/textual/_styles_cache.py @@ -10,7 +10,9 @@ from rich.text import Text from . import log +from ._ansi_theme import DEFAULT_TERMINAL_THEME from ._border import get_box, render_border_label, render_row +from ._context import active_app from ._opacity import _apply_opacity from ._segment_tools import line_pad, line_trim from .color import Color @@ -318,8 +320,14 @@ def post(segments: Iterable[Segment]) -> Iterable[Segment]: Returns: New list of segments """ + try: + app = active_app.get() + ansi_theme = app.ansi_theme + except LookupError: + ansi_theme = DEFAULT_TERMINAL_THEME + if styles.tint.a: - segments = Tint.process_segments(segments, styles.tint) + segments = Tint.process_segments(segments, styles.tint, ansi_theme) if opacity != 1.0: segments = _apply_opacity(segments, base_background, opacity) return segments @@ -347,9 +355,11 @@ def post(segments: Iterable[Segment]) -> Iterable[Segment]: if label_color.a else None ), - (base_label_background + label_background).rich_color - if label_background.a - else None, + ( + (base_label_background + label_background).rich_color + if label_background.a + else None + ), ) render_label = (label, style) # Try to save time with expensive call to `render_border_label`: @@ -383,7 +393,7 @@ def post(segments: Iterable[Segment]) -> Iterable[Segment]: has_left, has_right, label_segments, - label_alignment, + label_alignment, # type: ignore ) # Draw padding (B) 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 33845e4494..5a435b3287 100644 --- a/src/textual/_text_area_theme.py +++ b/src/textual/_text_area_theme.py @@ -1,6 +1,7 @@ from __future__ import annotations -from dataclasses import dataclass, field +from dataclasses import dataclass, field, fields +from typing import TYPE_CHECKING from rich.style import Style @@ -8,6 +9,9 @@ from textual.color import Color from textual.design import DEFAULT_DARK_SURFACE +if TYPE_CHECKING: + from textual.widgets import TextArea + @dataclass class TextAreaTheme: @@ -63,10 +67,27 @@ class TextAreaTheme: syntax_styles: dict[str, Style] = field(default_factory=dict) """The mapping of tree-sitter names from the `highlight_query` to Rich styles.""" + _theme_configured_attributes: set[str] = field(init=False, default_factory=set) + """Records which attributes were set via the theme object (as opposed to CSS components).""" + def __post_init__(self) -> None: - """Generate some styles if they haven't been supplied.""" - if self.base_style is None: - self.base_style = Style() + theme_fields = fields(self) + for field in theme_fields: + if getattr(self, field.name) is not None: + self._theme_configured_attributes.add(field.name) + + def apply_css(self, text_area: TextArea) -> None: + """Apply CSS rules from a TextArea to be used for fallback styling. + + If any attributes in the theme aren't supplied, they'll be filled with the appropriate + base CSS (e.g. color, background, etc.) and component CSS (e.g. text-area--cursor) from + the supplied TextArea. + + Args: + text_area: The TextArea instance to retrieve fallback styling from. + """ + self.base_style = text_area.rich_style or Style() + get_style = text_area.get_component_rich_style if self.base_style.color is None: self.base_style = Style(color="#f3f3f3", bgcolor=self.base_style.bgcolor) @@ -76,38 +97,62 @@ def __post_init__(self) -> None: color=self.base_style.color, bgcolor=DEFAULT_DARK_SURFACE ) + configured = self._theme_configured_attributes.__contains__ + assert self.base_style is not None assert self.base_style.color is not None assert self.base_style.bgcolor is not None - if self.gutter_style is None: - self.gutter_style = self.base_style.copy() + if not configured("gutter_style"): + gutter_style = get_style("text-area--gutter") + if gutter_style: + self.gutter_style = gutter_style + else: + self.gutter_style = self.base_style.copy() background_color = Color.from_rich_color(self.base_style.bgcolor) - if self.cursor_style is None: - self.cursor_style = Style( - color=background_color.rich_color, - bgcolor=background_color.inverse.rich_color, - ) - - 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: - bracket_matching_background = background_color.blend( - background_color.inverse, factor=0.05 - ) - self.bracket_matching_style = Style( - bgcolor=bracket_matching_background.rich_color - ) - - if self.selection_style is None: - selection_background_color = background_color.blend( - DEFAULT_COLORS["dark"].primary, factor=0.75 - ) - self.selection_style = Style.from_color( - bgcolor=selection_background_color.rich_color - ) + if not configured("cursor_style"): + # If the theme doesn't contain a cursor style, fallback to component styles. + cursor_style = get_style("text-area--cursor") + if cursor_style: + self.cursor_style = cursor_style + else: + # There's no component style either, fallback to a default. + self.cursor_style = Style.from_color( + color=background_color.rich_color, + bgcolor=background_color.inverse.rich_color, + ) + + # Apply fallbacks for the styles of the active line and active line gutter. + if not configured("cursor_line_style"): + self.cursor_line_style = get_style("text-area--cursor-line") + + if not configured("cursor_line_gutter_style"): + self.cursor_line_gutter_style = get_style("text-area--cursor-gutter") + + if not configured("bracket_matching_style"): + matching_bracket_style = get_style("text-area--matching-bracket") + if matching_bracket_style: + self.bracket_matching_style = matching_bracket_style + else: + bracket_matching_background = background_color.blend( + background_color.inverse, factor=0.05 + ) + self.bracket_matching_style = Style( + bgcolor=bracket_matching_background.rich_color + ) + + if not configured("selection_style"): + selection_style = get_style("text-area--selection") + if selection_style: + self.selection_style = selection_style + else: + selection_background_color = background_color.blend( + DEFAULT_COLORS["dark"].primary, factor=0.75 + ) + self.selection_style = Style.from_color( + bgcolor=selection_background_color.rich_color + ) @classmethod def get_builtin_theme(cls, theme_name: str) -> TextAreaTheme | None: @@ -145,15 +190,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", @@ -342,12 +378,12 @@ def default(cls) -> TextAreaTheme: }, ) +_CSS_THEME = TextAreaTheme(name="css", syntax_styles=_DARK_VS.syntax_styles) + _BUILTIN_THEMES = { + "css": _CSS_THEME, "monokai": _MONOKAI, "dracula": _DRACULA, "vscode_dark": _DARK_VS, "github_light": _GITHUB_LIGHT, } - -DEFAULT_THEME = TextAreaTheme.get_builtin_theme("monokai") -"""The default TextAreaTheme used by Textual.""" diff --git a/src/textual/_two_way_dict.py b/src/textual/_two_way_dict.py index 123e1848d8..ac0ffe16ef 100644 --- a/src/textual/_two_way_dict.py +++ b/src/textual/_two_way_dict.py @@ -32,7 +32,7 @@ def __delitem__(self, key: Key) -> None: def __iter__(self): return iter(self._forward) - def get(self, key: Key) -> Value: + def get(self, key: Key) -> Value | None: """Given a key, efficiently lookup and return the associated value. Args: @@ -43,7 +43,7 @@ def get(self, key: Key) -> Value: """ return self._forward.get(key) - def get_key(self, value: Value) -> Key: + def get_key(self, value: Value) -> Key | None: """Given a value, efficiently lookup and return the associated key. Args: diff --git a/src/textual/_types.py b/src/textual/_types.py index b1ad7972f3..603d799f05 100644 --- a/src/textual/_types.py +++ b/src/textual/_types.py @@ -1,4 +1,4 @@ -from typing import TYPE_CHECKING, Any, Awaitable, Callable, List, Union +from typing import TYPE_CHECKING, Any, Awaitable, Callable, List, Literal, Union from typing_extensions import Protocol @@ -11,19 +11,15 @@ class MessageTarget(Protocol): """Protocol that must be followed by objects that can receive messages.""" - async def _post_message(self, message: "Message") -> bool: - ... + async def _post_message(self, message: "Message") -> bool: ... - def post_message(self, message: "Message") -> bool: - ... + def post_message(self, message: "Message") -> bool: ... class EventTarget(Protocol): - async def _post_message(self, message: "Message") -> bool: - ... + async def _post_message(self, message: "Message") -> bool: ... - def post_message(self, message: "Message") -> bool: - ... + def post_message(self, message: "Message") -> bool: ... class UnusedParameter: @@ -35,12 +31,27 @@ 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"] +"""The levels that the [`TEXTUAL_ANIMATIONS`][textual.constants.TEXTUAL_ANIMATIONS] env var can be set to.""" diff --git a/src/textual/_widget_navigation.py b/src/textual/_widget_navigation.py new file mode 100644 index 0000000000..c547d01be0 --- /dev/null +++ b/src/textual/_widget_navigation.py @@ -0,0 +1,191 @@ +""" +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 Literal, Protocol, Sequence + +from typing_extensions import TypeAlias + + +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. +""" + + +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[Disableable], +) -> 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[Disableable]) -> 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[Disableable], + 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[Disableable], + 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/_work_decorator.py b/src/textual/_work_decorator.py index cf4bc624df..71bd67ac1e 100644 --- a/src/textual/_work_decorator.py +++ b/src/textual/_work_decorator.py @@ -2,7 +2,6 @@ A decorator used to create [workers](/guide/workers). """ - from __future__ import annotations from functools import partial, wraps @@ -44,8 +43,7 @@ def work( exclusive: bool = False, description: str | None = None, thread: bool = False, -) -> Callable[FactoryParamSpec, "Worker[ReturnType]"]: - ... +) -> Callable[FactoryParamSpec, "Worker[ReturnType]"]: ... @overload @@ -58,8 +56,7 @@ def work( exclusive: bool = False, description: str | None = None, thread: bool = False, -) -> Callable[FactoryParamSpec, "Worker[ReturnType]"]: - ... +) -> Callable[FactoryParamSpec, "Worker[ReturnType]"]: ... @overload @@ -71,14 +68,15 @@ def work( exclusive: bool = False, description: str | None = None, thread: bool = False, -) -> Decorator[..., ReturnType]: - ... +) -> Decorator[..., ReturnType]: ... def work( - method: Callable[FactoryParamSpec, ReturnType] - | Callable[FactoryParamSpec, Coroutine[None, None, ReturnType]] - | None = None, + method: ( + Callable[FactoryParamSpec, ReturnType] + | Callable[FactoryParamSpec, Coroutine[None, None, ReturnType]] + | None + ) = None, *, name: str = "", group: str = "default", diff --git a/src/textual/_worker_manager.py b/src/textual/_worker_manager.py index 4515f02d4c..ab69947718 100644 --- a/src/textual/_worker_manager.py +++ b/src/textual/_worker_manager.py @@ -4,7 +4,6 @@ You access this object via [App.workers][textual.app.App.workers] or [Widget.workers][textual.dom.DOMNode.workers]. """ - from __future__ import annotations import asyncio diff --git a/src/textual/_wrap.py b/src/textual/_wrap.py new file mode 100644 index 0000000000..343532794b --- /dev/null +++ b/src/textual/_wrap.py @@ -0,0 +1,147 @@ +from __future__ import annotations + +import re +from typing import Iterable + +from rich.cells import get_character_cell_size + +from ._cells import cell_len +from ._loop import loop_last +from .expand_tabs import get_tab_widths + +re_chunk = re.compile(r"\S+\s*|\s+") + + +def chunks(text: str) -> Iterable[tuple[int, int, str]]: + """Yields each "chunk" from the text as a tuple containing (start_index, end_index, chunk_content). + A "chunk" in this context refers to a word and any whitespace around it. + + Args: + text: The text to split into chunks. + + Returns: + Yields tuples containing the start, end and content for each chunk. + """ + end = 0 + while (chunk_match := re_chunk.match(text, end)) is not None: + start, end = chunk_match.span() + chunk = chunk_match.group(0) + yield start, end, chunk + + +def compute_wrap_offsets( + text: str, + width: int, + tab_size: int, + fold: bool = True, + precomputed_tab_sections: list[tuple[str, int]] | None = None, +) -> list[int]: + """Given a string of text, and a width (measured in cells), return a list + of codepoint indices which the string should be split at in order for it to fit + within the given width. + + Args: + text: The text to examine. + width: The available cell width. + tab_size: The tab stop width. + fold: If True, words longer than `width` will be folded onto a new line. + precomputed_tab_sections: The output of `get_tab_widths` can be passed here directly, + to prevent us from having to recompute the value. + + Returns: + A list of indices to break the line at. + """ + tab_size = min(tab_size, width) + if precomputed_tab_sections: + tab_sections = precomputed_tab_sections + else: + tab_sections = get_tab_widths(text, tab_size) + + break_positions: list[int] = [] # offsets to insert the breaks at + append = break_positions.append + cell_offset = 0 + _cell_len = cell_len + + tab_section_index = 0 + cumulative_width = 0 + cumulative_widths: list[int] = [] # prefix sum of tab widths for each codepoint + record_widths = cumulative_widths.extend + + for last, (tab_section, tab_width) in loop_last(tab_sections): + # add 1 since the \t character is stripped by get_tab_widths + section_codepoint_length = len(tab_section) + int(bool(tab_width)) + widths = [cumulative_width] * section_codepoint_length + record_widths(widths) + cumulative_width += tab_width + if last: + cumulative_widths.append(cumulative_width) + + for start, end, chunk in chunks(text): + chunk_width = _cell_len(chunk) # this cell len excludes tabs completely + tab_width_before_start = cumulative_widths[start] + tab_width_before_end = cumulative_widths[end] + chunk_tab_width = tab_width_before_end - tab_width_before_start + chunk_width += chunk_tab_width + remaining_space = width - cell_offset + chunk_fits = remaining_space >= chunk_width + + if chunk_fits: + # Simplest case - the word fits within the remaining width for this line. + cell_offset += chunk_width + else: + # Not enough space remaining for this word on the current line. + if chunk_width > width: + # The word doesn't fit on any line, so we must fold it + if fold: + _get_character_cell_size = get_character_cell_size + lines: list[list[str]] = [[]] + + append_new_line = lines.append + append_to_last_line = lines[-1].append + + total_width = 0 + for character in chunk: + if character == "\t": + # Tab characters have dynamic width, so look it up + cell_width = tab_sections[tab_section_index][1] + tab_section_index += 1 + else: + cell_width = _get_character_cell_size(character) + + if total_width + cell_width > width: + append_new_line([character]) + append_to_last_line = lines[-1].append + total_width = cell_width + else: + append_to_last_line(character) + total_width += cell_width + + folded_word = ["".join(line) for line in lines] + for last, line in loop_last(folded_word): + if start: + append(start) + if last: + # Since cell_len ignores tabs, we need to check the width + # of the tabs in this line. The width of tabs within the + # line is computed by taking the difference between the + # cumulative width of tabs up to the end of the line and the + # cumulative width of tabs up to the start of the line. + line_tab_widths = ( + cumulative_widths[start + len(line)] + - cumulative_widths[start] + ) + cell_offset = _cell_len(line) + line_tab_widths + else: + start += len(line) + else: + # Folding isn't allowed, so crop the word. + if start: + append(start) + cell_offset = chunk_width + elif cell_offset and start: + # The word doesn't fit within the remaining space on the current + # line, but it *can* fit on to the next (empty) line. + append(start) + cell_offset = chunk_width + + return break_positions diff --git a/src/textual/_xterm_parser.py b/src/textual/_xterm_parser.py index 0a2ba5b045..78a4fce4ac 100644 --- a/src/textual/_xterm_parser.py +++ b/src/textual/_xterm_parser.py @@ -4,6 +4,8 @@ import unicodedata from typing import Any, Callable, Generator, Iterable +from typing_extensions import Final + from . import events, messages from ._ansi_sequences import ANSI_SEQUENCES_KEYS, IGNORE_SEQUENCE from ._parser import Awaitable, Parser, TokenCallback @@ -18,8 +20,15 @@ _re_terminal_mode_response = re.compile( "^" + re.escape("\x1b[") + r"\?(?P\d+);(?P\d)\$y" ) -_re_bracketed_paste_start = re.compile(r"^\x1b\[200~$") -_re_bracketed_paste_end = re.compile(r"^\x1b\[201~$") + +BRACKETED_PASTE_START: Final[str] = "\x1b[200~" +"""Sequence received when a bracketed paste event starts.""" +BRACKETED_PASTE_END: Final[str] = "\x1b[201~" +"""Sequence received when a bracketed paste event ends.""" +FOCUSIN: Final[str] = "\x1b[I" +"""Sequence received when the terminal receives focus.""" +FOCUSOUT: Final[str] = "\x1b[O" +"""Sequence received when focus is lost from the terminal.""" class XTermParser(Parser[events.Event]): @@ -202,15 +211,19 @@ def reissue_sequence_as_keys(reissue_sequence: str) -> None: self.debug_log(f"sequence={sequence!r}") - bracketed_paste_start_match = _re_bracketed_paste_start.match( - sequence - ) - if bracketed_paste_start_match is not None: + if sequence == FOCUSIN: + on_token(events.AppFocus()) + break + + if sequence == FOCUSOUT: + on_token(events.AppBlur()) + break + + if sequence == BRACKETED_PASTE_START: bracketed_paste = True break - bracketed_paste_end_match = _re_bracketed_paste_end.match(sequence) - if bracketed_paste_end_match is not None: + if sequence == BRACKETED_PASTE_END: bracketed_paste = False break diff --git a/src/textual/app.py b/src/textual/app.py index 4f679c4787..aaf799a66c 100644 --- a/src/textual/app.py +++ b/src/textual/app.py @@ -13,6 +13,7 @@ import io import os import platform +import signal import sys import threading import warnings @@ -38,6 +39,7 @@ Generator, Generic, Iterable, + Iterator, List, Sequence, Type, @@ -50,15 +52,26 @@ import rich import rich.repr -from rich import terminal_theme from rich.console import Console, RenderableType from rich.control import Control from rich.protocol import is_renderable from rich.segment import Segment, Segments - -from . import Logger, LogGroup, LogVerbosity, actions, constants, events, log, messages +from rich.terminal_theme import TerminalTheme + +from . import ( + Logger, + LogGroup, + LogVerbosity, + actions, + constants, + events, + log, + messages, + on, +) from ._animator import DEFAULT_EASING, Animatable, Animator, EasingFunction from ._ansi_sequences import SYNC_END, SYNC_START +from ._ansi_theme import ALABASTER, MONOKAI from ._callback import invoke from ._compose import compose from ._compositor import CompositorUpdate @@ -66,21 +79,24 @@ from ._context import message_hook as message_hook_context_var from ._event_broker import NoHandler, extract_handler_actions from ._path import CSSPathType, _css_path_type_as_list, _make_path_object_relative +from ._types import AnimationLevel from ._wait import wait_for_idle from ._worker_manager import WorkerManager from .actions import ActionParseResult, SkipAction from .await_remove import AwaitRemove from .binding import Binding, BindingType, _Bindings from .command import CommandPalette, Provider +from .css.errors import StylesheetError from .css.query import NoMatches from .css.stylesheet import RulesMap, Stylesheet from .design import ColorSystem -from .dom import DOMNode +from .dom import DOMNode, NoScreen from .driver import Driver from .drivers.headless_driver import HeadlessDriver 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, @@ -98,13 +114,14 @@ ScreenResultType, _SystemModalScreen, ) +from .signal import Signal from .widget import AwaitMount, Widget from .widgets._toast import ToastRack from .worker import NoActiveWorker, get_current_worker if TYPE_CHECKING: from textual_dev.client import DevtoolsClient - from typing_extensions import Coroutine, Literal, TypeAlias + from typing_extensions import Coroutine, Literal, Self, TypeAlias from ._system_commands import SystemCommands from ._types import MessageTarget @@ -201,6 +218,14 @@ class ActiveModeError(ModeError): """Raised when attempting to remove the currently active mode.""" +class SuspendNotSupported(Exception): + """Raised if suspending the application is not supported. + + This exception is raised if [`App.suspend`][textual.app.App.suspend] is called while + the application is running in an environment where this isn't supported. + """ + + ReturnType = TypeVar("ReturnType") CSSPathType = Union[ @@ -371,9 +396,15 @@ class MyApp(App[None]): """Indicates if the app has focus. When run in the terminal, the app always has focus. When run in the web, the app will - get focus when the terminal widget has focus. + get focus when the terminal widget has focus. """ + ansi_theme_dark = Reactive(MONOKAI, init=False) + """Maps ANSI colors to hex colors using a Rich TerminalTheme object while in dark mode.""" + + ansi_theme_light = Reactive(ALABASTER, init=False) + """Maps ANSI colors to hex colors using a Rich TerminalTheme object while in light mode.""" + def __init__( self, driver_class: Type[Driver] | None = None, @@ -398,20 +429,17 @@ def __init__( super().__init__() self.features: frozenset[FeatureFlag] = parse_features(os.getenv("TEXTUAL", "")) - self._filters: list[LineFilter] = [] + ansi_theme = self.ansi_theme_dark if self.dark else self.ansi_theme_light + self._filters: list[LineFilter] = [ANSIToTruecolor(ansi_theme)] + 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( @@ -424,6 +452,7 @@ def __init__( _environ=environ, force_terminal=True, safe_box=False, + soft_wrap=False, ) self._workers = WorkerManager(self) self.error_console = Console(markup=False, stderr=True) @@ -525,6 +554,7 @@ def __init__( self._compose_stacks: list[list[Widget]] = [] self._composed: list[list[Widget]] = [] + self._recompose_required = False self.devtools: DevtoolsClient | None = None self._devtools_redirector: StdoutRedirector | None = None @@ -559,9 +589,9 @@ def __init__( self._batch_count = 0 self._notifications = Notifications() - self._capture_print: WeakKeyDictionary[ - MessageTarget, tuple[bool, bool] - ] = WeakKeyDictionary() + self._capture_print: WeakKeyDictionary[MessageTarget, tuple[bool, bool]] = ( + WeakKeyDictionary() + ) """Registry of the MessageTargets which are capturing output at any given time.""" self._capture_stdout = _PrintCapture(self, stderr=False) """File-like object capturing data written to stdout.""" @@ -572,9 +602,40 @@ def __init__( self._original_stderr = sys.__stderr__ """The original stderr stream (before redirection etc).""" + self.app_suspend_signal = Signal(self, "app-suspend") + """The signal that is published when the app is suspended. + + When [`App.suspend`][textual.app.App.suspend] is called this signal + will be [published][textual.signal.Signal.publish]; + [subscribe][textual.signal.Signal.subscribe] to this signal to + perform work before the suspension takes place. + """ + self.app_resume_signal = Signal(self, "app-resume") + """The signal that is published when the app is resumed after a suspend. + + When the app is resumed after a + [`App.suspend`][textual.app.App.suspend] call this signal will be + [published][textual.signal.Signal.publish]; + [subscribe][textual.signal.Signal.subscribe] to this signal to + perform work after the app has resumed. + """ + self.set_class(self.dark, "-dark-mode") self.set_class(not self.dark, "-light-mode") + self.animation_level: AnimationLevel = constants.TEXTUAL_ANIMATIONS + """Determines what type of animations the app will display. + + See [`textual.constants.TEXTUAL_ANIMATIONS`][textual.constants.TEXTUAL_ANIMATIONS]. + """ + + self._last_focused_on_app_blur: Widget | None = None + """The widget that had focus when the last `AppBlur` happened. + + This will be used to restore correct focus when an `AppFocus` + happens. + """ + def validate_title(self, title: Any) -> str: """Make sure the title is set to a string.""" return str(title) @@ -670,6 +731,7 @@ def animate( delay: float = 0.0, easing: EasingFunction | str = DEFAULT_EASING, on_complete: CallbackType | None = None, + level: AnimationLevel = "full", ) -> None: """Animate an attribute. @@ -684,6 +746,7 @@ def animate( delay: A delay (in seconds) before the animation starts. easing: An easing method. on_complete: A callable to invoke when the animation is finished. + level: Minimum level required for the animation to take place (inclusive). """ self._animate( attribute, @@ -694,6 +757,7 @@ def animate( delay=delay, easing=easing, on_complete=on_complete, + level=level, ) async def stop_animation(self, attribute: str, complete: bool = True) -> None: @@ -831,8 +895,40 @@ def watch_dark(self, dark: bool) -> None: """ self.set_class(dark, "-dark-mode", update=False) self.set_class(not dark, "-light-mode", update=False) + self._refresh_truecolor_filter(self.ansi_theme) self.call_later(self.refresh_css) + def watch_ansi_theme_dark(self, theme: TerminalTheme) -> None: + if self.dark: + self._refresh_truecolor_filter(theme) + self.call_later(self.refresh_css) + + def watch_ansi_theme_light(self, theme: TerminalTheme) -> None: + if not self.dark: + self._refresh_truecolor_filter(theme) + self.call_later(self.refresh_css) + + @property + def ansi_theme(self) -> TerminalTheme: + """The ANSI TerminalTheme currently being used. + + Defines how colors defined as ANSI (e.g. `magenta`) inside Rich renderables + are mapped to hex codes. + """ + return self.ansi_theme_dark if self.dark else self.ansi_theme_light + + def _refresh_truecolor_filter(self, theme: TerminalTheme) -> None: + """Update the ANSI to Truecolor filter, if available, with a new theme mapping. + + Args: + theme: The new terminal theme to use for mapping ANSI to truecolor. + """ + filters = self._filters + for index, filter in enumerate(filters): + if isinstance(filter, ANSIToTruecolor): + filters[index] = ANSIToTruecolor(theme) + return + def get_driver_class(self) -> Type[Driver]: """Get a driver class for this platform. @@ -1104,7 +1200,7 @@ def export_screenshot(self, *, title: str | None = None) -> str: def save_screenshot( self, filename: str | None = None, - path: str = "./", + path: str | None = None, time_format: str | None = None, ) -> str: """Save an SVG screenshot of the current screen. @@ -1119,7 +1215,8 @@ def save_screenshot( Returns: Filename of screenshot. """ - if filename is None: + path = path or "./" + if not filename: if time_format is None: dt = datetime.now().isoformat() else: @@ -1185,9 +1282,7 @@ async def _press_keys(self, keys: Iterable[str]) -> None: if key.startswith("wait:"): _, wait_ms = key.split(":") await asyncio.sleep(float(wait_ms) / 1000) - await wait_for_idle(0) await app._animator.wait_until_complete() - await wait_for_idle(0) else: if len(key) == 1 and not key.isalnum(): key = _character_to_key(key) @@ -1198,7 +1293,7 @@ async def _press_keys(self, keys: Iterable[str]) -> None: except KeyError: char = key if len(key) == 1 else None key_event = events.Key(key, char) - key_event._set_sender(app) + key_event.set_sender(app) driver.send_event(key_event) await wait_for_idle(0) await app._animator.wait_until_complete() @@ -1468,7 +1563,15 @@ async def _on_css_change(self) -> None: try: time = perf_counter() stylesheet = self.stylesheet.copy() - stylesheet.read_all(css_paths) + try: + stylesheet.read_all(css_paths) + except StylesheetError as error: + # If one of the CSS paths is no longer available (or perhaps temporarily unavailable), + # we'll end up with partial CSS, which is probably confusing more than anything. We opt to do + # nothing here, knowing that we'll retry again very soon, on the next file monitor invocation. + # Related issue: https://github.com/Textualize/textual/issues/3996 + self.log.warning(str(error)) + return stylesheet.parse() elapsed = (perf_counter() - time) * 1000 if self._css_has_errors: @@ -1497,18 +1600,16 @@ async def _on_css_change(self) -> None: for screen in self.screen_stack: self.stylesheet.update(screen) - def render(self) -> RenderableType: + def render(self) -> RenderResult: return Blank(self.styles.background) ExpectType = TypeVar("ExpectType", bound=Widget) @overload - def get_child_by_id(self, id: str) -> Widget: - ... + def get_child_by_id(self, id: str) -> Widget: ... @overload - def get_child_by_id(self, id: str, expect_type: type[ExpectType]) -> ExpectType: - ... + def get_child_by_id(self, id: str, expect_type: type[ExpectType]) -> ExpectType: ... def get_child_by_id( self, id: str, expect_type: type[ExpectType] | None = None @@ -1534,12 +1635,12 @@ def get_child_by_id( ) @overload - def get_widget_by_id(self, id: str) -> Widget: - ... + def get_widget_by_id(self, id: str) -> Widget: ... @overload - def get_widget_by_id(self, id: str, expect_type: type[ExpectType]) -> ExpectType: - ... + def get_widget_by_id( + self, id: str, expect_type: type[ExpectType] + ) -> ExpectType: ... def get_widget_by_id( self, id: str, expect_type: type[ExpectType] | None = None @@ -1874,8 +1975,7 @@ def push_screen( screen: Screen[ScreenResultType] | str, callback: ScreenResultCallbackType[ScreenResultType] | None = None, wait_for_dismiss: Literal[False] = False, - ) -> AwaitMount: - ... + ) -> AwaitMount: ... @overload def push_screen( @@ -1883,8 +1983,7 @@ def push_screen( screen: Screen[ScreenResultType] | str, callback: ScreenResultCallbackType[ScreenResultType] | None = None, wait_for_dismiss: Literal[True] = True, - ) -> asyncio.Future[ScreenResultType]: - ... + ) -> asyncio.Future[ScreenResultType]: ... def push_screen( self, @@ -1949,12 +2048,10 @@ def push_screen( @overload async def push_screen_wait( self, screen: Screen[ScreenResultType] - ) -> ScreenResultType: - ... + ) -> ScreenResultType: ... @overload - async def push_screen_wait(self, screen: str) -> Any: - ... + async def push_screen_wait(self, screen: str) -> Any: ... async def push_screen_wait( self, screen: Screen[ScreenResultType] | str @@ -2346,7 +2443,10 @@ async def _ready(self) -> None: async def take_screenshot() -> None: """Take a screenshot and exit.""" - self.save_screenshot() + self.save_screenshot( + path=constants.SCREENSHOT_LOCATION, + filename=constants.SCREENSHOT_FILENAME, + ) self.exit() if constants.SCREENSHOT_DELAY >= 0: @@ -2365,8 +2465,20 @@ async def _on_compose(self) -> None: await self.mount_all(widgets) - def _on_idle(self) -> None: - """Perform actions when there are no messages in the queue.""" + async def _check_recompose(self) -> None: + """Check if a recompose is required.""" + if self._recompose_required: + self._recompose_required = False + await self.recompose() + + async def recompose(self) -> None: + """Recompose the widget. + + Recomposing will remove children and call `self.compose` again to remount. + """ + async with self.screen.batch(): + await self.screen.query("*").exclude(".-textual-system").remove() + await self.screen.mount_all(compose(self)) def _register_child( self, parent: DOMNode, child: Widget, before: int | None, after: int | None @@ -2557,10 +2669,32 @@ async def _on_exit_app(self) -> None: self._begin_batch() # Prevent repaint / layout while shutting down await self._message_queue.put(None) - def refresh(self, *, repaint: bool = True, layout: bool = False) -> None: + def refresh( + self, + *, + repaint: bool = True, + layout: bool = False, + recompose: bool = False, + ) -> Self: + """Refresh the entire screen. + + Args: + repaint: Repaint the widget (will call render() again). + layout: Also layout widgets in the view. + recompose: Re-compose the widget (will remove and re-mount children). + + Returns: + The `App` instance. + """ + if recompose: + self._recompose_required = recompose + self.call_next(self._check_recompose) + return self + if self._screen_stack: self.screen.refresh(repaint=repaint, layout=layout) self.check_idle() + return self def refresh_css(self, animate: bool = True) -> None: """Refresh CSS. @@ -2572,7 +2706,7 @@ def refresh_css(self, animate: bool = True) -> None: stylesheet.set_variables(self.get_css_variables()) stylesheet.reparse() stylesheet.update(self.app, animate=animate) - self.screen._refresh_layout(self.size, full=True) + self.screen._refresh_layout(self.size) # The other screens in the stack will need to know about some style # changes, as a final pass let's check in on every screen that isn't # the current one and update them too. @@ -3070,10 +3204,27 @@ async def _prune_node(self, root: Widget) -> None: def _watch_app_focus(self, focus: bool) -> None: """Respond to changes in app focus.""" if focus: - focused = self.screen.focused - self.screen.set_focus(None) - self.screen.set_focus(focused) + # If we've got a last-focused widget, if it still has a screen, + # and if the screen is still the current screen and if nothing + # is focused right now... + try: + if ( + self._last_focused_on_app_blur is not None + and self._last_focused_on_app_blur.screen is self.screen + and self.screen.focused is None + ): + # ...settle focus back on that widget. + self.screen.set_focus(self._last_focused_on_app_blur) + except NoScreen: + pass + # Now that we have focus back on the app and we don't need the + # widget reference any more, don't keep it hanging around here. + self._last_focused_on_app_blur = None else: + # Remember which widget has focus, when the app gets focus back + # we'll want to try and focus it again. + self._last_focused_on_app_blur = self.screen.focused + # Remove focus for now. self.screen.set_focus(None) async def action_check_bindings(self, key: str) -> None: @@ -3292,3 +3443,82 @@ def action_command_palette(self) -> None: """Show the Textual command palette.""" if self.use_command_palette and not CommandPalette.is_open(self): self.push_screen(CommandPalette(), callback=self.call_next) + + def _suspend_signal(self) -> None: + """Signal that the application is being suspended.""" + self.app_suspend_signal.publish() + + @on(Driver.SignalResume) + def _resume_signal(self) -> None: + """Signal that the application is being resumed from a suspension.""" + self.app_resume_signal.publish() + + @contextmanager + def suspend(self) -> Iterator[None]: + """A context manager that temporarily suspends the app. + + While inside the `with` block, the app will stop reading input and + emitting output. Other applications will have full control of the + terminal, configured as it was before the app started running. When + the `with` block ends, the application will start reading input and + emitting output again. + + Example: + ```python + with self.suspend(): + os.system("emacs -nw") + ``` + + Raises: + SuspendNotSupported: If the environment doesn't support suspending. + + !!! note + Suspending the application is currently only supported on + Unix-like operating systems and Microsoft Windows. Suspending is + not supported in Textual Web. + """ + if self._driver is None: + return + if self._driver.can_suspend: + # Publish a suspend signal *before* we suspend application mode. + self._suspend_signal() + self._driver.suspend_application_mode() + # We're going to handle the start of the driver again so mark + # this next part as such; the reason for this is that the code + # the developer may be running could be in this process, and on + # Unix-like systems the user may `action_suspend_process` the + # app, and we don't want to have the driver auto-restart + # application mode when the application comes back to the + # foreground, in this context. + with self._driver.no_automatic_restart(), redirect_stdout( + sys.__stdout__ + ), redirect_stderr(sys.__stderr__): + yield + # We're done with the dev's code so resume application mode. + self._driver.resume_application_mode() + # ...and publish a resume signal. + self._resume_signal() + else: + raise SuspendNotSupported( + "App.suspend is not supported in this environment." + ) + + def action_suspend_process(self) -> None: + """Suspend the process into the background. + + Note: + On Unix and Unix-like systems a `SIGTSTP` is sent to the + application's process. Currently on Windows and when running + under Textual Web this is a non-operation. + """ + # Check if we're in an environment that permits this kind of + # suspend. + if not WINDOWS and self._driver is not None and self._driver.can_suspend: + # First, ensure that the suspend signal gets published while + # we're still in application mode. + self._suspend_signal() + # With that out of the way, send the SIGTSTP signal. + os.kill(os.getpid(), signal.SIGTSTP) + # NOTE: There is no call to publish the resume signal here, this + # will be handled by the driver posting a SignalResume event + # (see the event handler on App._resume_signal) above. diff --git a/src/textual/await_complete.py b/src/textual/await_complete.py index 51d807f6d2..2ff1068862 100644 --- a/src/textual/await_complete.py +++ b/src/textual/await_complete.py @@ -1,40 +1,37 @@ from __future__ import annotations from asyncio import Future, gather -from typing import Any, Coroutine, Iterator, TypeVar +from typing import Any, Awaitable, Generator import rich.repr -ReturnType = TypeVar("ReturnType") - @rich.repr.auto(angular=True) class AwaitComplete: - """An 'optionally-awaitable' object.""" + """An 'optionally-awaitable' object which runs one or more coroutines (or other awaitables) concurrently.""" - def __init__(self, *coroutines: Coroutine[Any, Any, Any]) -> None: + def __init__(self, *awaitables: Awaitable) -> None: """Create an AwaitComplete. Args: - coroutines: One or more coroutines to execute. + awaitables: One or more awaitables to run concurrently. """ - self.coroutines: tuple[Coroutine[Any, Any, Any], ...] = coroutines - self._future: Future = gather(*self.coroutines) + self._future: Future[Any] = gather(*awaitables) async def __call__(self) -> Any: return await self - def __await__(self) -> Iterator[None]: + def __await__(self) -> Generator[Any, None, Any]: return self._future.__await__() @property def is_done(self) -> bool: - """Returns True if the task has completed.""" + """`True` if the task has completed.""" return self._future.done() @property def exception(self) -> BaseException | None: - """An exception if it occurred in any of the coroutines.""" + """An exception if the awaitables failed.""" if self._future.done(): return self._future.exception() return None diff --git a/src/textual/binding.py b/src/textual/binding.py index 87f2c608b1..409fe9de56 100644 --- a/src/textual/binding.py +++ b/src/textual/binding.py @@ -5,7 +5,6 @@ See [bindings](/guide/input#bindings) in the guide for details. """ - from __future__ import annotations from dataclasses import dataclass diff --git a/src/textual/cache.py b/src/textual/cache.py index 3772090b17..420d90004f 100644 --- a/src/textual/cache.py +++ b/src/textual/cache.py @@ -129,12 +129,12 @@ def set(self, key: CacheKey, value: CacheValue) -> None: __setitem__ = set @overload - def get(self, key: CacheKey) -> CacheValue | None: - ... + def get(self, key: CacheKey) -> CacheValue | None: ... @overload - def get(self, key: CacheKey, default: DefaultValue) -> CacheValue | DefaultValue: - ... + def get( + self, key: CacheKey, default: DefaultValue + ) -> CacheValue | DefaultValue: ... def get( self, key: CacheKey, default: DefaultValue | None = None @@ -269,12 +269,12 @@ def set(self, key: CacheKey, value: CacheValue) -> None: __setitem__ = set @overload - def get(self, key: CacheKey) -> CacheValue | None: - ... + def get(self, key: CacheKey) -> CacheValue | None: ... @overload - def get(self, key: CacheKey, default: DefaultValue) -> CacheValue | DefaultValue: - ... + def get( + self, key: CacheKey, default: DefaultValue + ) -> CacheValue | DefaultValue: ... def get( self, key: CacheKey, default: DefaultValue | None = None diff --git a/src/textual/clock.py b/src/textual/clock.py new file mode 100644 index 0000000000..6df651bcc9 --- /dev/null +++ b/src/textual/clock.py @@ -0,0 +1,74 @@ +from __future__ import annotations + +from time import monotonic +from typing import Callable + +import rich.repr + + +@rich.repr.auto(angular=True) +class Clock: + """An object to get relative time. + + The `time` attribute of clock will return the time in seconds since the + Clock was created or reset. + + """ + + def __init__(self, *, get_time: Callable[[], float] = monotonic) -> None: + """Create a clock. + + Args: + get_time: A callable to get time in seconds. + start: Start the clock (time is 0 unless clock has been started). + """ + self._get_time = get_time + self._start_time = self._get_time() + + def __rich_repr__(self) -> rich.repr.Result: + yield self.time + + def clone(self) -> Clock: + """Clone the Clock with an independent time.""" + return Clock(get_time=self._get_time) + + def reset(self) -> None: + """Reset the clock.""" + self._start_time = self._get_time() + + @property + def time(self) -> float: + """Time since creation or reset.""" + return self._get_time() - self._start_time + + +class MockClock(Clock): + """A mock clock object where the time may be explicitly set.""" + + def __init__(self, time: float = 0.0) -> None: + """Construct a mock clock.""" + self._time = time + super().__init__(get_time=lambda: self._time) + + def clone(self) -> MockClock: + """Clone the mocked clock (clone will return the same time as original).""" + clock = MockClock(self._time) + clock._get_time = self._get_time + clock._time = self._time + return clock + + def reset(self) -> None: + """A null-op because it doesn't make sense to reset a mocked clock.""" + + def set_time(self, time: float) -> None: + """Set the time for the clock. + + Args: + time: Time to set. + """ + self._time = time + + @property + def time(self) -> float: + """Time since creation or reset.""" + return self._get_time() diff --git a/src/textual/command.py b/src/textual/command.py index 9136bf9d5b..c7c240fcaa 100644 --- a/src/textual/command.py +++ b/src/textual/command.py @@ -25,7 +25,6 @@ import rich.repr from rich.align import Align from rich.console import Group, RenderableType -from rich.emoji import Emoji from rich.style import Style from rich.text import Text from typing_extensions import Final, TypeAlias @@ -49,6 +48,7 @@ __all__ = [ "CommandPalette", + "DiscoveryHit", "Hit", "Hits", "Matcher", @@ -82,6 +82,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 +110,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 +254,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 +273,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 +314,7 @@ class Command(Option): def __init__( self, prompt: RenderableType, - command: Hit, + command: DiscoveryHit | Hit, id: str | None = None, disabled: bool = False, ) -> None: @@ -309,13 +383,14 @@ class SearchIcon(Static, inherit_css=False): DEFAULT_CSS = """ SearchIcon { + color: #000; /* required for snapshot tests */ margin-left: 1; margin-top: 1; width: 2; } """ - icon: var[str] = var(Emoji.replace(":magnifying_glass_tilted_right:")) + icon: var[str] = var("🔎") """The icon to display.""" def render(self) -> RenderableType: @@ -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/constants.py b/src/textual/constants.py index d47d0d2c15..6d0ebfe323 100644 --- a/src/textual/constants.py +++ b/src/textual/constants.py @@ -5,13 +5,16 @@ from __future__ import annotations import os +from typing import get_args -from typing_extensions import Final +from typing_extensions import Final, TypeGuard + +from ._types import AnimationLevel get_environ = os.environ.get -def get_environ_bool(name: str) -> bool: +def _get_environ_bool(name: str) -> bool: """Check an environment variable switch. Args: @@ -24,7 +27,7 @@ def get_environ_bool(name: str) -> bool: return has_environ -def get_environ_int(name: str, default: int) -> int: +def _get_environ_int(name: str, default: int) -> int: """Retrieves an integer environment variable. Args: @@ -44,7 +47,34 @@ def get_environ_int(name: str, default: int) -> int: return default -DEBUG: Final[bool] = get_environ_bool("TEXTUAL_DEBUG") +def _is_valid_animation_level(value: str) -> TypeGuard[AnimationLevel]: + """Checks if a string is a valid animation level. + + Args: + value: The string to check. + + Returns: + Whether it's a valid level or not. + """ + return value in get_args(AnimationLevel) + + +def _get_textual_animations() -> AnimationLevel: + """Get the value of the environment variable that controls textual animations. + + The variable can be in any of the values defined by [`AnimationLevel`][textual.constants.AnimationLevel]. + + Returns: + The value that the variable was set to. If the environment variable is set to an + invalid value, we default to showing all animations. + """ + value: str = get_environ("TEXTUAL_ANIMATIONS", "FULL").lower() + if _is_valid_animation_level(value): + return value + return "full" + + +DEBUG: Final[bool] = _get_environ_bool("TEXTUAL_DEBUG") """Enable debug mode.""" DRIVER: Final[str | None] = get_environ("TEXTUAL_DRIVER", None) @@ -59,20 +89,29 @@ def get_environ_int(name: str, default: int) -> int: DEVTOOLS_HOST: Final[str] = get_environ("TEXTUAL_DEVTOOLS_HOST", "127.0.0.1") """The host where textual console is running.""" -DEVTOOLS_PORT: Final[int] = get_environ_int("TEXTUAL_DEVTOOLS_PORT", 8081) +DEVTOOLS_PORT: Final[int] = _get_environ_int("TEXTUAL_DEVTOOLS_PORT", 8081) """Constant with the port that the devtools will connect to.""" -SCREENSHOT_DELAY: Final[int] = get_environ_int("TEXTUAL_SCREENSHOT", -1) +SCREENSHOT_DELAY: Final[int] = _get_environ_int("TEXTUAL_SCREENSHOT", -1) """Seconds delay before taking screenshot.""" +SCREENSHOT_LOCATION: Final[str | None] = get_environ("TEXTUAL_SCREENSHOT_LOCATION") +"""The location where screenshots should be written.""" + +SCREENSHOT_FILENAME: Final[str | None] = get_environ("TEXTUAL_SCREENSHOT_FILENAME") +"""The filename to use for the screenshot.""" + PRESS: Final[str] = get_environ("TEXTUAL_PRESS", "") """Keys to automatically press.""" -SHOW_RETURN: Final[bool] = get_environ_bool("TEXTUAL_SHOW_RETURN") +SHOW_RETURN: Final[bool] = _get_environ_bool("TEXTUAL_SHOW_RETURN") """Write the return value on exit.""" -MAX_FPS: Final[int] = get_environ_int("TEXTUAL_FPS", 60) +MAX_FPS: Final[int] = _get_environ_int("TEXTUAL_FPS", 60) """Maximum frames per second for updates.""" COLOR_SYSTEM: Final[str | None] = get_environ("TEXTUAL_COLOR_SYSTEM", "auto") """Force color system override""" + +TEXTUAL_ANIMATIONS: AnimationLevel = _get_textual_animations() +"""Determines whether animations run or not.""" diff --git a/src/textual/css/query.py b/src/textual/css/query.py index 7df103e7a3..d3cf48b512 100644 --- a/src/textual/css/query.py +++ b/src/textual/css/query.py @@ -9,7 +9,6 @@ If this sounds like JQuery, a (once) popular JS library, it is no coincidence. """ - from __future__ import annotations from typing import TYPE_CHECKING, Generic, Iterable, Iterator, TypeVar, cast, overload @@ -86,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, ...]] = ( @@ -145,27 +145,28 @@ def __reversed__(self) -> Iterator[QueryType]: return reversed(self.nodes) @overload - def __getitem__(self, index: int) -> QueryType: - ... + def __getitem__(self, index: int) -> QueryType: ... @overload - def __getitem__(self, index: slice) -> list[QueryType]: - ... + def __getitem__(self, index: slice) -> list[QueryType]: ... 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. @@ -191,12 +192,10 @@ def exclude(self, selector: str) -> DOMQuery[QueryType]: return DOMQuery(self.node, exclude=selector, parent=self) @overload - def first(self) -> QueryType: - ... + def first(self) -> QueryType: ... @overload - def first(self, expect_type: type[ExpectType]) -> ExpectType: - ... + def first(self, expect_type: type[ExpectType]) -> ExpectType: ... def first( self, expect_type: type[ExpectType] | None = None @@ -224,15 +223,13 @@ 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: - ... + def only_one(self) -> QueryType: ... @overload - def only_one(self, expect_type: type[ExpectType]) -> ExpectType: - ... + def only_one(self, expect_type: type[ExpectType]) -> ExpectType: ... def only_one( self, expect_type: type[ExpectType] | None = None @@ -274,12 +271,10 @@ def only_one( return the_one @overload - def last(self) -> QueryType: - ... + def last(self) -> QueryType: ... @overload - def last(self, expect_type: type[ExpectType]) -> ExpectType: - ... + def last(self, expect_type: type[ExpectType]) -> ExpectType: ... def last( self, expect_type: type[ExpectType] | None = None @@ -298,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( @@ -307,12 +302,10 @@ def last( return last @overload - def results(self) -> Iterator[QueryType]: - ... + def results(self) -> Iterator[QueryType]: ... @overload - def results(self, filter_type: type[ExpectType]) -> Iterator[ExpectType]: - ... + def results(self, filter_type: type[ExpectType]) -> Iterator[ExpectType]: ... def results( self, filter_type: type[ExpectType] | None = None @@ -416,17 +409,72 @@ def set_styles( return self def refresh( - self, *, repaint: bool = True, layout: bool = False + self, *, repaint: bool = True, layout: bool = False, recompose: bool = False ) -> DOMQuery[QueryType]: """Refresh matched nodes. Args: repaint: Repaint node(s). layout: Layout node(s). + recompose: Recompose node(s). + + Returns: + Query for chaining. + """ + for node in self: + node.refresh(repaint=repaint, layout=layout, recompose=recompose) + return self + + def focus(self) -> DOMQuery[QueryType]: + """Focus the first matching node that permits focus. + + Returns: + Query for chaining. + """ + for node in self: + if node.allow_focus(): + node.focus() + break + return self + + def blur(self) -> DOMQuery[QueryType]: + """Blur the first matching node that is focused. + + Returns: + Query for chaining. + """ + focused = self._node.screen.focused + if focused is not None: + nodes: list[Widget] = list(self) + 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: - node.refresh(repaint=repaint, layout=layout) + 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/scalar.py b/src/textual/css/scalar.py index 044fce1161..0ef332c571 100644 --- a/src/textual/css/scalar.py +++ b/src/textual/css/scalar.py @@ -37,8 +37,6 @@ class Unit(Enum): AUTO = 8 -UNIT_EXCLUDES_BORDER = {Unit.CELLS, Unit.FRACTION, Unit.VIEW_WIDTH, Unit.VIEW_HEIGHT} - UNIT_SYMBOL = { Unit.CELLS: "", Unit.FRACTION: "fr", @@ -206,10 +204,6 @@ def is_fraction(self) -> bool: """Check if the unit is a fraction.""" return self.unit == Unit.FRACTION - @property - def excludes_border(self) -> bool: - return self.unit in UNIT_EXCLUDES_BORDER - @property def cells(self) -> int | None: """Check if the unit is explicit cells.""" diff --git a/src/textual/css/scalar_animation.py b/src/textual/css/scalar_animation.py index 018d28b191..bf690b8a6c 100644 --- a/src/textual/css/scalar_animation.py +++ b/src/textual/css/scalar_animation.py @@ -3,7 +3,7 @@ from typing import TYPE_CHECKING from .._animator import Animation, EasingFunction -from .._types import CallbackType +from .._types import AnimationLevel, CallbackType from .scalar import Scalar, ScalarOffset if TYPE_CHECKING: @@ -23,6 +23,7 @@ def __init__( speed: float | None, easing: EasingFunction, on_complete: CallbackType | None = None, + level: AnimationLevel = "full", ): assert ( speed is not None or duration is not None @@ -34,6 +35,7 @@ def __init__( self.final_value = value self.easing = easing self.on_complete = on_complete + self.level = level size = widget.outer_size viewport = widget.app.size @@ -48,11 +50,18 @@ def __init__( assert duration is not None, "Duration expected to be non-None" self.duration = duration - def __call__(self, time: float) -> bool: + def __call__( + self, time: float, app_animation_level: AnimationLevel = "full" + ) -> bool: factor = min(1.0, (time - self.start_time) / self.duration) eased_factor = self.easing(factor) - if eased_factor >= 1: + if ( + eased_factor >= 1 + or app_animation_level == "none" + or app_animation_level == "basic" + and self.level == "full" + ): setattr(self.styles, self.attribute, self.final_value) return True diff --git a/src/textual/css/styles.py b/src/textual/css/styles.py index 5996918f38..8bc14d49c9 100644 --- a/src/textual/css/styles.py +++ b/src/textual/css/styles.py @@ -2,7 +2,7 @@ from abc import ABC, abstractmethod from dataclasses import dataclass, field -from functools import lru_cache +from functools import lru_cache, partial from operator import attrgetter from typing import TYPE_CHECKING, Any, Iterable, NamedTuple, cast @@ -11,7 +11,7 @@ from typing_extensions import TypedDict from .._animator import DEFAULT_EASING, Animatable, BoundAnimator, EasingFunction -from .._types import CallbackType +from .._types import AnimationLevel, CallbackType from ..color import Color from ..geometry import Offset, Spacing from ._style_properties import ( @@ -369,6 +369,7 @@ def __textual_animation__( speed: float | None, easing: EasingFunction, on_complete: CallbackType | None = None, + level: AnimationLevel = "full", ) -> ScalarAnimation | None: if self.node is None: return None @@ -395,7 +396,12 @@ def __textual_animation__( duration=duration, speed=speed, easing=easing, - on_complete=on_complete, + on_complete=( + partial(self.node.app.call_later, on_complete) + if on_complete is not None + else None + ), + level=level, ) return None @@ -1138,6 +1144,7 @@ def animate( delay: float = 0.0, easing: EasingFunction | str = DEFAULT_EASING, on_complete: CallbackType | None = None, + level: AnimationLevel = "full", ) -> None: """Animate an attribute. @@ -1150,6 +1157,7 @@ def animate( delay: A delay (in seconds) before the animation starts. easing: An easing method. on_complete: A callable to invoke when the animation is finished. + level: Minimum level required for the animation to take place (inclusive). """ if self._animate is None: assert self.node is not None @@ -1164,6 +1172,7 @@ def animate( delay=delay, easing=easing, on_complete=on_complete, + level=level, ) def __rich_repr__(self) -> rich.repr.Result: diff --git a/src/textual/css/tokenize.py b/src/textual/css/tokenize.py index 7cf1556613..4193cc578b 100644 --- a/src/textual/css/tokenize.py +++ b/src/textual/css/tokenize.py @@ -30,6 +30,19 @@ VARIABLE_REF = r"\$[a-zA-Z0-9_\-]+" IDENTIFIER = r"[a-zA-Z_\-][a-zA-Z0-9_\-]*" +SELECTOR_TYPE_NAME = r"[A-Z_][a-zA-Z0-9_]*" +"""Selectors representing Widget type names should start with upper case or '_'. + +The fact that a selector starts with an upper case letter or '_' is relevant in the +context of nested CSS to help determine whether xxx:yyy is a declaration + value or a +selector + pseudo-class.""" +DECLARATION_NAME = r"[a-z][a-zA-Z0-9_\-]*" +"""Declaration of TCSS rules start with lowercase. + +The fact that a declaration starts with a lower case letter is relevant in the context +of nested CSS to help determine whether xxx:yyy is a declaration + value or a selector ++ pseudo-class. +""" # Values permitted in variable and rule declarations. DECLARATION_VALUES = { @@ -54,7 +67,7 @@ selector_start_id=r"\#" + IDENTIFIER, selector_start_class=r"\." + IDENTIFIER, selector_start_universal=r"\*", - selector_start=IDENTIFIER, + selector_start=SELECTOR_TYPE_NAME, variable_name=rf"{VARIABLE_REF}:", declaration_set_end=r"\}", ).expect_eof(True) @@ -64,10 +77,11 @@ whitespace=r"\s+", comment_start=COMMENT_START, comment_line=COMMENT_LINE, + declaration_name=DECLARATION_NAME + r"\:", selector_start_id=r"\#" + IDENTIFIER, selector_start_class=r"\." + IDENTIFIER, selector_start_universal=r"\*", - selector_start=IDENTIFIER, + selector_start=SELECTOR_TYPE_NAME, variable_name=rf"{VARIABLE_REF}:", declaration_set_end=r"\}", nested=r"\&", @@ -97,14 +111,15 @@ comment_start=COMMENT_START, comment_line=COMMENT_LINE, pseudo_class=r"\:[a-zA-Z_-]+", - selector_id=r"\#[a-zA-Z_\-][a-zA-Z0-9_\-]*", - selector_class=r"\.[a-zA-Z_\-][a-zA-Z0-9_\-]*", + selector_id=r"\#" + IDENTIFIER, + selector_class=r"\." + IDENTIFIER, selector_universal=r"\*", - selector=IDENTIFIER, + selector=SELECTOR_TYPE_NAME, combinator_child=">", new_selector=r",", declaration_set_start=r"\{", declaration_set_end=r"\}", + nested=r"\&", ).expect_eof(True) # A rule declaration e.g. "text: red;" @@ -115,13 +130,13 @@ whitespace=r"\s+", comment_start=COMMENT_START, comment_line=COMMENT_LINE, - declaration_name=r"[a-zA-Z_\-]+\:", + declaration_name=DECLARATION_NAME + r"\:", declaration_set_end=r"\}", # selector_start_id=r"\#" + IDENTIFIER, selector_start_class=r"\." + IDENTIFIER, selector_start_universal=r"\*", - selector_start=IDENTIFIER, + selector_start=SELECTOR_TYPE_NAME, ) expect_declaration_solo = Expect( @@ -129,7 +144,7 @@ whitespace=r"\s+", comment_start=COMMENT_START, comment_line=COMMENT_LINE, - declaration_name=r"[a-zA-Z_\-]+\:", + declaration_name=DECLARATION_NAME + r"\:", declaration_set_end=r"\}", ).expect_eof(True) @@ -210,7 +225,7 @@ def __call__(self, code: str, read_from: CSSLocation) -> Iterable[Token]: nest_level += 1 elif name == "declaration_set_end": nest_level -= 1 - expect = expect_root_nested if nest_level else expect_root_scope + expect = expect_declaration if nest_level else expect_root_scope yield token continue expect = get_state(name, expect) diff --git a/src/textual/css/tokenizer.py b/src/textual/css/tokenizer.py index 44e5716e3e..dc68a9e575 100644 --- a/src/textual/css/tokenizer.py +++ b/src/textual/css/tokenizer.py @@ -248,11 +248,14 @@ def get_token(self, expect: Expect) -> Token: line = self.lines[line_no] match = expect.match(line, col_no) if match is None: + error_line = line[col_no:].rstrip() + error_message = ( + f"{expect.description} (found {error_line.split(';')[0]!r})." + ) + if not error_line.endswith(";"): + error_message += "; Did you forget a semicolon at the end of a line?" raise TokenError( - self.read_from, - self.code, - (line_no + 1, col_no + 1), - f"{expect.description} (found {line[col_no:].rstrip()!r}).; Did you forget a semicolon at the end of a line?", + self.read_from, self.code, (line_no + 1, col_no + 1), error_message ) iter_groups = iter(match.groups()) diff --git a/src/textual/demo.py b/src/textual/demo.py index 2f37d3718a..5d2a67d74e 100644 --- a/src/textual/demo.py +++ b/src/textual/demo.py @@ -2,6 +2,7 @@ from importlib.metadata import version from pathlib import Path +from typing import cast from rich import box from rich.console import RenderableType @@ -194,8 +195,9 @@ def compose(self) -> ComposeResult: yield Button("Start", variant="success") def on_button_pressed(self, event: Button.Pressed) -> None: - self.app.add_note("[b magenta]Start!") - self.app.query_one(".location-first").scroll_visible(duration=0.5, top=True) + app = cast(DemoApp, self.app) + app.add_note("[b magenta]Start!") + app.query_one(".location-first").scroll_visible(duration=0.5, top=True) class OptionGroup(Container): @@ -248,8 +250,9 @@ def __init__(self, label: str, reveal: str) -> None: self.reveal = reveal def on_click(self) -> None: - self.app.query_one(self.reveal).scroll_visible(top=True, duration=0.5) - self.app.add_note(f"Scrolling to [b]{self.reveal}[/b]") + app = cast(DemoApp, self.app) + app.query_one(self.reveal).scroll_visible(top=True, duration=0.5) + app.add_note(f"Scrolling to [b]{self.reveal}[/b]") class LoginForm(Container): diff --git a/src/textual/document/_document.py b/src/textual/document/_document.py index 4fc8076f61..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" @@ -91,6 +91,16 @@ def text(self) -> str: def newline(self) -> Newline: """Return the line separator used in the document.""" + @property + @abstractmethod + def lines(self) -> list[str]: + """Get the lines of the document as a list of strings. + + The strings should *not* include newline characters. The newline + character used for the document can be retrieved via the newline + property. + """ + @abstractmethod def get_line(self, index: int) -> str: """Returns the line with the given index from the document. @@ -162,12 +172,10 @@ def line_count(self) -> int: """Returns the number of lines in the document.""" @overload - def __getitem__(self, line_index: int) -> str: - ... + def __getitem__(self, line_index: int) -> str: ... @overload - def __getitem__(self, line_index: slice) -> list[str]: - ... + def __getitem__(self, line_index: slice) -> list[str]: ... @abstractmethod def __getitem__(self, line_index: int | slice) -> str | list[str]: @@ -233,6 +241,8 @@ def get_size(self, tab_width: int) -> Size: def replace_range(self, start: Location, end: Location, text: str) -> EditResult: """Replace text at the given range. + This is the only method by which a document may be updated. + Args: start: A tuple (row, column) where the edit starts. end: A tuple (row, column) where the edit ends. @@ -370,12 +380,10 @@ def get_line(self, index: int) -> str: return line_string @overload - def __getitem__(self, line_index: int) -> str: - ... + def __getitem__(self, line_index: int) -> str: ... @overload - def __getitem__(self, line_index: slice) -> list[str]: - ... + def __getitem__(self, line_index: slice) -> list[str]: ... def __getitem__(self, line_index: int | slice) -> str | list[str]: """Return the content of a line as a string, excluding newline characters. diff --git a/src/textual/document/_document_navigator.py b/src/textual/document/_document_navigator.py new file mode 100644 index 0000000000..25b44e8422 --- /dev/null +++ b/src/textual/document/_document_navigator.py @@ -0,0 +1,470 @@ +import re +from bisect import bisect, bisect_left, bisect_right +from typing import Any, Sequence + +from textual._cells import cell_len +from textual.document._document import Location +from textual.document._wrapped_document import WrappedDocument +from textual.geometry import Offset, clamp + + +class DocumentNavigator: + """Cursor navigation in the TextArea is "wrapping-aware". + + Although the cursor location (the selection) is represented as a location + in the raw document, when you actually *move* the cursor, it must take wrapping + into account (otherwise things start to look really confusing to the user where + wrapping is involved). + + Your cursor visually moves through the wrapped version of the document, rather + than the raw document. So, for example, pressing down on the keyboard + may move your cursor to a position further along the current raw document line, + rather than on to the next line in the raw document. + + The DocumentNavigator class manages that behaviour. + + Given a cursor location in the unwrapped document, and a cursor movement action, + this class can inform us of the destination the cursor will move to considering + the current wrapping width and document content. It can also translate between + document-space (a location/(row,col) in the raw document), and visual-space + (x and y offsets) as the user will see them on screen after the document has been + wrapped. + + For this to work correctly, the wrapped_document and document must be synchronised. + This means that if you make an edit to the document, you *must* then update the + wrapped document, and *then* you may query the document navigator. + + Naming conventions: + + A "location" refers to a location, in document-space (in the raw document). It + is entirely unrelated to visually positioning. A location in a document can appear + in any visual position, as it is influenced by scrolling, wrapping, gutter settings, + and the cell width of characters to its left. + + A "wrapped section" refers to a portion of the line accounting for wrapping. + For example the line "ABCDEF" when wrapped at width 3 will result in 2 sections: + "ABC" and "DEF". In this case, we call "ABC" is the first section/wrapped section. + + A "wrap offset" is an integer representing the index at which wrapping occurs in a + document-space line. This is a codepoint index, rather than a visual offset. + In "ABCDEF" with wrapping at width 3, there is a single wrap offset of 3. + + "Smart home" refers to a modification of the "home" key behaviour. If smart home is + enabled, the first non-whitespace character is considered to be the home location. + If the cursor is currently at this position, then the normal home behaviour applies. + This is designed to make cursor movement more useful to end users. + """ + + def __init__(self, wrapped_document: WrappedDocument) -> None: + """Create a DocumentNavigator. + + Args: + wrapped_document: The WrappedDocument to be used when making navigation decisions. + """ + self._wrapped_document = wrapped_document + self._document = wrapped_document.document + + self._word_pattern = re.compile(r"(?<=\W)(?=\w)|(?<=\w)(?=\W)") + """Compiled regular expression for what we consider to be a 'word'.""" + + self.last_x_offset = 0 + """Remembers the last x offset (cell width) the cursor was moved horizontally to, + so that it can be restored on vertical movement where possible.""" + + def is_start_of_document_line(self, location: Location) -> bool: + """True when the location is at the start of the first document line. + + Args: + location: The location to check. + + Returns: + True if the location is at column index 0. + """ + return location[1] == 0 + + def is_start_of_wrapped_line(self, location: Location) -> bool: + """True when the location is at the start of the first wrapped line. + + Args: + location: The location to check. + + Returns: + True if the location is at column index 0. + """ + if self.is_start_of_document_line(location): + return True + + row, column = location + wrap_offsets = self._wrapped_document.get_offsets(row) + return index(wrap_offsets, column) != -1 + + 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 at the end of a line in the document. + """ + row, column = location + row_length = len(self._document[row]) + return column == row_length + + def is_end_of_wrapped_line(self, location: Location) -> bool: + """True if the location is at the end of a wrapped line. + + Args: + location: The location to examine. + + Returns: + True if and only if the cursor is on the last wrapped section of *any* line. + """ + if self.is_end_of_document_line(location): + return True + + row, column = location + wrap_offsets = self._wrapped_document.get_offsets(row) + return index(wrap_offsets, column - 1) != -1 + + def is_first_document_line(self, location: Location) -> bool: + """Check if the given location is on the first line in the document. + + Args: + location: The location to examine. + + Returns: + True if and only if the cursor is on the first line of the document. + """ + return location[0] == 0 + + def is_first_wrapped_line(self, location: Location) -> bool: + """Check if the given location is on the first wrapped section of the first line in the document. + + Args: + location: The location to examine. + + Returns: + True if and only if the cursor is on the first wrapped section of the first line. + """ + if not self.is_first_document_line(location): + return False + + row, column = location + wrap_offsets = self._wrapped_document.get_offsets(row) + + if not wrap_offsets: + return True + + if column < wrap_offsets[0]: + return True + return False + + def is_last_document_line(self, location: Location) -> bool: + """Check if the given location is on the last line of the document. + + Args: + location: The location to examine. + + Returns: + True when the location is on the last line of the document. + """ + return location[0] == self._document.line_count - 1 + + def is_last_wrapped_line(self, location: Location) -> bool: + """Check if the given location is on the last wrapped section of the last line. + + That is, the cursor is *visually* on the last rendered row. + + Args: + location: The location to examine. + + Returns: + True if and only if the cursor is on the last section of the last line. + """ + if not self.is_last_document_line(location): + return False + + row, column = location + wrap_offsets = self._wrapped_document.get_offsets(row) + + if not wrap_offsets: + return True + + if column >= wrap_offsets[-1]: + return True + return False + + def is_start_of_document(self, location: Location) -> bool: + """Check if a location is at the start of the document. + + Args: + location: The location to examine. + + Returns: + True if and only if the cursor is at document location (0, 0)""" + return location == (0, 0) + + def is_end_of_document(self, location: Location) -> bool: + """Check if a location is at the end of the document. + + Args: + location: The location to examine. + + Returns: + True if and only if the cursor is at the end of the document. + """ + return self.is_last_document_line(location) and self.is_end_of_document_line( + location + ) + + def get_location_left(self, location: Location) -> Location: + """Get the location to the left of the given location. + + Note that if the given location is at the start of the line, then + this will return the end of the preceding line, since that's where + you would expect the cursor to move. + + Args: + location: The location to start from. + + Returns: + The location to the right. + """ + if location == (0, 0): + return 0, 0 + + row, column = location + length_of_row_above = len(self._document[row - 1]) + target_row = row if column != 0 else row - 1 + target_column = column - 1 if column != 0 else length_of_row_above + return target_row, target_column + + def get_location_right(self, location: Location) -> Location: + """Get the location to the right of the given location. + + Note that if the given location is at the end of the line, then + this will return the start of the following line, since that's where + you would expect the cursor to move. + + Args: + location: The location to start from. + + Returns: + The location to the right. + """ + if self.is_end_of_document(location): + return location + row, column = location + is_end_of_line = self.is_end_of_document_line(location) + target_row = row + 1 if is_end_of_line else row + target_column = 0 if is_end_of_line else column + 1 + return target_row, target_column + + def get_location_above(self, location: Location) -> Location: + """Get the location visually aligned with the cell above the given location. + + Args: + location: The location to start from. + + Returns: + The cell above the given location. + """ + + # Get the wrap offsets of the current line. + line_index, column_index = location + wrap_offsets = self._wrapped_document.get_offsets(line_index) + section_start_columns = [0, *wrap_offsets] + + # We need to find the insertion point to determine which section index we're + # on within the current line. When we know the section index, we can use it + # to find the section which sits above it. + section_index = bisect_right(wrap_offsets, column_index) + offset_within_section = column_index - section_start_columns[section_index] + wrapped_line = self._wrapped_document.get_sections(line_index) + section = wrapped_line[section_index] + + # Convert that cursor offset to a cell (visual) offset + current_visual_offset = cell_len(section[:offset_within_section]) + target_offset = max(current_visual_offset, self.last_x_offset) + + if section_index == 0: + # Moving up from a position on the first visual line moves us to the start. + if self.is_first_wrapped_line(location): + return 0, 0 + # Get the last section from the line above, and find where to move in it. + target_row = line_index - 1 + target_column = self._wrapped_document.get_target_document_column( + target_row, target_offset, -1 + ) + target_location = target_row, target_column + else: + # Stay on the same document line, but move backwards. + # Since the section above could be shorter, we need to clamp the column + # to a valid value. + target_column = self._wrapped_document.get_target_document_column( + line_index, target_offset, section_index - 1 + ) + target_location = line_index, target_column + + return target_location + + def get_location_below(self, location: Location) -> Location: + """Given a location in the raw document, return the raw document + location corresponding to moving down in the wrapped representation + of the document. + + Args: + location: The location in the raw document. + + Returns: + The location which is *visually* below the given location. + """ + line_index, column_index = location + document = self._document + + wrap_offsets = self._wrapped_document.get_offsets(line_index) + section_start_columns = [0, *wrap_offsets] + section_index = bisect(wrap_offsets, column_index) + offset_within_section = column_index - section_start_columns[section_index] + wrapped_line = self._wrapped_document.get_sections(line_index) + section = wrapped_line[section_index] + current_visual_offset = cell_len(section[:offset_within_section]) + target_offset = max(current_visual_offset, self.last_x_offset) + + # If we're at the last section/row of a wrapped line + if section_index == len(wrapped_line) - 1: + # Last section of last line: go to end of file. + if self.is_last_document_line(location): + return line_index, len(document[line_index]) + + # Go to the first section of the line below. + target_row = line_index + 1 + target_column = self._wrapped_document.get_target_document_column( + target_row, target_offset, 0 + ) + target_location = target_row, target_column + else: + # Stay on the same document line, but move forwards to + # the location on the section below with the same visual offset. + target_column = self._wrapped_document.get_target_document_column( + line_index, target_offset, section_index + 1 + ) + target_location = line_index, target_column + + return target_location + + def get_location_end(self, location: Location) -> Location: + """Get the location corresponding to the end of the current section. + + Args: + location: The current location. + + Returns: + The location corresponding to the end of the wrapped line. + """ + line_index, column_offset = location + wrap_offsets = self._wrapped_document.get_offsets(line_index) + if wrap_offsets: + # Get the next wrap offset to the right + next_offset_right = bisect(wrap_offsets, column_offset) + # There's no more wrapping to the right of this location - go to line end. + if next_offset_right == len(wrap_offsets): + return line_index, len(self._document[line_index]) + # We've found a wrap point + return line_index, wrap_offsets[next_offset_right] - 1 + else: + # No wrapping to consider - go to the start/end of the document line. + target_column = len(self._document[line_index]) + return line_index, target_column + + def get_location_home( + self, location: Location, smart_home: bool = False + ) -> Location: + """Get the "home location" corresponding to the given location. + + Args: + location: The location to consider. + smart_home: Enable/disable 'smart home' behaviour. + + Returns: + The home location, relative to the given location. + """ + line_index, column_offset = location + wrap_offsets = self._wrapped_document.get_offsets(line_index) + if wrap_offsets: + next_offset_left = bisect(wrap_offsets, column_offset) + if next_offset_left == 0: + return line_index, 0 + return line_index, wrap_offsets[next_offset_left - 1] + else: + # No wrapping to consider, go to the start of the document line + line = self._wrapped_document.document[line_index] + target_column = 0 + if smart_home: + for code_point_index, code_point in enumerate(line): + if not code_point.isspace(): + target_column = code_point_index + break + + if column_offset == 0 or column_offset > target_column: + return line_index, target_column + + return line_index, 0 + + def get_location_at_y_offset( + self, location: Location, vertical_offset: int + ) -> Location: + """Apply a visual vertical offset to a location and check the resulting location. + + Args: + location: The location to start from. + vertical_offset: The vertical offset to move (negative=up, positive=down). + + Returns: + The location after the offset has been applied. + """ + # Convert into offset-space to apply the offset. + x_offset, y_offset = self._wrapped_document.location_to_offset(location) + # Convert the offset with the delta applied back to location-space. + return self._wrapped_document.offset_to_location( + Offset(x_offset, y_offset + vertical_offset), + ) + + def clamp_reachable(self, location: Location) -> Location: + """Given a location, return the nearest location that corresponds to a + reachable location in the document. + + Args: + location: A location. + + Returns: + The nearest reachable location in the document. + """ + document = self._document + row, column = location + clamped_row = clamp(row, 0, document.line_count - 1) + + row_text = self._document[clamped_row] + clamped_column = clamp(column, 0, len(row_text)) + return clamped_row, clamped_column + + +def index(sequence: Sequence, value: Any) -> int: + """Locate the leftmost item in the sequence equal to value via bisection. + + Args: + sequence: The sequence to search in. + value: The value to find. + + Returns: + The index of the value, or -1 if the value is not found in the sequence. + """ + insert_index = bisect_left(sequence, value) + if insert_index != len(sequence) and sequence[insert_index] == value: + return insert_index + return -1 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/document/_syntax_aware_document.py b/src/textual/document/_syntax_aware_document.py index de571b70f0..32dbc191b3 100644 --- a/src/textual/document/_syntax_aware_document.py +++ b/src/textual/document/_syntax_aware_document.py @@ -58,8 +58,16 @@ def __init__( if isinstance(language, str): if language not in BUILTIN_LANGUAGES: raise SyntaxAwareDocumentError(f"Invalid language {language!r}") - self.language = get_language(language) - self._parser = get_parser(language) + # If tree-sitter-languages is not installed properly, get_language + # and get_parser may raise an OSError when unable to load their + # resources + try: + self.language = get_language(language) + self._parser = get_parser(language) + except OSError as e: + raise SyntaxAwareDocumentError( + f"Could not find binaries for {language!r}" + ) from e else: self.language = language self._parser = Parser() diff --git a/src/textual/document/_wrapped_document.py b/src/textual/document/_wrapped_document.py new file mode 100644 index 0000000000..cef7a57dda --- /dev/null +++ b/src/textual/document/_wrapped_document.py @@ -0,0 +1,452 @@ +from __future__ import annotations + +from bisect import bisect_right + +from rich.text import Text + +from textual._cells import cell_len, cell_width_to_column_index +from textual._wrap import compute_wrap_offsets +from textual.document._document import DocumentBase, Location +from textual.expand_tabs import expand_tabs_inline, get_tab_widths +from textual.geometry import Offset, clamp + +VerticalOffset = int +LineIndex = int +SectionOffset = int + + +class WrappedDocument: + """A view into a Document which wraps the document at a certain + width and can be queried to retrieve lines from the *wrapped* version + of the document. + + Allows for incremental updates, ensuring that we only re-wrap ranges of the document + that were influenced by edits. + """ + + def __init__( + self, + document: DocumentBase, + width: int = 0, + tab_width: int = 4, + ) -> None: + """Construct a WrappedDocument. + + By default, a WrappedDocument is wrapped with width=0 (no wrapping). + To wrap the document, use the wrap() method. + + Args: + document: The document to wrap. + width: The width to wrap at. + tab_width: The maximum width to consider for tab characters. + """ + self.document = document + """The document wrapping is performed on.""" + + self._wrap_offsets: list[list[int]] = [] + """Maps line indices to the offsets within the line where wrapping + breaks should be added.""" + + self._tab_width_cache: list[list[int]] = [] + """Maps line indices to a list of tab widths. `[[2, 4]]` means that on line 0, the first + tab has width 2, and the second tab has width 4.""" + + self._offset_to_line_info: list[tuple[LineIndex, SectionOffset]] = [] + """Maps y_offsets (from the top of the document) to line_index and the offset + of the section within the line.""" + + self._line_index_to_offsets: list[list[VerticalOffset]] = [] + """Maps line indices to all the vertical offsets which correspond to that line.""" + + self._width: int = width + """The width the document is currently wrapped at. This will correspond with + the value last passed into the `wrap` method.""" + + self._tab_width: int = tab_width + """The maximum width to expand tabs to when considering their widths.""" + + self.wrap(width, tab_width) + + @property + def wrapped(self) -> bool: + """True if the content is wrapped. This is not the same as wrapping being "enabled". + For example, an empty document can have wrapping enabled, but no wrapping has actually + occurred. + + In other words, this is True if the length of any line in the document is greater + than the available width.""" + return len(self._line_index_to_offsets) == len(self._offset_to_line_info) + + def wrap(self, width: int, tab_width: int | None = None) -> None: + """Wrap and cache all lines in the document. + + Args: + width: The width to wrap at. 0 for no wrapping. + tab_width: The maximum width to consider for tab characters. If None, + reuse the tab width. + """ + self._width = width + if tab_width: + self._tab_width = tab_width + + # We're starting wrapping from scratch + new_wrap_offsets: list[list[int]] = [] + offset_to_line_info: list[tuple[LineIndex, SectionOffset]] = [] + line_index_to_offsets: list[list[VerticalOffset]] = [] + line_tab_widths: list[list[int]] = [] + + append_wrap_offset = new_wrap_offsets.append + append_line_info = offset_to_line_info.append + append_line_offsets = line_index_to_offsets.append + append_line_tab_widths = line_tab_widths.append + + current_offset = 0 + tab_width = self._tab_width + for line_index, line in enumerate(self.document.lines): + tab_sections = get_tab_widths(line, tab_width) + wrap_offsets = ( + compute_wrap_offsets( + line, + width, + tab_size=tab_width, + precomputed_tab_sections=tab_sections, + ) + if width + else [] + ) + append_line_tab_widths([width for _, width in tab_sections]) + append_wrap_offset(wrap_offsets) + append_line_offsets([]) + for section_y_offset in range(len(wrap_offsets) + 1): + append_line_info((line_index, section_y_offset)) + line_index_to_offsets[line_index].append(current_offset) + current_offset += 1 + + self._wrap_offsets = new_wrap_offsets + self._offset_to_line_info = offset_to_line_info + self._line_index_to_offsets = line_index_to_offsets + self._tab_width_cache = line_tab_widths + + @property + def lines(self) -> list[list[str]]: + """The lines of the wrapped version of the Document. + + Each index in the returned list represents a line index in the raw + document. The list[str] at each index is the content of the raw document line + split into multiple lines via wrapping. + + Note that this is expensive to compute and is not cached. + + Returns: + A list of lines from the wrapped version of the document. + """ + wrapped_lines: list[list[str]] = [] + append = wrapped_lines.append + for line_index, line in enumerate(self.document.lines): + divided = Text(line).divide(self._wrap_offsets[line_index]) + append([section.plain for section in divided]) + + return wrapped_lines + + @property + def height(self) -> int: + """The height of the wrapped document.""" + return sum(len(offsets) + 1 for offsets in self._wrap_offsets) + + def wrap_range( + self, + start: Location, + old_end: Location, + new_end: Location, + ) -> None: + """Incrementally recompute wrapping based on a performed edit. + + This must be called *after* the source document has been edited. + + Args: + start: The start location of the edit that was performed in document-space. + old_end: The old end location of the edit in document-space. + new_end: The new end location of the edit in document-space. + """ + start_line_index, _ = start + old_end_line_index, _ = old_end + new_end_line_index, _ = new_end + + # Although end users should not be able to edit invalid ranges via a TextArea, + # programmers can pass whatever they wish to the edit API, so we need to clamp + # the edit ranges here to ensure we only attempt to update within the bounds + # of the wrapped document. + old_max_index = len(self._line_index_to_offsets) - 1 + new_max_index = self.document.line_count - 1 + + start_line_index = clamp( + start_line_index, 0, min((old_max_index, new_max_index)) + ) + old_end_line_index = clamp(old_end_line_index, 0, old_max_index) + new_end_line_index = clamp(new_end_line_index, 0, new_max_index) + + top_line_index, old_bottom_line_index = sorted( + (start_line_index, old_end_line_index) + ) + new_bottom_line_index = max((start_line_index, new_end_line_index)) + + top_y_offset = self._line_index_to_offsets[top_line_index][0] + old_bottom_y_offset = self._line_index_to_offsets[old_bottom_line_index][-1] + + # Get the new range of the edit from top to bottom. + new_lines = self.document.lines[top_line_index : new_bottom_line_index + 1] + + new_wrap_offsets: list[list[int]] = [] + new_line_index_to_offsets: list[list[VerticalOffset]] = [] + new_offset_to_line_info: list[tuple[LineIndex, SectionOffset]] = [] + new_tab_widths: list[list[int]] = [] + + append_wrap_offsets = new_wrap_offsets.append + append_tab_widths = new_tab_widths.append + + width = self._width + tab_width = self._tab_width + + # Add the new offsets between the top and new bottom (the new post-edit offsets) + current_y_offset = top_y_offset + for line_index, line in enumerate(new_lines, top_line_index): + tab_sections = get_tab_widths(line, tab_width) + wrap_offsets = ( + compute_wrap_offsets( + line, width, tab_width, precomputed_tab_sections=tab_sections + ) + if width + else [] + ) + append_tab_widths([width for _, width in tab_sections]) + append_wrap_offsets(wrap_offsets) + + # Collect up the new y offsets for this document line + y_offsets_for_line: list[int] = [] + for section_offset in range(len(wrap_offsets) + 1): + y_offsets_for_line.append(current_y_offset) + new_offset_to_line_info.append((line_index, section_offset)) + current_y_offset += 1 + + # Save the new y offsets for this line + new_line_index_to_offsets.append(y_offsets_for_line) + + # Replace the range start -> old with the new wrapped lines + self._offset_to_line_info[top_y_offset : old_bottom_y_offset + 1] = ( + new_offset_to_line_info + ) + + self._line_index_to_offsets[top_line_index : old_bottom_line_index + 1] = ( + new_line_index_to_offsets + ) + + self._tab_width_cache[top_line_index : old_bottom_line_index + 1] = ( + new_tab_widths + ) + + # How much did the edit/rewrap alter the offsets? + old_height = old_bottom_y_offset - top_y_offset + 1 + new_height = len(new_offset_to_line_info) + + offset_shift = new_height - old_height + line_shift = new_bottom_line_index - old_bottom_line_index + + # Update the line info at all offsets below the edit region. + if line_shift: + for y_offset in range( + top_y_offset + new_height, len(self._offset_to_line_info) + ): + old_line_index, section_offset = self._offset_to_line_info[y_offset] + new_line_index = old_line_index + line_shift + new_line_info = (new_line_index, section_offset) + self._offset_to_line_info[y_offset] = new_line_info + + # Update the offsets at all lines below the edit region + if offset_shift: + for line_index in range( + top_line_index + len(new_lines), len(self._line_index_to_offsets) + ): + old_offsets = self._line_index_to_offsets[line_index] + new_offsets = [offset + offset_shift for offset in old_offsets] + self._line_index_to_offsets[line_index] = new_offsets + + self._wrap_offsets[top_line_index : old_bottom_line_index + 1] = ( + new_wrap_offsets + ) + + def offset_to_location(self, offset: Offset) -> Location: + """Given an offset within the wrapped/visual display of the document, + return the corresponding location in the document. + + Args: + offset: The y-offset within the document. + + Raises: + ValueError: When the given offset does not correspond to a line + in the document. + + Returns: + The Location in the document corresponding to the given offset. + """ + x, y = offset + x = max(0, x) + y = max(0, y) + + if not self._width: + # No wrapping, so we directly map offset to location and clamp. + line_index = min(y, len(self._wrap_offsets) - 1) + column_index = min(x, len(self.document.get_line(line_index))) + return line_index, column_index + + # Find the line corresponding to the given y offset in the wrapped document. + get_target_document_column = self.get_target_document_column + + try: + offset_data = self._offset_to_line_info[y] + except IndexError: + # y-offset is too large + offset_data = self._offset_to_line_info[-1] + + if offset_data is not None: + line_index, section_y = offset_data + location = line_index, get_target_document_column( + line_index, + x, + section_y, + ) + else: + location = len(self._wrap_offsets) - 1, get_target_document_column( + -1, x, -1 + ) + + # Offset doesn't match any line => land on bottom wrapped line + return location + + def location_to_offset(self, location: Location) -> Offset: + """ + Convert a location in the document to an offset within the wrapped/visual display of the document. + + Args: + location: The location in the document. + + Returns: + The Offset in the document's visual display corresponding to the given location. + """ + line_index, column_index = location + + # Clamp the line index to the bounds of the document + line_index = clamp(line_index, 0, len(self._line_index_to_offsets)) + + # Find the section index of this location, so that we know which y_offset to use + wrap_offsets = self.get_offsets(line_index) + section_start_columns = [0, *wrap_offsets] + section_index = bisect_right(wrap_offsets, column_index) + + # Get the y-offsets corresponding to this line index + y_offsets = self._line_index_to_offsets[line_index] + section_column_index = column_index - section_start_columns[section_index] + + section = self.get_sections(line_index)[section_index] + x_offset = cell_len( + expand_tabs_inline(section[:section_column_index], self._tab_width) + ) + + return Offset(x_offset, y_offsets[section_index]) + + def get_target_document_column( + self, + line_index: int, + x_offset: int, + y_offset: int, + ) -> int: + """Given a line index and the offsets within the wrapped version of that + line, return the corresponding column index in the raw document. + + Args: + line_index: The index of the line in the document. + x_offset: The x-offset within the wrapped line. + y_offset: The y-offset within the wrapped line (supports negative indexing). + + Returns: + The column index corresponding to the line index and y offset. + """ + + # We've found the relevant line, now find the character by + # looking at the character corresponding to the offset width. + sections = self.get_sections(line_index) + + # wrapped_section is the text that appears on a single y_offset within + # the TextArea. It's a potentially wrapped portion of a larger line from + # the original document. + target_section = sections[y_offset] + + # Add the offsets from the wrapped sections above this one (from the same raw + # document line) + target_section_start = sum( + len(wrapped_section) for wrapped_section in sections[:y_offset] + ) + + # Get the column index within this wrapped section of the line + target_column_index = target_section_start + cell_width_to_column_index( + target_section, x_offset, self._tab_width + ) + + # If we're on the final section of a line, the cursor can legally rest beyond + # the end by a single cell. Otherwise, we'll need to ensure that we're + # keeping the cursor within the bounds of the target section. + if y_offset != len(sections) - 1 and y_offset != -1: + target_column_index = min( + target_column_index, target_section_start + len(target_section) - 1 + ) + + return target_column_index + + def get_sections(self, line_index: int) -> list[str]: + """Return the sections for the given line index. + + When wrapping is enabled, a single line in the document can visually span + multiple lines. The list returned represents that visually (each string in + the list represents a single section (y-offset) after wrapping happens). + + Args: + line_index: The index of the line to get sections for. + + Returns: + The wrapped line as a list of strings. + """ + line_offsets = self._wrap_offsets[line_index] + wrapped_lines = Text(self.document[line_index], end="").divide(line_offsets) + return [line.plain for line in wrapped_lines] + + def get_offsets(self, line_index: int) -> list[int]: + """Given a line index, get the offsets within that line where wrapping + should occur for the current document. + + Args: + line_index: The index of the line within the document. + + Raises: + ValueError: When `line_index` is out of bounds. + + Returns: + The offsets within the line where wrapping should occur. + """ + wrap_offsets = self._wrap_offsets + out_of_bounds = line_index < 0 or line_index >= len(wrap_offsets) + if out_of_bounds: + raise ValueError( + f"The document line index {line_index!r} is out of bounds. " + f"The document contains {len(wrap_offsets)!r} lines." + ) + return wrap_offsets[line_index] + + def get_tab_widths(self, line_index: int) -> list[int]: + """Return a list of the tab widths for the given line index. + + Args: + line_index: The index of the line in the document. + + Returns: + An ordered list of the expanded width of the tabs in the line. + """ + return self._tab_width_cache[line_index] diff --git a/src/textual/dom.py b/src/textual/dom.py index 461e9acea7..1a6d969cf3 100644 --- a/src/textual/dom.py +++ b/src/textual/dom.py @@ -3,7 +3,6 @@ which includes all Widgets, Screens, and Apps. """ - from __future__ import annotations import re @@ -29,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 @@ -42,11 +41,14 @@ 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 if TYPE_CHECKING: + from typing_extensions import Self, TypeAlias + from _typeshed import SupportsRichComparison + from rich.console import RenderableType from .app import App from .css.query import DOMQuery, QueryType @@ -55,7 +57,6 @@ from .screen import Screen from .widget import Widget from .worker import Worker, WorkType, ResultType - from typing_extensions import Self, TypeAlias # Unused & ignored imports are needed for the docs to link to these objects: from .css.query import NoMatches, TooManyMatches, WrongType # type: ignore # noqa: F401 @@ -69,6 +70,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.""" @@ -80,7 +84,7 @@ def check_identifiers(description: str, *names: str) -> None: description: Description of where identifier is used for error message. *names: Identifiers to check. """ - match = _re_identifier.match + match = _re_identifier.fullmatch for name in names: if match(name) is None: raise BadIdentifier( @@ -159,6 +163,9 @@ class DOMNode(MessagePump): _decorated_handlers: dict[type[Message], list[tuple[Callable, str | None]]] + # Names of potential computed reactives + _computes: ClassVar[frozenset[str]] + def __init__( self, *, @@ -195,9 +202,129 @@ 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) + if self._is_mounted: + self._initialize_data_bind() + else: + self.call_later(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=True, + ) + else: + self.call_later(partial(setter, reactive)) + self._reactive_connect = None + def compose_add_child(self, widget: Widget) -> None: """Add a node to children. @@ -219,6 +346,30 @@ def children(self) -> Sequence["Widget"]: """ return self._nodes + def sort_children( + self, + *, + key: Callable[[Widget], SupportsRichComparison] | None = None, + reverse: bool = False, + ) -> None: + """Sort child widgets with an optional key function. + + If `key` is not provided then widgets will be sorted in the order they are constructed. + + Example: + ```python + # Sort widgets by name + screen.sort_children(key=lambda widget: widget.name or "") + ``` + + Args: + key: A callable which accepts a widget and returns something that can be sorted, + or `None` to sort without a key function. + reverse: Sort in descending order. + """ + self._nodes._sort(key=key, reverse=reverse) + self.refresh(layout=True) + @property def auto_refresh(self) -> float | None: """Number of seconds between automatic refresh, or `None` for no automatic refresh.""" @@ -325,6 +476,13 @@ def __init_subclass__( css_type_names.add(base.__name__) cls._merged_bindings = cls._merge_bindings() cls._css_type_names = frozenset(css_type_names) + cls._computes = frozenset( + [ + name.lstrip("_")[8:] + for name in dir(cls) + if name.startswith(("_compute_", "compute_")) + ] + ) def get_component_styles(self, name: str) -> RenderStyles: """Get a "component" styles object (must be defined in COMPONENT_CLASSES classvar). @@ -1030,8 +1188,7 @@ def walk_children( with_self: bool = False, method: WalkMethod = "depth", reverse: bool = False, - ) -> list[WalkType]: - ... + ) -> list[WalkType]: ... @overload def walk_children( @@ -1040,8 +1197,7 @@ def walk_children( with_self: bool = False, method: WalkMethod = "depth", reverse: bool = False, - ) -> list[DOMNode]: - ... + ) -> list[DOMNode]: ... def walk_children( self, @@ -1078,20 +1234,18 @@ def walk_children( return cast("list[DOMNode]", nodes) @overload - def query(self, selector: str | None) -> DOMQuery[Widget]: - ... + def query(self, selector: str | None) -> DOMQuery[Widget]: ... @overload - def query(self, selector: type[QueryType]) -> DOMQuery[QueryType]: - ... + def query(self, selector: type[QueryType]) -> DOMQuery[QueryType]: ... def query( self, selector: str | type[QueryType] | None = None ) -> DOMQuery[Widget] | DOMQuery[QueryType]: - """Get a DOM query matching a selector. + """Query the DOM for children that match a selector or widget type. Args: - selector: A CSS selector or `None` for all nodes. + selector: A CSS selector, widget type, or `None` for all nodes. Returns: A query object. @@ -1105,26 +1259,23 @@ def query( return DOMQuery[QueryType](self, filter=selector.__name__) @overload - def query_one(self, selector: str) -> Widget: - ... + def query_one(self, selector: str) -> Widget: ... @overload - def query_one(self, selector: type[QueryType]) -> QueryType: - ... + def query_one(self, selector: type[QueryType]) -> QueryType: ... @overload - def query_one(self, selector: str, expect_type: type[QueryType]) -> QueryType: - ... + def query_one(self, selector: str, expect_type: type[QueryType]) -> QueryType: ... def query_one( self, selector: str | type[QueryType], expect_type: type[QueryType] | None = None, ) -> QueryType | Widget: - """Get a single Widget matching the given selector or selector type. + """Get a widget from this widget's children that matches a selector or widget type. Args: - selector: A selector. + selector: A selector or widget type. expect_type: Require the object be of the supplied type, or None for any type. Raises: @@ -1297,5 +1448,18 @@ def has_pseudo_classes(self, class_names: set[str]) -> bool: """ return class_names.issubset(self.get_pseudo_classes()) - def refresh(self, *, repaint: bool = True, layout: bool = False) -> Self: + def refresh( + self, *, repaint: bool = True, layout: bool = False, recompose: 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/driver.py b/src/textual/driver.py index 7cada2a473..c70edf9588 100644 --- a/src/textual/driver.py +++ b/src/textual/driver.py @@ -2,7 +2,8 @@ import asyncio from abc import ABC, abstractmethod -from typing import TYPE_CHECKING +from contextlib import contextmanager +from typing import TYPE_CHECKING, Iterator from . import events from .events import MouseUp @@ -34,12 +35,19 @@ def __init__( self._loop = asyncio.get_running_loop() self._down_buttons: list[int] = [] self._last_move_event: events.MouseMove | None = None + self._auto_restart = True + """Should the application auto-restart (where appropriate)?""" @property def is_headless(self) -> bool: """Is the driver 'headless' (no output)?""" return False + @property + def can_suspend(self) -> bool: + """Can this driver be suspended?""" + return False + def send_event(self, event: events.Event) -> None: """Send an event to the target app. @@ -58,7 +66,7 @@ def process_event(self, event: events.Event) -> None: """ # NOTE: This runs in a thread. # Avoid calling methods on the app. - event._set_sender(self._app) + event.set_sender(self._app) if isinstance(event, events.MouseDown): if event.button: self._down_buttons.append(event.button) @@ -118,5 +126,40 @@ def disable_input(self) -> None: def stop_application_mode(self) -> None: """Stop application mode, restore state.""" + def suspend_application_mode(self) -> None: + """Suspend application mode. + + Used to suspend application mode and allow uninhibited access to the + terminal. + """ + self.stop_application_mode() + self.close() + + def resume_application_mode(self) -> None: + """Resume application mode. + + Used to resume application mode after it has been previously + suspended. + """ + self.start_application_mode() + + class SignalResume(events.Event): + """Event sent to the app when a resume signal should be published.""" + + @contextmanager + def no_automatic_restart(self) -> Iterator[None]: + """A context manager used to tell the driver to not auto-restart. + + For drivers that support the application being suspended by the + operating system, this context manager is used to mark a body of + code as one that will manage its own stop and start. + """ + auto_restart = self._auto_restart + self._auto_restart = False + try: + yield + finally: + self._auto_restart = auto_restart + def close(self) -> None: """Perform any final cleanup.""" diff --git a/src/textual/drivers/linux_driver.py b/src/textual/drivers/linux_driver.py index 9b4b7c9da5..3e7958837c 100644 --- a/src/textual/drivers/linux_driver.py +++ b/src/textual/drivers/linux_driver.py @@ -42,13 +42,49 @@ def __init__( size: Initial size of the terminal or `None` to detect. """ super().__init__(app, debug=debug, size=size) - self._file = sys.__stdout__ - self.fileno = sys.stdin.fileno() + self._file = sys.__stderr__ + self.fileno = sys.__stdin__.fileno() self.attrs_before: list[Any] | None = None self.exit_event = Event() self._key_thread: Thread | None = None self._writer_thread: WriterThread | None = None + # If we've finally and properly come back from a SIGSTOP we want to + # be able to ask the app to publish its resume signal; to do that we + # need to know that we came in here via a SIGTSTP; this flag helps + # keep track of this. + self._must_signal_resume = False + + # Put handlers for SIGTSTP and SIGCONT in place. These are necessary + # to support the user pressing Ctrl+Z (or whatever the dev might + # have bound to call the relevant action on App) to suspend the + # application. + signal.signal(signal.SIGTSTP, self._sigtstp_application) + signal.signal(signal.SIGCONT, self._sigcont_application) + + def _sigtstp_application(self, *_) -> None: + """Handle a SIGTSTP signal.""" + # If we're supposed to auto-restart, that means we need to shut down + # first. + if self._auto_restart: + self.suspend_application_mode() + # Flag that we'll need to signal a resume on successful startup + # again. + self._must_signal_resume = True + # Now send a SIGSTOP to our process to *actually* suspend the + # process. + os.kill(os.getpid(), signal.SIGSTOP) + + def _sigcont_application(self, *_) -> None: + """Handle a SICONT application.""" + if self._auto_restart: + self.resume_application_mode() + + @property + def can_suspend(self) -> bool: + """Can this driver be suspended?""" + return True + def __rich_repr__(self) -> rich.repr.Result: yield self._app @@ -115,6 +151,39 @@ def write(self, data: str) -> None: def start_application_mode(self): """Start application mode.""" + + def _stop_again(*_) -> None: + """Signal handler that will put the application back to sleep.""" + os.kill(os.getpid(), signal.SIGSTOP) + + # 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() def send_size_event(): @@ -163,6 +232,7 @@ def on_terminal_resize(signum, stack) -> None: self.write("\x1b[?25l") # Hide cursor self.write("\033[?1003h\n") + self.write("\033[?1004h\n") # Enable FocusIn/FocusOut. self.flush() self._key_thread = Thread(target=self._run_input_thread) send_size_event() @@ -170,6 +240,15 @@ def on_terminal_resize(signum, stack) -> None: self._request_terminal_sync_mode_support() self._enable_bracketed_paste() + # If we need to ask the app to signal that we've come back from a + # SIGTSTP... + if self._must_signal_resume: + self._must_signal_resume = False + asyncio.run_coroutine_threadsafe( + self._app._post_message(self.SignalResume()), + loop=loop, + ) + def _request_terminal_sync_mode_support(self) -> None: """Writes an escape sequence to query the terminal support for the sync protocol.""" # Terminals should ignore this sequence if not supported. @@ -181,7 +260,19 @@ def _request_terminal_sync_mode_support(self) -> None: @classmethod def _patch_lflag(cls, attrs: int) -> int: - return attrs & ~(termios.ECHO | termios.ICANON | termios.IEXTEN | termios.ISIG) + """Patch termios lflag. + + Args: + attributes: New set attributes. + + Returns: + New lflag. + + """ + # if TEXTUAL_ALLOW_SIGNALS env var is set, then allow Ctrl+C to send signals + ISIG = 0 if os.environ.get("TEXTUAL_ALLOW_SIGNALS") else termios.ISIG + + return attrs & ~(termios.ECHO | termios.ICANON | termios.IEXTEN | ISIG) @classmethod def _patch_iflag(cls, attrs: int) -> int: @@ -226,6 +317,7 @@ def stop_application_mode(self) -> None: # Alt screen false, show cursor self.write("\x1b[?1049l" + "\x1b[?25h") + self.write("\033[?1004l\n") # Disable FocusIn/FocusOut. self.flush() def close(self) -> None: @@ -250,15 +342,16 @@ def _run_input_thread(self) -> None: def run_input_thread(self) -> None: """Wait for input and dispatch events.""" - selector = selectors.DefaultSelector() + selector = selectors.SelectSelector() selector.register(self.fileno, selectors.EVENT_READ) fileno = self.fileno + EVENT_READ = selectors.EVENT_READ def more_data() -> bool: """Check if there is more data to parse.""" - EVENT_READ = selectors.EVENT_READ - for key, events in selector.select(0.01): + + for _key, events in selector.select(0.01): if events & EVENT_READ: return True return False @@ -269,14 +362,15 @@ def more_data() -> bool: utf8_decoder = getincrementaldecoder("utf-8")().decode decode = utf8_decoder read = os.read - EVENT_READ = selectors.EVENT_READ try: while not self.exit_event.is_set(): selector_events = selector.select(0.1) for _selector_key, mask in selector_events: if mask & EVENT_READ: - unicode_data = decode(read(fileno, 1024)) + unicode_data = decode( + read(fileno, 1024), final=self.exit_event.is_set() + ) for event in feed(unicode_data): self.process_event(event) finally: diff --git a/src/textual/drivers/win32.py b/src/textual/drivers/win32.py index 5751af2caa..bfa4a11fec 100644 --- a/src/textual/drivers/win32.py +++ b/src/textual/drivers/win32.py @@ -161,8 +161,8 @@ def enable_application_mode() -> Callable[[], None]: A callable that will restore terminal to previous state. """ - terminal_in = sys.stdin - terminal_out = sys.stdout + terminal_in = sys.__stdin__ + terminal_out = sys.__stdout__ current_console_mode_in = get_console_mode(terminal_in) current_console_mode_out = get_console_mode(terminal_out) @@ -264,7 +264,7 @@ def run(self) -> None: # Key event, store unicode char in keys list key_event = input_record.Event.KeyEvent key = key_event.uChar.UnicodeChar - if key_event.bKeyDown or key == "\x1b": + if key_event.bKeyDown: if ( key_event.dwControlKeyState and key_event.wVirtualKeyCode == 0 diff --git a/src/textual/drivers/windows_driver.py b/src/textual/drivers/windows_driver.py index faf2c0a52c..1df31728ac 100644 --- a/src/textual/drivers/windows_driver.py +++ b/src/textual/drivers/windows_driver.py @@ -37,6 +37,11 @@ def __init__( self._restore_console: Callable[[], None] | None = None self._writer_thread: WriterThread | None = None + @property + def can_suspend(self) -> bool: + """Can this driver be suspended?""" + return True + def write(self, data: str) -> None: """Write data to the output device. @@ -85,6 +90,7 @@ def start_application_mode(self) -> None: self._enable_mouse_support() self.write("\x1b[?25l") # Hide cursor self.write("\033[?1003h\n") + self.write("\033[?1004h\n") # Enable FocusIn/FocusOut. self._enable_bracketed_paste() self._event_thread = win32.EventMonitor( @@ -113,6 +119,7 @@ def stop_application_mode(self) -> None: # Disable alt screen, show cursor self.write("\x1b[?1049l" + "\x1b[?25h") + self.write("\033[?1004l\n") # Disable FocusIn/FocusOut. self.flush() def close(self) -> None: diff --git a/src/textual/eta.py b/src/textual/eta.py new file mode 100644 index 0000000000..3edb004696 --- /dev/null +++ b/src/textual/eta.py @@ -0,0 +1,129 @@ +from __future__ import annotations + +import bisect +from math import ceil +from time import monotonic + +import rich.repr + + +@rich.repr.auto(angular=True) +class ETA: + """Calculate speed and estimate time to arrival.""" + + def __init__( + self, estimation_period: float = 60, extrapolate_period: float = 30 + ) -> None: + """Create an ETA. + + Args: + estimation_period: Period in seconds, used to calculate speed. + extrapolate_period: Maximum number of seconds used to estimate progress after last sample. + """ + self.estimation_period = estimation_period + self.max_extrapolate = extrapolate_period + self._samples: list[tuple[float, float]] = [(0.0, 0.0)] + self._add_count = 0 + + def __rich_repr__(self) -> rich.repr.Result: + yield "speed", self.speed + yield "eta", self.get_eta(monotonic()) + + @property + def first_sample(self) -> tuple[float, float]: + """First sample.""" + assert self._samples, "Assumes samples not empty" + return self._samples[0] + + @property + def last_sample(self) -> tuple[float, float]: + """Last sample.""" + assert self._samples, "Assumes samples not empty" + return self._samples[-1] + + def reset(self) -> None: + """Start ETA calculations from current time.""" + del self._samples[:] + + def add_sample(self, time: float, progress: float) -> None: + """Add a new sample. + + Args: + time: Time when sample occurred. + progress: Progress ratio (0 is start, 1 is complete). + """ + if self._samples and self.last_sample[1] > progress: + # If progress goes backwards, we need to reset calculations + self.reset() + self._samples.append((time, progress)) + self._add_count += 1 + if self._add_count % 100 == 0: + # Prune periodically so we don't accumulate vast amounts of samples + self._prune() + + def _prune(self) -> None: + """Prune old samples.""" + if len(self._samples) <= 10: + # Keep at least 10 samples + return + prune_time = self._samples[-1][0] - self.estimation_period + index = bisect.bisect_left(self._samples, (prune_time, 0)) + del self._samples[:index] + + def _get_progress_at(self, time: float) -> tuple[float, float]: + """Get the progress at a specific time.""" + + index = bisect.bisect_left(self._samples, (time, 0)) + if index >= len(self._samples): + return self.last_sample + if index == 0: + return self.first_sample + # Linearly interpolate progress between two samples + time1, progress1 = self._samples[index - 1] + time2, progress2 = self._samples[index] + factor = (time - time1) / (time2 - time1) + intermediate_progress = progress1 + (progress2 - progress1) * factor + return time, intermediate_progress + + @property + def speed(self) -> float | None: + """The current speed, or `None` if it couldn't be calculated.""" + + if len(self._samples) < 2: + # Need at least 2 samples to calculate speed + return None + + recent_sample_time, progress2 = self.last_sample + progress_start_time, progress1 = self._get_progress_at( + recent_sample_time - self.estimation_period + ) + if recent_sample_time - progress_start_time < 1: + # Require at least a second span to calculate speed. + return None + time_delta = recent_sample_time - progress_start_time + distance = progress2 - progress1 + speed = distance / time_delta if time_delta else 0 + return speed + + def get_eta(self, time: float) -> int | None: + """Estimated seconds until completion, or `None` if no estimate can be made. + + Args: + time: Current time. + """ + speed = self.speed + if not speed: + # Not enough samples to guess + return None + recent_time, recent_progress = self.last_sample + remaining = 1.0 - recent_progress + if remaining <= 0: + # Complete + return 0 + # The bar is not complete, so we will extrapolate progress + # This will give us a countdown, even with no samples + time_since_sample = min(self.max_extrapolate, time - recent_time) + extrapolate_progress = speed * time_since_sample + # We don't want to extrapolate all the way to 0, as that would erroneously suggest it is finished + eta = max(1.0, (remaining - extrapolate_progress) / speed) + return ceil(eta) diff --git a/src/textual/events.py b/src/textual/events.py index a5518407f9..79c7c2366a 100644 --- a/src/textual/events.py +++ b/src/textual/events.py @@ -110,8 +110,11 @@ def __init__( container_size: Size | None = None, ) -> None: self.size = size + """The new size of the Widget.""" self.virtual_size = virtual_size + """The virtual size (scrollable size) of the Widget.""" self.container_size = size if container_size is None else container_size + """The size of the Widget's container widget.""" super().__init__() def can_replace(self, message: "Message") -> bool: @@ -190,6 +193,7 @@ class MouseCapture(Event, bubble=False): def __init__(self, mouse_position: Offset) -> None: super().__init__() self.mouse_position = mouse_position + """The position of the mouse when captured.""" def __rich_repr__(self) -> rich.repr.Result: yield None, self.mouse_position @@ -209,6 +213,7 @@ class MouseRelease(Event, bubble=False): def __init__(self, mouse_position: Offset) -> None: super().__init__() self.mouse_position = mouse_position + """The position of the mouse when released.""" def __rich_repr__(self) -> rich.repr.Result: yield None, self.mouse_position @@ -228,8 +233,6 @@ class Key(InputEvent): Args: key: The key that was pressed. character: A printable character or ``None`` if it is not printable. - Attributes: - aliases: The aliases for the key, including the key itself. """ __slots__ = ["key", "character", "aliases"] @@ -237,10 +240,13 @@ class Key(InputEvent): def __init__(self, key: str, character: str | None) -> None: super().__init__() self.key = key + """The key that was pressed.""" self.character = ( (key if len(key) == 1 else None) if character is None else character ) + """A printable character or ``None`` if it is not printable.""" self.aliases: list[str] = _get_key_aliases(key) + """The aliases for the key, including the key itself.""" def __rich_repr__(self) -> rich.repr.Result: yield "key", self.key @@ -264,7 +270,7 @@ def is_printable(self) -> bool: """Check if the key is printable (produces a unicode character). Returns: - True if the key is printable. + `True` if the key is printable. """ return False if self.character is None else self.character.isprintable() @@ -327,15 +333,25 @@ def __init__( ) -> None: super().__init__() self.x = x + """The relative x coordinate.""" self.y = y + """The relative y coordinate.""" self.delta_x = delta_x + """Change in x since the last message.""" self.delta_y = delta_y + """Change in y since the last message.""" self.button = button + """Indexed of the pressed button.""" self.shift = shift + """`True` if the shift key is pressed.""" self.meta = meta + """`True` if the meta key is pressed.""" self.ctrl = ctrl + """`True` if the ctrl key is pressed.""" self.screen_x = x if screen_x is None else screen_x + """The absolute x coordinate.""" self.screen_y = y if screen_y is None else screen_y + """The absolute y coordinate.""" self._style = style or Style() @classmethod @@ -380,20 +396,12 @@ def offset(self) -> Offset: @property def screen_offset(self) -> Offset: - """Mouse coordinate relative to the screen. - - Returns: - Mouse coordinate. - """ + """Mouse coordinate relative to the screen.""" return Offset(self.screen_x, self.screen_y) @property def delta(self) -> Offset: - """Mouse coordinate delta (change since last event). - - Returns: - Mouse coordinate. - """ + """Mouse coordinate delta (change since last event).""" return Offset(self.delta_x, self.delta_y) @property @@ -475,7 +483,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 +492,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 @@ -563,20 +571,24 @@ class Blur(Event, bubble=False): class AppFocus(Event, bubble=False): """Sent when the app has focus. - Used by textual-web. - - [ ] Bubbles - [ ] Verbose + + Note: + Only available when running within a terminal that supports + `FocusIn`, or when running via textual-web. """ class AppBlur(Event, bubble=False): """Sent when the app loses focus. - Used by textual-web. - - [ ] Bubbles - [ ] Verbose + + Note: + Only available when running within a terminal that supports + `FocusOut`, or when running via textual-web. """ @@ -632,6 +644,7 @@ class Paste(Event, bubble=True): def __init__(self, text: str) -> None: super().__init__() self.text = text + """The text that was pasted.""" def __rich_repr__(self) -> rich.repr.Result: yield "text", self.text @@ -655,21 +668,26 @@ class ScreenSuspend(Event, bubble=False): @rich.repr.auto class Print(Event, bubble=False): - """Sent to a widget that is capturing prints. + """Sent to a widget that is capturing [`print`][print]. - [ ] Bubbles - [ ] Verbose Args: text: Text that was printed. - stderr: True if the print was to stderr, or False for stdout. + stderr: `True` if the print was to stderr, or `False` for stdout. + Note: + Python's [`print`][print] output can be captured with + [`App.begin_capture_print`][textual.app.App.begin_capture_print]. """ def __init__(self, text: str, stderr: bool = False) -> None: super().__init__() self.text = text + """The text that was printed.""" self.stderr = stderr + """`True` if the print was to stderr, or `False` for stdout.""" def __rich_repr__(self) -> rich.repr.Result: yield self.text diff --git a/src/textual/expand_tabs.py b/src/textual/expand_tabs.py index 721da64d68..9026a6fabe 100644 --- a/src/textual/expand_tabs.py +++ b/src/textual/expand_tabs.py @@ -3,6 +3,7 @@ import re from rich.cells import cell_len +from rich.text import Text _TABS_SPLITTER_RE = re.compile(r"(.*?\t|.+?$)") @@ -21,7 +22,6 @@ def get_tab_widths(line: str, tab_size: int = 4) -> list[tuple[str, int]]: Returns: A list of tuples representing the line split on tab characters, and the widths of the tabs after tab expansion is applied. - """ parts: list[tuple[str, int]] = [] @@ -61,6 +61,39 @@ def expand_tabs_inline(line: str, tab_size: int = 4) -> str: ) +def expand_text_tabs_from_widths(line: Text, tab_widths: list[int]) -> Text: + """Expand tabs to the widths defined in the `tab_widths` list. + + This will return a new Text instance with tab characters expanded into a + number of spaces. Each time a tab is encountered, it's expanded into the + next integer encountered in the `tab_widths` list. Consequently, the length + of `tab_widths` should match the number of tab chracters in `line`. + + Args: + line: The `Text` instance to expand tabs in. + tab_widths: The widths to expand tabs to. + + Returns: + A new text instance with tab characters converted to spaces. + """ + if "\t" not in line.plain: + return line + + parts = line.split("\t", include_separator=True) + tab_widths_iter = iter(tab_widths) + + new_parts: list[Text] = [] + append_part = new_parts.append + for part in parts: + if part.plain.endswith("\t"): + part._text[-1] = part._text[-1][:-1] + " " + spaces = next(tab_widths_iter) + part.extend_style(spaces - 1) + append_part(part) + + return Text("", end="").join(new_parts) + + if __name__ == "__main__": print(expand_tabs_inline("\tbar")) print(expand_tabs_inline("\tbar\t")) diff --git a/src/textual/file_monitor.py b/src/textual/file_monitor.py index 3f7bbb3fca..36a4180bb2 100644 --- a/src/textual/file_monitor.py +++ b/src/textual/file_monitor.py @@ -31,7 +31,14 @@ def __rich_repr__(self) -> rich.repr.Result: def _get_last_modified_time(self) -> float: """Get the most recent modified time out of all files being watched.""" - return max((os.stat(path).st_mtime for path in self._paths), default=0) + modified_times = [] + for path in self._paths: + try: + modified_time = os.stat(path).st_mtime + except FileNotFoundError: + modified_time = 0 + modified_times.append(modified_time) + return max(modified_times, default=0) def check(self) -> bool: """Check the monitored files. Return True if any were changed since the last modification time.""" diff --git a/src/textual/filter.py b/src/textual/filter.py index 7494d9a52a..2963689ebd 100644 --- a/src/textual/filter.py +++ b/src/textual/filter.py @@ -188,7 +188,7 @@ def __init__(self, terminal_theme: TerminalTheme): Args: terminal_theme: A rich terminal theme. """ - self.terminal_theme = terminal_theme + self._terminal_theme = terminal_theme @lru_cache(1024) def truecolor_style(self, style: Style) -> Style: @@ -200,7 +200,7 @@ def truecolor_style(self, style: Style) -> Style: Returns: New style. """ - terminal_theme = self.terminal_theme + terminal_theme = self._terminal_theme color = style.color if color is not None and color.is_system_defined: color = RichColor.from_rgb( @@ -211,6 +211,7 @@ def truecolor_style(self, style: Style) -> Style: bgcolor = RichColor.from_rgb( *bgcolor.get_truecolor(terminal_theme, foreground=False) ) + return style + Style.from_color(color, bgcolor) def apply(self, segments: list[Segment], background: Color) -> list[Segment]: diff --git a/src/textual/keys.py b/src/textual/keys.py index ef32404d16..36da438d24 100644 --- a/src/textual/keys.py +++ b/src/textual/keys.py @@ -283,6 +283,9 @@ def _get_key_display(key: str) -> str: """Given a key (i.e. the `key` string argument to Binding __init__), return the value that should be displayed in the app when referring to this key (e.g. in the Footer widget).""" + if "+" in key: + return "+".join([_get_key_display(key) for key in key.split("+")]) + display_alias = KEY_DISPLAY_ALIASES.get(key) if display_alias: return display_alias diff --git a/src/textual/layouts/grid.py b/src/textual/layouts/grid.py index d38c74a7d7..077b905074 100644 --- a/src/textual/layouts/grid.py +++ b/src/textual/layouts/grid.py @@ -211,7 +211,7 @@ def apply_height_limits(widget: Widget, height: int) -> int: widget.get_content_height( size, viewport, - column_width - parent.styles.grid_gutter_vertical, + column_width, ) + widget.styles.gutter.height, ) diff --git a/src/textual/layouts/horizontal.py b/src/textual/layouts/horizontal.py index 9d5697f0fe..02672ae879 100644 --- a/src/textual/layouts/horizontal.py +++ b/src/textual/layouts/horizontal.py @@ -26,7 +26,9 @@ def arrange( add_placement = placements.append child_styles = [child.styles for child in children] - box_margins: list[Spacing] = [styles.margin for styles in child_styles] + box_margins: list[Spacing] = [ + styles.margin for styles in child_styles if styles.overlay != "screen" + ] if box_margins: resolve_margin = Size( sum( @@ -36,7 +38,7 @@ def arrange( ] ) + (box_margins[0].left + box_margins[-1].right), - max( + min( [ margin_top + margin_bottom for margin_top, _, margin_bottom, _ in box_margins diff --git a/src/textual/layouts/vertical.py b/src/textual/layouts/vertical.py index a81518f1d2..995b135439 100644 --- a/src/textual/layouts/vertical.py +++ b/src/textual/layouts/vertical.py @@ -29,7 +29,7 @@ def arrange( ] if box_margins: resolve_margin = Size( - max( + min( [ margin_right + margin_left for _, margin_right, _, margin_left in box_margins diff --git a/src/textual/lazy.py b/src/textual/lazy.py index ef8d6bfbd4..bbfe5762af 100644 --- a/src/textual/lazy.py +++ b/src/textual/lazy.py @@ -2,7 +2,6 @@ Tools for lazy loading widgets. """ - from __future__ import annotations from .widget import Widget diff --git a/src/textual/logging.py b/src/textual/logging.py index 4c2563a237..106a4d318b 100644 --- a/src/textual/logging.py +++ b/src/textual/logging.py @@ -6,7 +6,6 @@ If there is *no* active app, then log messages will go to stderr or stdout, depending on configuration. """ - import sys from logging import Handler, LogRecord diff --git a/src/textual/message.py b/src/textual/message.py index 931c5aa21b..97d6b6c40e 100644 --- a/src/textual/message.py +++ b/src/textual/message.py @@ -8,14 +8,15 @@ from typing import TYPE_CHECKING, ClassVar import rich.repr +from typing_extensions import Self from . import _time from ._context import active_message_pump from .case import camel_to_snake if TYPE_CHECKING: + from .dom import DOMNode from .message_pump import MessagePump - from .widget import Widget @rich.repr.auto @@ -77,7 +78,7 @@ def __init_subclass__( cls.handler_name = f"on_{namespace}_{name}" if namespace else f"on_{name}" @property - def control(self) -> Widget | None: + def control(self) -> DOMNode | None: """The widget associated with this message, or None by default.""" return None @@ -90,9 +91,23 @@ def _set_forwarded(self) -> None: """Mark this event as being forwarded.""" self._forwarded = True - def _set_sender(self, sender: MessagePump) -> None: - """Set the sender.""" + def set_sender(self, sender: MessagePump) -> Self: + """Set the sender of the message. + + Args: + sender: The sender. + + Note: + When creating a message the sender is automatically set. + Normally there will be no need for this method to be called. + This method will be used when strict control is required over + the sender of a message. + + Returns: + Self. + """ self._sender = sender + return self def can_replace(self, message: "Message") -> bool: """Check if another message may supersede this one. diff --git a/src/textual/message_pump.py b/src/textual/message_pump.py index 808422cdce..7c9ef51b0e 100644 --- a/src/textual/message_pump.py +++ b/src/textual/message_pump.py @@ -7,6 +7,7 @@ Most of the method here are useful in general app development. """ + from __future__ import annotations import asyncio @@ -15,7 +16,17 @@ from asyncio import CancelledError, Queue, QueueEmpty, Task, create_task from contextlib import contextmanager from functools import partial -from typing import TYPE_CHECKING, Any, Awaitable, Callable, Generator, Iterable, cast +from typing import ( + TYPE_CHECKING, + Any, + Awaitable, + Callable, + Generator, + Iterable, + Type, + TypeVar, + cast, +) from weakref import WeakSet from . import Logger, events, log, messages @@ -51,18 +62,21 @@ class MessagePumpClosed(Exception): pass +_MessagePumpMetaSub = TypeVar("_MessagePumpMetaSub", bound="_MessagePumpMeta") + + class _MessagePumpMeta(type): """Metaclass for message pump. This exists to populate a Message inner class of a Widget with the parent classes' name. """ def __new__( - cls, + cls: Type[_MessagePumpMetaSub], name: str, bases: tuple[type, ...], class_dict: dict[str, Any], - **kwargs, - ): + **kwargs: Any, + ) -> _MessagePumpMetaSub: namespace = camel_to_snake(name) isclass = inspect.isclass handlers: dict[ @@ -188,6 +202,11 @@ def has_parent(self) -> bool: """Does this object have a parent?""" return self._parent is not None + @property + def message_queue_size(self) -> int: + """The current size of the message queue.""" + return self._message_queue.qsize() + @property def app(self) -> "App[object]": """ @@ -586,7 +605,8 @@ async def _flush_next_callbacks(self) -> None: self._next_callbacks.clear() for callback in callbacks: try: - await self._dispatch_message(callback) + with self.prevent(*callback._prevent): + await invoke(callback.callback) except Exception as error: self.app._handle_exception(error) break diff --git a/src/textual/notifications.py b/src/textual/notifications.py index e1a9fbae44..27f95ccd6d 100644 --- a/src/textual/notifications.py +++ b/src/textual/notifications.py @@ -36,7 +36,7 @@ class Notification: severity: SeverityLevel = "information" """The severity level for the notification.""" - timeout: float = 3 + timeout: float = 5 """The timeout for the notification.""" raised_at: float = field(default_factory=time) diff --git a/src/textual/pilot.py b/src/textual/pilot.py index a252615621..8226eb97ee 100644 --- a/src/textual/pilot.py +++ b/src/textual/pilot.py @@ -15,8 +15,9 @@ from ._wait import wait_for_idle from .app import App, ReturnType -from .events import Click, MouseDown, MouseEvent, MouseMove, MouseUp -from .geometry import Offset +from .drivers.headless_driver import HeadlessDriver +from .events import Click, MouseDown, MouseEvent, MouseMove, MouseUp, Resize +from .geometry import Offset, Size from .widget import Widget @@ -81,6 +82,20 @@ async def press(self, *keys: str) -> None: await self._app._press_keys(keys) await self._wait_for_screen() + async def resize_terminal(self, width: int, height: int) -> None: + """Resize the terminal to the given dimensions. + + Args: + width: The new width of the terminal. + height: The new height of the terminal. + """ + size = Size(width, height) + # If we're running with the headless driver, update the inherent app size. + if isinstance(self.app._driver, HeadlessDriver): + self.app._driver._size = size + self.app.post_message(Resize(size, size)) + await self.pause() + async def mouse_down( self, selector: type[Widget] | str | None = None, @@ -328,7 +343,11 @@ async def _post_mouse_events( # the driver works and emits a click event. widget_at, _ = app.get_widget_at(*offset) event = mouse_event_cls(**message_arguments) - # Bypass event processing in App.on_event + # Bypass event processing in App.on_event. Because App.on_event + # is responsible for updating App.mouse_position, and because + # that's useful to other things (tooltip handling, for example), + # we patch the offset in there as well. + app.mouse_position = offset app.screen._forward_event(event) await self.pause() diff --git a/src/textual/reactive.py b/src/textual/reactive.py index d361bd0049..41b240c1b2 100644 --- a/src/textual/reactive.py +++ b/src/textual/reactive.py @@ -16,13 +16,22 @@ Generic, Type, TypeVar, + cast, + overload, ) import rich.repr from . import events from ._callback import count_parameters -from ._types import MessageTarget, WatchCallbackType +from ._context import active_message_pump +from ._types import ( + MessageTarget, + WatchCallbackBothValuesType, + WatchCallbackNewValueType, + WatchCallbackNoArgsType, + WatchCallbackType, +) if TYPE_CHECKING: from .dom import DOMNode @@ -30,12 +39,60 @@ 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) + reset_token = active_message_pump.set(watcher_object) + try: + 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) + ) + finally: + active_message_pump.reset(reset_token) + + @rich.repr.auto class Reactive(Generic[ReactiveType]): """Reactive descriptor. @@ -47,6 +104,7 @@ class Reactive(Generic[ReactiveType]): init: Call watchers on initialize (post mount). always_update: Call watchers even when the new value equals the old value. compute: Run compute methods when attribute is changed. + recompose: Compose the widget again when the attribute changes. """ _reactives: ClassVar[dict[str, object]] = {} @@ -60,6 +118,7 @@ def __init__( init: bool = False, always_update: bool = False, compute: bool = True, + recompose: bool = False, ) -> None: self._default = default self._layout = layout @@ -67,6 +126,8 @@ def __init__( self._init = init self._always_update = always_update self._run_compute = compute + self._recompose = recompose + self._owner: Type[MessageTarget] | None = None def __rich_repr__(self) -> rich.repr.Result: yield self._default @@ -75,6 +136,13 @@ def __rich_repr__(self) -> rich.repr.Result: yield "init", self._init yield "always_update", self._always_update yield "compute", self._run_compute + yield "recompose", self._recompose + + @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 +194,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 +217,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 +247,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 +257,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): @@ -195,11 +290,15 @@ def __set__(self, obj: Reactable, value: ReactiveType) -> None: self._compute(obj) # Refresh according to descriptor flags - if self._layout or self._repaint: - obj.refresh(repaint=self._repaint, layout=self._layout) + if self._layout or self._repaint or self._recompose: + obj.refresh( + repaint=self._repaint, + layout=self._layout, + recompose=self._recompose, + ) @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 +311,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 +320,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]): @@ -275,7 +341,7 @@ def _compute(cls, obj: Reactable) -> None: obj: Reactable object. """ _rich_traceback_guard = True - for compute in obj._reactives.keys(): + for compute in obj._reactives.keys() & obj._computes: try: compute_method = getattr(obj, f"compute_{compute}") except AttributeError: @@ -311,6 +377,7 @@ def __init__( repaint: bool = True, init: bool = True, always_update: bool = False, + recompose: bool = False, ) -> None: super().__init__( default, @@ -318,6 +385,7 @@ def __init__( repaint=repaint, init=init, always_update=always_update, + recompose=recompose, ) @@ -356,6 +424,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 +432,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/sparkline.py b/src/textual/renderables/sparkline.py index 8b22aff02b..4da25fa233 100644 --- a/src/textual/renderables/sparkline.py +++ b/src/textual/renderables/sparkline.py @@ -45,7 +45,7 @@ def __init__( self.summary_function: SummaryFunction[T] = summary_function @classmethod - def _buckets(cls, data: Sequence[T], num_buckets: int) -> Iterable[Sequence[T]]: + def _buckets(cls, data: list[T], num_buckets: int) -> Iterable[Sequence[T]]: """Partition ``data`` into ``num_buckets`` buckets. For example, the data [1, 2, 3, 4] partitioned into 2 buckets is [[1, 2], [3, 4]]. @@ -76,7 +76,7 @@ def __rich_console__( minimum, maximum = min(self.data), max(self.data) extent = maximum - minimum or 1 - buckets = tuple(self._buckets(self.data, num_buckets=width)) + buckets = tuple(self._buckets(list(self.data), num_buckets=width)) bucket_index = 0.0 bars_rendered = 0 diff --git a/src/textual/renderables/tint.py b/src/textual/renderables/tint.py index ebdd38105e..a11450e2e3 100644 --- a/src/textual/renderables/tint.py +++ b/src/textual/renderables/tint.py @@ -2,11 +2,13 @@ from typing import Iterable -from rich.console import Console, ConsoleOptions, RenderableType, RenderResult +from rich.console import RenderableType from rich.segment import Segment from rich.style import Style +from rich.terminal_theme import TerminalTheme from ..color import Color +from ..filter import ANSIToTruecolor class Tint: @@ -28,13 +30,14 @@ def __init__( @classmethod def process_segments( - cls, segments: Iterable[Segment], color: Color + cls, segments: Iterable[Segment], color: Color, ansi_theme: TerminalTheme ) -> Iterable[Segment]: """Apply tint to segments. Args: segments: Incoming segments. color: Color of tint. + ansi_theme: The TerminalTheme defining how to map ansi colors to hex. Returns: Segments with applied tint. @@ -43,13 +46,15 @@ def process_segments( style_from_color = Style.from_color _Segment = Segment + truecolor_style = ANSIToTruecolor(ansi_theme).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, ( @@ -69,10 +74,3 @@ def process_segments( ), control, ) - - def __rich_console__( - self, console: Console, options: ConsoleOptions - ) -> RenderResult: - segments = console.render(self.renderable, options) - color = self.color - return self.process_segments(segments, color) diff --git a/src/textual/screen.py b/src/textual/screen.py index 938508b6c3..d29022d3a5 100644 --- a/src/textual/screen.py +++ b/src/textual/screen.py @@ -42,6 +42,7 @@ from .reactive import Reactive from .renderables.background_screen import BackgroundScreen from .renderables.blank import Blank +from .signal import Signal from .timer import Timer from .widget import Widget from .widgets import Tooltip @@ -213,6 +214,9 @@ def __init__( self.title = self.TITLE self.sub_title = self.SUB_TITLE + self.screen_layout_refresh_signal = Signal(self, "layout-refresh") + """The signal that is published when the screen's layout is refreshed.""" + @property def is_modal(self) -> bool: """Is the screen modal?""" @@ -285,6 +289,9 @@ def get_widget_at(self, x: int, y: int) -> tuple[Widget, Region]: Returns: Widget and screen region. + + Raises: + NoWidget: If there is no widget under the screen coordinate. """ return self._compositor.get_widget_at(x, y) @@ -300,6 +307,29 @@ def get_widgets_at(self, x: int, y: int) -> Iterable[tuple[Widget, Region]]: """ return self._compositor.get_widgets_at(x, y) + def get_focusable_widget_at(self, x: int, y: int) -> Widget | None: + """Get the focusable widget under a given coordinate. + + If the widget directly under the given coordinate is not focusable, then this method will check + if any of the ancestors are focusable. If no ancestors are focusable, then `None` will be returned. + + Args: + x: X coordinate. + y: Y coordinate. + + Returns: + A `Widget`, or `None` if there is no focusable widget underneath the coordinate. + """ + try: + widget, _region = self.get_widget_at(x, y) + except NoWidget: + return None + + for node in widget.ancestors_with_self: + if isinstance(node, Widget) and node.focusable: + return node + return None + def get_style_at(self, x: int, y: int) -> Style: """Get the style under a given coordinate. @@ -611,6 +641,10 @@ def _extend_compose(self, widgets: list[Widget]) -> None: if not self.app._disable_notifications: widgets.insert(0, ToastRack(id="textual-toastrack")) + def _on_mount(self, event: events.Mount) -> None: + """Set up the tooltip-clearing signal when we mount.""" + self.screen_layout_refresh_signal.subscribe(self, self._maybe_clear_tooltip) + async def _on_idle(self, event: events.Idle) -> None: # Check for any widgets marked as 'dirty' (needs a repaint) event.prevent_default() @@ -620,6 +654,7 @@ async def _on_idle(self, event: events.Idle) -> None: self._layout_required or self._scroll_required or self._repaint_required + or self._recompose_required or self._dirty_widgets ): self._update_timer.resume() @@ -664,6 +699,10 @@ def _on_timer_update(self) -> None: self._compositor.update_widgets(self._dirty_widgets) self._compositor_refresh() + if self._recompose_required: + self._recompose_required = False + self.call_next(self.recompose) + if self._callbacks: self.call_next(self._invoke_and_clear_callbacks) @@ -712,9 +751,7 @@ def _pop_result_callback(self) -> None: """Remove the latest result callback from the stack.""" self._result_callbacks.pop() - def _refresh_layout( - self, size: Size | None = None, full: bool = False, scroll: bool = False - ) -> None: + def _refresh_layout(self, size: Size | None = None, scroll: bool = False) -> None: """Refresh the layout (can change size and positions of widgets).""" size = self.outer_size if size is None else size if not size: @@ -783,7 +820,9 @@ def _refresh_layout( if self.is_current: self._compositor_refresh() - if not self.app._dom_ready: + if self.app._dom_ready: + self.screen_layout_refresh_signal.publish() + else: self.app.post_message(events.Ready()) self.app._dom_ready = True @@ -809,7 +848,7 @@ async def _on_update_scroll(self, message: messages.UpdateScroll) -> None: def _screen_resized(self, size: Size): """Called by App when the screen is resized.""" - self._refresh_layout(size, full=True) + self._refresh_layout(size) self.refresh() def _on_screen_resume(self) -> None: @@ -817,7 +856,7 @@ def _on_screen_resume(self) -> None: self.stack_updates += 1 self.app._refresh_notifications() size = self.app.size - self._refresh_layout(size, full=True) + self._refresh_layout(size) self.refresh() # Only auto-focus when the app has focus (textual-web only) if self.app.app_focus: @@ -833,6 +872,7 @@ def _on_screen_resume(self) -> None: def _on_screen_suspend(self) -> None: """Screen has suspended.""" self.app._set_mouse_over(None) + self._clear_tooltip() self.stack_updates += 1 async def _on_resize(self, event: events.Resize) -> None: @@ -851,6 +891,35 @@ def _update_tooltip(self, widget: Widget) -> None: if tooltip.display and self._tooltip_widget is widget: self._handle_tooltip_timer(widget) + def _clear_tooltip(self) -> None: + """Unconditionally clear any existing tooltip.""" + try: + tooltip = self.get_child_by_type(Tooltip) + except NoMatches: + return + if tooltip.display: + if self._tooltip_timer is not None: + self._tooltip_timer.stop() + tooltip.display = False + + def _maybe_clear_tooltip(self) -> None: + """Check if the widget under the mouse cursor still pertains to the tooltip. + + If they differ, the tooltip will be removed. + """ + # If there's a widget associated with the tooltip at all... + if self._tooltip_widget is not None: + # ...look at what's currently under the mouse. + try: + under_mouse, _ = self.get_widget_at(*self.app.mouse_position) + except NoWidget: + pass + else: + # If it's not the same widget... + if under_mouse is not self._tooltip_widget: + # ...clear the tooltip. + self._clear_tooltip() + def _handle_tooltip_timer(self, widget: Widget) -> None: """Called by a timer from _handle_mouse_move to update the tooltip. @@ -967,8 +1036,10 @@ def _forward_event(self, event: events.Event) -> None: except errors.NoWidget: self.set_focus(None) else: - if isinstance(event, events.MouseDown) and widget.focusable: - self.set_focus(widget, scroll_visible=False) + if isinstance(event, events.MouseDown): + focusable_widget = self.get_focusable_widget_at(event.x, event.y) + if focusable_widget: + self.set_focus(focusable_widget, scroll_visible=False) event.style = self.get_style_at(event.screen_x, event.screen_y) if widget is self: event._set_forwarded() diff --git a/src/textual/scroll_view.py b/src/textual/scroll_view.py index a4e3aa03d8..4e418ed350 100644 --- a/src/textual/scroll_view.py +++ b/src/textual/scroll_view.py @@ -7,7 +7,7 @@ from rich.console import RenderableType from ._animator import EasingFunction -from ._types import CallbackType +from ._types import AnimationLevel, CallbackType from .containers import ScrollableContainer from .geometry import Region, Size @@ -119,6 +119,7 @@ def scroll_to( easing: EasingFunction | str | None = None, force: bool = False, on_complete: CallbackType | None = None, + level: AnimationLevel = "basic", ) -> None: """Scroll to a given (absolute) coordinate, optionally animating. @@ -131,6 +132,7 @@ def scroll_to( easing: An easing method for the scrolling animation. force: Force scrolling even when prohibited by overflow styling. on_complete: A callable to invoke when the animation is finished. + level: Minimum level required for the animation to take place (inclusive). """ self._scroll_to( @@ -142,6 +144,22 @@ def scroll_to( easing=easing, force=force, on_complete=on_complete, + 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: @@ -152,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/scrollbar.py b/src/textual/scrollbar.py index 31ae35b18f..2700419192 100644 --- a/src/textual/scrollbar.py +++ b/src/textual/scrollbar.py @@ -3,6 +3,7 @@ You will not need to use the widgets defined in this module. """ + from __future__ import annotations from math import ceil @@ -146,18 +147,22 @@ def render_bar( if bar_character != " ": segments[start_index] = _Segment( bar_character * width_thickness, - _Style(bgcolor=back, color=bar, meta=foreground_meta) - if vertical - else _Style(bgcolor=bar, color=back, meta=foreground_meta), + ( + _Style(bgcolor=back, color=bar, meta=foreground_meta) + if vertical + else _Style(bgcolor=bar, color=back, meta=foreground_meta) + ), ) if end_index < len(segments): bar_character = bars[len_bars - 1 - end_bar] if bar_character != " ": segments[end_index] = _Segment( bar_character * width_thickness, - _Style(bgcolor=bar, color=back, meta=foreground_meta) - if vertical - else _Style(bgcolor=back, color=bar, meta=foreground_meta), + ( + _Style(bgcolor=bar, color=back, meta=foreground_meta) + if vertical + else _Style(bgcolor=back, color=bar, meta=foreground_meta) + ), ) else: style = _Style(bgcolor=back) diff --git a/src/textual/signal.py b/src/textual/signal.py new file mode 100644 index 0000000000..6226b0273b --- /dev/null +++ b/src/textual/signal.py @@ -0,0 +1,91 @@ +""" +Signals are a simple pub-sub mechanism. + +DOMNodes can subscribe to a signal, which will invoke a callback when the signal is published. + +This is experimental for now, for internal use. It may be part of the public API in a future release. + +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING +from weakref import WeakKeyDictionary + +import rich.repr + +from textual import log + +if TYPE_CHECKING: + from ._types import IgnoreReturnCallbackType + from .dom import DOMNode + + +class SignalError(Exception): + """Raised for Signal errors.""" + + +@rich.repr.auto(angular=True) +class Signal: + """A signal that a widget may subscribe to, in order to invoke callbacks when an associated event occurs.""" + + def __init__(self, owner: DOMNode, name: str) -> None: + """Initialize a signal. + + Args: + owner: The owner of this signal. + name: An identifier for debugging purposes. + """ + self._owner = owner + self._name = name + self._subscriptions: WeakKeyDictionary[ + DOMNode, list[IgnoreReturnCallbackType] + ] = WeakKeyDictionary() + + def __rich_repr__(self) -> rich.repr.Result: + yield "owner", self._owner + yield "name", self._name + yield "subscriptions", list(self._subscriptions.keys()) + + def subscribe(self, node: DOMNode, callback: IgnoreReturnCallbackType) -> None: + """Subscribe a node to this signal. + + When the signal is published, the callback will be invoked. + + Args: + node: Node to subscribe. + callback: A callback function which takes no arguments, and returns anything (return type ignored). + + Raises: + SignalError: Raised when subscribing a non-mounted widget. + """ + if not node.is_running: + raise SignalError( + f"Node must be running to subscribe to a signal (has {node} been mounted)?" + ) + callbacks = self._subscriptions.setdefault(node, []) + if callback not in callbacks: + callbacks.append(callback) + + def unsubscribe(self, node: DOMNode) -> None: + """Unsubscribe a node from this signal. + + Args: + node: Node to unsubscribe, + """ + self._subscriptions.pop(node, None) + + def publish(self) -> None: + """Publish the signal (invoke subscribed callbacks).""" + + for node, callbacks in list(self._subscriptions.items()): + if not node.is_running: + # Removed nodes that are no longer running + self._subscriptions.pop(node) + else: + # Call callbacks + for callback in callbacks: + try: + callback() + except Exception as error: + log.error(f"error publishing signal to {node} ignored; {error}") diff --git a/src/textual/strip.py b/src/textual/strip.py index e3a694c724..d3145e2753 100644 --- a/src/textual/strip.py +++ b/src/textual/strip.py @@ -3,7 +3,6 @@ See [line API](/guide/widgets#line-api) for how to use Strips. """ - from __future__ import annotations from itertools import chain diff --git a/src/textual/tree-sitter/highlights/html.scm b/src/textual/tree-sitter/highlights/html.scm index 15f2adb436..41c83ce0d8 100644 --- a/src/textual/tree-sitter/highlights/html.scm +++ b/src/textual/tree-sitter/highlights/html.scm @@ -6,45 +6,6 @@ (quoted_attribute_value) @string) (text) @text @spell -((element (start_tag (tag_name) @_tag) (text) @text.title) - (#eq? @_tag "title")) - -((element (start_tag (tag_name) @_tag) (text) @text.title.1) - (#eq? @_tag "h1")) - -((element (start_tag (tag_name) @_tag) (text) @text.title.2) - (#eq? @_tag "h2")) - -((element (start_tag (tag_name) @_tag) (text) @text.title.3) - (#eq? @_tag "h3")) - -((element (start_tag (tag_name) @_tag) (text) @text.title.4) - (#eq? @_tag "h4")) - -((element (start_tag (tag_name) @_tag) (text) @text.title.5) - (#eq? @_tag "h5")) - -((element (start_tag (tag_name) @_tag) (text) @text.title.6) - (#eq? @_tag "h6")) - -((element (start_tag (tag_name) @_tag) (text) @text.strong) - (#any-of? @_tag "strong" "b")) - -((element (start_tag (tag_name) @_tag) (text) @text.emphasis) - (#any-of? @_tag "em" "i")) - -((element (start_tag (tag_name) @_tag) (text) @text.strike) - (#any-of? @_tag "s" "del")) - -((element (start_tag (tag_name) @_tag) (text) @text.underline) - (#eq? @_tag "u")) - -((element (start_tag (tag_name) @_tag) (text) @text.literal) - (#any-of? @_tag "code" "kbd")) - -((element (start_tag (tag_name) @_tag) (text) @text.uri) - (#eq? @_tag "a")) - ((attribute (attribute_name) @_attr (quoted_attribute_value (attribute_value) @text.uri)) diff --git a/src/textual/types.py b/src/textual/types.py index 33be4449fe..95f33db4c2 100644 --- a/src/textual/types.py +++ b/src/textual/types.py @@ -6,12 +6,14 @@ from ._context import NoActiveAppError from ._path import CSSPathError, CSSPathType from ._types import ( + AnimationLevel, CallbackType, IgnoreReturnCallbackType, MessageTarget, UnusedParameter, WatchCallbackType, ) +from ._widget_navigation import Direction from .actions import ActionParseResult from .css.styles import RenderStyles from .widgets._directory_tree import DirEntry @@ -28,10 +30,12 @@ __all__ = [ "ActionParseResult", "Animatable", + "AnimationLevel", "CallbackType", "CSSPathError", "CSSPathType", "DirEntry", + "Direction", "DuplicateID", "EasingFunction", "IgnoreReturnCallbackType", diff --git a/src/textual/walk.py b/src/textual/walk.py index b34892d614..a58bf0110f 100644 --- a/src/textual/walk.py +++ b/src/textual/walk.py @@ -22,8 +22,7 @@ def walk_depth_first( root: DOMNode, *, with_root: bool = True, -) -> Iterable[DOMNode]: - ... +) -> Iterable[DOMNode]: ... @overload @@ -32,8 +31,7 @@ def walk_depth_first( filter_type: type[WalkType], *, with_root: bool = True, -) -> Iterable[WalkType]: - ... +) -> Iterable[WalkType]: ... def walk_depth_first( @@ -82,8 +80,7 @@ def walk_breadth_first( root: DOMNode, *, with_root: bool = True, -) -> Iterable[DOMNode]: - ... +) -> Iterable[DOMNode]: ... @overload @@ -92,8 +89,7 @@ def walk_breadth_first( filter_type: type[WalkType], *, with_root: bool = True, -) -> Iterable[WalkType]: - ... +) -> Iterable[WalkType]: ... def walk_breadth_first( diff --git a/src/textual/widget.py b/src/textual/widget.py index cbff95e1c3..4a27762f7d 100644 --- a/src/textual/widget.py +++ b/src/textual/widget.py @@ -4,13 +4,15 @@ from __future__ import annotations -from asyncio import create_task, wait +from asyncio import Lock, create_task, wait from collections import Counter +from contextlib import asynccontextmanager from fractions import Fraction from itertools import islice from types import TracebackType from typing import ( TYPE_CHECKING, + AsyncGenerator, Awaitable, ClassVar, Collection, @@ -30,15 +32,18 @@ ConsoleRenderable, JustifyMethod, RenderableType, - RenderResult, - RichCast, ) +from rich.console import RenderResult as RichRenderResult +from rich.console import RichCast from rich.measure import Measurement from rich.segment import Segment from rich.style import Style from rich.text import Text from typing_extensions import Self +if TYPE_CHECKING: + from .app import RenderResult + from . import constants, errors, events, messages from ._animator import DEFAULT_EASING, Animatable, BoundAnimator, EasingFunction from ._arrange import DockArrangeResult, arrange @@ -48,10 +53,13 @@ from ._layout import Layout from ._segment_tools import align_lines from ._styles_cache import StylesCache +from ._types import AnimationLevel from .actions import SkipAction from .await_remove import AwaitRemove from .box_model import BoxModel from .cache import FIFOCache +from .css.match import match +from .css.parse import parse_selectors from .css.query import NoMatches, WrongType from .css.scalar import ScalarOffset from .dom import DOMNode, NoScreen @@ -77,6 +85,7 @@ if TYPE_CHECKING: from .app import App, ComposeResult + from .css.query import QueryType from .message_pump import MessagePump from .scrollbar import ( ScrollBar, @@ -146,7 +155,7 @@ def __init__( def __rich_console__( self, console: "Console", options: "ConsoleOptions" - ) -> "RenderResult": + ) -> "RichRenderResult": style = console.get_style(self.style) result_segments = console.render(self.renderable, options) @@ -237,6 +246,10 @@ def __get__(self, obj: Widget, objtype: type[Widget] | None = None) -> str | Non return title.markup +class BadWidgetName(Exception): + """Raised when widget class names do not satisfy the required restrictions.""" + + @rich.repr.auto class Widget(DOMNode): """ @@ -292,6 +305,9 @@ class Widget(DOMNode): loading: Reactive[bool] = Reactive(False) """If set to `True` this widget will temporarily be replaced with a loading indicator.""" + # Default sort order, incremented by constructor + _sort_order: ClassVar[int] = 0 + def __init__( self, *children: Widget, @@ -315,8 +331,11 @@ def __init__( self._layout_required = False self._repaint_required = False self._scroll_required = False + self._recompose_required = False self._default_layout = VerticalLayout() self._animate: BoundAnimator | None = None + Widget._sort_order += 1 + self.sort_order = Widget._sort_order self.highlight_style: Style | None = None self._vertical_scrollbar: ScrollBar | None = None @@ -333,13 +352,12 @@ def __init__( self._repaint_regions: set[Region] = set() # Cache the auto content dimensions - # TODO: add mechanism to explicitly clear this self._content_width_cache: tuple[object, int] = (None, 0) self._content_height_cache: tuple[object, int] = (None, 0) - self._arrangement_cache: FIFOCache[ - tuple[Size, int], DockArrangeResult - ] = FIFOCache(4) + self._arrangement_cache: FIFOCache[tuple[Size, int], DockArrangeResult] = ( + FIFOCache(4) + ) self._styles_cache = StylesCache() self._rich_style_cache: dict[str, tuple[Style, Style]] = {} @@ -372,6 +390,14 @@ def __init__( if self.BORDER_SUBTITLE: self.border_subtitle = self.BORDER_SUBTITLE + self.lock = Lock() + """`asyncio` lock to be used to synchronize the state of the widget. + + Two different tasks might call methods on a widget at the same time, which + might result in a race condition. + This can be fixed by adding `async with widget.lock:` around the method calls. + """ + virtual_size: Reactive[Size] = Reactive(Size(0, 0), layout=True) """The virtual (scrollable) [size][textual.geometry.Size] of the widget.""" @@ -563,6 +589,19 @@ def __exit__( else: self.app._composed[-1].append(composed) + def clear_cached_dimensions(self) -> None: + """Clear cached results of `get_content_width` and `get_content_height`. + + Call if the widget's renderable changes size after the widget has been created. + + !!! note + + This is not required if you are extending [`Static`][textual.widgets.Static]. + + """ + self._content_width_cache = (None, 0) + self._content_height_cache = (None, 0) + def get_loading_widget(self) -> Widget: """Get a widget to display a loading indicator. @@ -602,12 +641,10 @@ async def _watch_loading(self, loading: bool) -> None: ExpectType = TypeVar("ExpectType", bound="Widget") @overload - def get_child_by_id(self, id: str) -> Widget: - ... + def get_child_by_id(self, id: str) -> Widget: ... @overload - def get_child_by_id(self, id: str, expect_type: type[ExpectType]) -> ExpectType: - ... + def get_child_by_id(self, id: str, expect_type: type[ExpectType]) -> ExpectType: ... def get_child_by_id( self, id: str, expect_type: type[ExpectType] | None = None @@ -638,12 +675,12 @@ def get_child_by_id( return child @overload - def get_widget_by_id(self, id: str) -> Widget: - ... + def get_widget_by_id(self, id: str) -> Widget: ... @overload - def get_widget_by_id(self, id: str, expect_type: type[ExpectType]) -> ExpectType: - ... + def get_widget_by_id( + self, id: str, expect_type: type[ExpectType] + ) -> ExpectType: ... def get_widget_by_id( self, id: str, expect_type: type[ExpectType] | None = None @@ -930,8 +967,7 @@ def move_child( *, before: int | Widget, after: None = None, - ) -> None: - ... + ) -> None: ... @overload def move_child( @@ -940,8 +976,7 @@ def move_child( *, after: int | Widget, before: None = None, - ) -> None: - ... + ) -> None: ... def move_child( self, @@ -1015,7 +1050,10 @@ def _to_widget(child: int | Widget, called: str) -> Widget: def compose(self) -> ComposeResult: """Called by Textual to create child widgets. - Extend this to build a UI. + This method is called when a widget is mounted or by setting `recompose=True` when + calling [`refresh()`][textual.widget.Widget.refresh]. + + Note that you don't typically need to explicitly call this method. Example: ```python @@ -1028,6 +1066,22 @@ def compose(self) -> ComposeResult: """ yield from () + async def _check_recompose(self) -> None: + """Check if a recompose is required.""" + if self._recompose_required: + self._recompose_required = False + await self.recompose() + + async def recompose(self) -> None: + """Recompose the widget. + + Recomposing will remove children and call `self.compose` again to remount. + """ + if self._parent is not None: + async with self.batch(): + await self.query("*").exclude(".-textual-system").remove() + await self.mount_all(compose(self)) + def _post_register(self, app: App) -> None: """Called when the instance is registered. @@ -1075,8 +1129,6 @@ def _get_box_model( # Container minus padding and border content_container = container - gutter.totals - # The container including the content - sizing_container = content_container if is_border_box else container if styles.width is None: # No width specified, fill available space @@ -1084,9 +1136,7 @@ def _get_box_model( elif is_auto_width: # When width is auto, we want enough space to always fit the content content_width = Fraction( - self.get_content_width( - content_container - styles.margin.totals, viewport - ) + self.get_content_width(content_container - margin.totals, viewport) ) if styles.scrollbar_gutter == "stable" and styles.overflow_x == "auto": content_width += styles.scrollbar_size_vertical @@ -1099,15 +1149,15 @@ def _get_box_model( # An explicit width styles_width = styles.width content_width = styles_width.resolve( - sizing_container - styles.margin.totals, viewport, width_fraction + container - margin.totals, viewport, width_fraction ) - if is_border_box and styles_width.excludes_border: + if is_border_box: content_width -= gutter.width if styles.min_width is not None: # Restrict to minimum width, if set min_width = styles.min_width.resolve( - content_container, viewport, width_fraction + container - margin.totals, viewport, width_fraction ) if is_border_box: min_width -= gutter.width @@ -1116,7 +1166,7 @@ def _get_box_model( if styles.max_width is not None: # Restrict to maximum width, if set max_width = styles.max_width.resolve( - content_container, viewport, width_fraction + container - margin.totals, viewport, width_fraction ) if is_border_box: max_width -= gutter.width @@ -1143,15 +1193,15 @@ def _get_box_model( styles_height = styles.height # Explicit height set content_height = styles_height.resolve( - sizing_container - styles.margin.totals, viewport, height_fraction + container - margin.totals, viewport, height_fraction ) - if is_border_box and styles_height.excludes_border: + if is_border_box: content_height -= gutter.height if styles.min_height is not None: # Restrict to minimum height, if set min_height = styles.min_height.resolve( - content_container, viewport, height_fraction + container - margin.totals, viewport, height_fraction ) if is_border_box: min_height -= gutter.height @@ -1160,7 +1210,7 @@ def _get_box_model( if styles.max_height is not None: # Restrict maximum height, if set max_height = styles.max_height.resolve( - content_container, viewport, height_fraction + container - margin.totals, viewport, height_fraction ) if is_border_box: max_height -= gutter.height @@ -1728,6 +1778,7 @@ def animate( delay: float = 0.0, easing: EasingFunction | str = DEFAULT_EASING, on_complete: CallbackType | None = None, + level: AnimationLevel = "full", ) -> None: """Animate an attribute. @@ -1740,6 +1791,7 @@ def animate( delay: A delay (in seconds) before the animation starts. easing: An easing method. on_complete: A callable to invoke when the animation is finished. + level: Minimum level required for the animation to take place (inclusive). """ if self._animate is None: self._animate = self.app.animator.bind(self) @@ -1753,6 +1805,7 @@ def animate( delay=delay, easing=easing, on_complete=on_complete, + level=level, ) async def stop_animation(self, attribute: str, complete: bool = True) -> None: @@ -1899,6 +1952,7 @@ def _scroll_to( easing: EasingFunction | str | None = None, force: bool = False, on_complete: CallbackType | None = None, + level: AnimationLevel = "basic", ) -> bool: """Scroll to a given (absolute) coordinate, optionally animating. @@ -1911,6 +1965,7 @@ def _scroll_to( easing: An easing method for the scrolling animation. force: Force scrolling even when prohibited by overflow styling. on_complete: A callable to invoke when the animation is finished. + level: Minimum level required for the animation to take place (inclusive). Returns: `True` if the scroll position changed, otherwise `False`. @@ -1919,6 +1974,11 @@ def _scroll_to( maybe_scroll_x = x is not None and (self.allow_horizontal_scroll or force) maybe_scroll_y = y is not None and (self.allow_vertical_scroll or force) scrolled_x = scrolled_y = False + + animator = self.app.animator + animator.force_stop_animation(self, "scroll_x") + animator.force_stop_animation(self, "scroll_y") + if animate: # TODO: configure animation speed if duration is None and speed is None: @@ -1938,6 +1998,7 @@ def _scroll_to( duration=duration, easing=easing, on_complete=on_complete, + level=level, ) scrolled_x = True if maybe_scroll_y: @@ -1951,6 +2012,7 @@ def _scroll_to( duration=duration, easing=easing, on_complete=on_complete, + level=level, ) scrolled_y = True @@ -1982,6 +2044,7 @@ def scroll_to( easing: EasingFunction | str | None = None, force: bool = False, on_complete: CallbackType | None = None, + level: AnimationLevel = "basic", ) -> None: """Scroll to a given (absolute) coordinate, optionally animating. @@ -1994,6 +2057,7 @@ def scroll_to( easing: An easing method for the scrolling animation. force: Force scrolling even when prohibited by overflow styling. on_complete: A callable to invoke when the animation is finished. + level: Minimum level required for the animation to take place (inclusive). Note: The call to scroll is made after the next refresh. @@ -2008,6 +2072,7 @@ def scroll_to( easing=easing, force=force, on_complete=on_complete, + level=level, ) def scroll_relative( @@ -2021,6 +2086,7 @@ def scroll_relative( easing: EasingFunction | str | None = None, force: bool = False, on_complete: CallbackType | None = None, + level: AnimationLevel = "basic", ) -> None: """Scroll relative to current position. @@ -2033,6 +2099,7 @@ def scroll_relative( easing: An easing method for the scrolling animation. force: Force scrolling even when prohibited by overflow styling. on_complete: A callable to invoke when the animation is finished. + level: Minimum level required for the animation to take place (inclusive). """ self.scroll_to( None if x is None else (self.scroll_x + x), @@ -2043,6 +2110,7 @@ def scroll_relative( easing=easing, force=force, on_complete=on_complete, + level=level, ) def scroll_home( @@ -2054,6 +2122,7 @@ def scroll_home( easing: EasingFunction | str | None = None, force: bool = False, on_complete: CallbackType | None = None, + level: AnimationLevel = "basic", ) -> None: """Scroll to home position. @@ -2064,6 +2133,7 @@ def scroll_home( easing: An easing method for the scrolling animation. force: Force scrolling even when prohibited by overflow styling. on_complete: A callable to invoke when the animation is finished. + level: Minimum level required for the animation to take place (inclusive). """ if speed is None and duration is None: duration = 1.0 @@ -2076,6 +2146,7 @@ def scroll_home( easing=easing, force=force, on_complete=on_complete, + level=level, ) def scroll_end( @@ -2087,6 +2158,7 @@ def scroll_end( easing: EasingFunction | str | None = None, force: bool = False, on_complete: CallbackType | None = None, + level: AnimationLevel = "basic", ) -> None: """Scroll to the end of the container. @@ -2097,6 +2169,7 @@ def scroll_end( easing: An easing method for the scrolling animation. force: Force scrolling even when prohibited by overflow styling. on_complete: A callable to invoke when the animation is finished. + level: Minimum level required for the animation to take place (inclusive). """ if speed is None and duration is None: duration = 1.0 @@ -2118,6 +2191,7 @@ def _lazily_scroll_end() -> None: easing=easing, force=force, on_complete=on_complete, + level=level, ) self.call_after_refresh(_lazily_scroll_end) @@ -2131,6 +2205,7 @@ def scroll_left( easing: EasingFunction | str | None = None, force: bool = False, on_complete: CallbackType | None = None, + level: AnimationLevel = "basic", ) -> None: """Scroll one cell left. @@ -2141,6 +2216,7 @@ def scroll_left( easing: An easing method for the scrolling animation. force: Force scrolling even when prohibited by overflow styling. on_complete: A callable to invoke when the animation is finished. + level: Minimum level required for the animation to take place (inclusive). """ self.scroll_to( x=self.scroll_target_x - 1, @@ -2150,6 +2226,7 @@ def scroll_left( easing=easing, force=force, on_complete=on_complete, + level=level, ) def _scroll_left_for_pointer( @@ -2161,6 +2238,7 @@ def _scroll_left_for_pointer( easing: EasingFunction | str | None = None, force: bool = False, on_complete: CallbackType | None = None, + level: AnimationLevel = "basic", ) -> bool: """Scroll left one position, taking scroll sensitivity into account. @@ -2171,6 +2249,7 @@ def _scroll_left_for_pointer( easing: An easing method for the scrolling animation. force: Force scrolling even when prohibited by overflow styling. on_complete: A callable to invoke when the animation is finished. + level: Minimum level required for the animation to take place (inclusive). Returns: `True` if any scrolling was done. @@ -2187,6 +2266,7 @@ def _scroll_left_for_pointer( easing=easing, force=force, on_complete=on_complete, + level=level, ) def scroll_right( @@ -2198,6 +2278,7 @@ def scroll_right( easing: EasingFunction | str | None = None, force: bool = False, on_complete: CallbackType | None = None, + level: AnimationLevel = "basic", ) -> None: """Scroll one cell right. @@ -2208,6 +2289,7 @@ def scroll_right( easing: An easing method for the scrolling animation. force: Force scrolling even when prohibited by overflow styling. on_complete: A callable to invoke when the animation is finished. + level: Minimum level required for the animation to take place (inclusive). """ self.scroll_to( x=self.scroll_target_x + 1, @@ -2217,6 +2299,7 @@ def scroll_right( easing=easing, force=force, on_complete=on_complete, + level=level, ) def _scroll_right_for_pointer( @@ -2228,6 +2311,7 @@ def _scroll_right_for_pointer( easing: EasingFunction | str | None = None, force: bool = False, on_complete: CallbackType | None = None, + level: AnimationLevel = "basic", ) -> bool: """Scroll right one position, taking scroll sensitivity into account. @@ -2238,6 +2322,7 @@ def _scroll_right_for_pointer( easing: An easing method for the scrolling animation. force: Force scrolling even when prohibited by overflow styling. on_complete: A callable to invoke when the animation is finished. + level: Minimum level required for the animation to take place (inclusive). Returns: `True` if any scrolling was done. @@ -2254,6 +2339,7 @@ def _scroll_right_for_pointer( easing=easing, force=force, on_complete=on_complete, + level=level, ) def scroll_down( @@ -2265,6 +2351,7 @@ def scroll_down( easing: EasingFunction | str | None = None, force: bool = False, on_complete: CallbackType | None = None, + level: AnimationLevel = "basic", ) -> None: """Scroll one line down. @@ -2275,6 +2362,7 @@ def scroll_down( easing: An easing method for the scrolling animation. force: Force scrolling even when prohibited by overflow styling. on_complete: A callable to invoke when the animation is finished. + level: Minimum level required for the animation to take place (inclusive). """ self.scroll_to( y=self.scroll_target_y + 1, @@ -2284,6 +2372,7 @@ def scroll_down( easing=easing, force=force, on_complete=on_complete, + level=level, ) def _scroll_down_for_pointer( @@ -2295,6 +2384,7 @@ def _scroll_down_for_pointer( easing: EasingFunction | str | None = None, force: bool = False, on_complete: CallbackType | None = None, + level: AnimationLevel = "basic", ) -> bool: """Scroll down one position, taking scroll sensitivity into account. @@ -2305,6 +2395,7 @@ def _scroll_down_for_pointer( easing: An easing method for the scrolling animation. force: Force scrolling even when prohibited by overflow styling. on_complete: A callable to invoke when the animation is finished. + level: Minimum level required for the animation to take place (inclusive). Returns: `True` if any scrolling was done. @@ -2321,6 +2412,7 @@ def _scroll_down_for_pointer( easing=easing, force=force, on_complete=on_complete, + level=level, ) def scroll_up( @@ -2332,6 +2424,7 @@ def scroll_up( easing: EasingFunction | str | None = None, force: bool = False, on_complete: CallbackType | None = None, + level: AnimationLevel = "basic", ) -> None: """Scroll one line up. @@ -2342,6 +2435,7 @@ def scroll_up( easing: An easing method for the scrolling animation. force: Force scrolling even when prohibited by overflow styling. on_complete: A callable to invoke when the animation is finished. + level: Minimum level required for the animation to take place (inclusive). """ self.scroll_to( y=self.scroll_target_y - 1, @@ -2351,6 +2445,7 @@ def scroll_up( easing=easing, force=force, on_complete=on_complete, + level=level, ) def _scroll_up_for_pointer( @@ -2362,6 +2457,7 @@ def _scroll_up_for_pointer( easing: EasingFunction | str | None = None, force: bool = False, on_complete: CallbackType | None = None, + level: AnimationLevel = "basic", ) -> bool: """Scroll up one position, taking scroll sensitivity into account. @@ -2372,6 +2468,7 @@ def _scroll_up_for_pointer( easing: An easing method for the scrolling animation. force: Force scrolling even when prohibited by overflow styling. on_complete: A callable to invoke when the animation is finished. + level: Minimum level required for the animation to take place (inclusive). Returns: `True` if any scrolling was done. @@ -2388,6 +2485,7 @@ def _scroll_up_for_pointer( easing=easing, force=force, on_complete=on_complete, + level=level, ) def scroll_page_up( @@ -2399,6 +2497,7 @@ def scroll_page_up( easing: EasingFunction | str | None = None, force: bool = False, on_complete: CallbackType | None = None, + level: AnimationLevel = "basic", ) -> None: """Scroll one page up. @@ -2409,6 +2508,7 @@ def scroll_page_up( easing: An easing method for the scrolling animation. force: Force scrolling even when prohibited by overflow styling. on_complete: A callable to invoke when the animation is finished. + level: Minimum level required for the animation to take place (inclusive). """ self.scroll_to( y=self.scroll_y - self.container_size.height, @@ -2418,6 +2518,7 @@ def scroll_page_up( easing=easing, force=force, on_complete=on_complete, + level=level, ) def scroll_page_down( @@ -2429,6 +2530,7 @@ def scroll_page_down( easing: EasingFunction | str | None = None, force: bool = False, on_complete: CallbackType | None = None, + level: AnimationLevel = "basic", ) -> None: """Scroll one page down. @@ -2439,6 +2541,7 @@ def scroll_page_down( easing: An easing method for the scrolling animation. force: Force scrolling even when prohibited by overflow styling. on_complete: A callable to invoke when the animation is finished. + level: Minimum level required for the animation to take place (inclusive). """ self.scroll_to( y=self.scroll_y + self.container_size.height, @@ -2448,6 +2551,7 @@ def scroll_page_down( easing=easing, force=force, on_complete=on_complete, + level=level, ) def scroll_page_left( @@ -2459,6 +2563,7 @@ def scroll_page_left( easing: EasingFunction | str | None = None, force: bool = False, on_complete: CallbackType | None = None, + level: AnimationLevel = "basic", ) -> None: """Scroll one page left. @@ -2469,6 +2574,7 @@ def scroll_page_left( easing: An easing method for the scrolling animation. force: Force scrolling even when prohibited by overflow styling. on_complete: A callable to invoke when the animation is finished. + level: Minimum level required for the animation to take place (inclusive). """ if speed is None and duration is None: duration = 0.3 @@ -2480,6 +2586,7 @@ def scroll_page_left( easing=easing, force=force, on_complete=on_complete, + level=level, ) def scroll_page_right( @@ -2491,6 +2598,7 @@ def scroll_page_right( easing: EasingFunction | str | None = None, force: bool = False, on_complete: CallbackType | None = None, + level: AnimationLevel = "basic", ) -> None: """Scroll one page right. @@ -2501,6 +2609,7 @@ def scroll_page_right( easing: An easing method for the scrolling animation. force: Force scrolling even when prohibited by overflow styling. on_complete: A callable to invoke when the animation is finished. + level: Minimum level required for the animation to take place (inclusive). """ if speed is None and duration is None: duration = 0.3 @@ -2512,6 +2621,7 @@ def scroll_page_right( easing=easing, force=force, on_complete=on_complete, + level=level, ) def scroll_to_widget( @@ -2527,6 +2637,7 @@ def scroll_to_widget( origin_visible: bool = True, force: bool = False, on_complete: CallbackType | None = None, + level: AnimationLevel = "basic", ) -> bool: """Scroll scrolling to bring a widget in to view. @@ -2540,6 +2651,7 @@ def scroll_to_widget( origin_visible: Ensure that the top left of the widget is within the window. force: Force scrolling even when prohibited by overflow styling. on_complete: A callable to invoke when the animation is finished. + level: Minimum level required for the animation to take place (inclusive). Returns: `True` if any scrolling has occurred in any descendant, otherwise `False`. @@ -2566,6 +2678,7 @@ def scroll_to_widget( origin_visible=origin_visible, force=force, on_complete=on_complete, + level=level, ) if scroll_offset: scrolled = True @@ -2600,6 +2713,7 @@ def scroll_to_region( origin_visible: bool = True, force: bool = False, on_complete: CallbackType | None = None, + level: AnimationLevel = "basic", ) -> Offset: """Scrolls a given region in to view, if required. @@ -2617,6 +2731,7 @@ def scroll_to_region( origin_visible: Ensure that the top left of the widget is within the window. force: Force scrolling even when prohibited by overflow styling. on_complete: A callable to invoke when the animation is finished. + level: Minimum level required for the animation to take place (inclusive). Returns: The distance that was scrolled. @@ -2663,6 +2778,7 @@ def scroll_to_region( easing=easing, force=force, on_complete=on_complete, + level=level, ) return delta @@ -2676,6 +2792,7 @@ def scroll_visible( easing: EasingFunction | str | None = None, force: bool = False, on_complete: CallbackType | None = None, + level: AnimationLevel = "basic", ) -> None: """Scroll the container to make this widget visible. @@ -2687,6 +2804,7 @@ def scroll_visible( easing: An easing method for the scrolling animation. force: Force scrolling even when prohibited by overflow styling. on_complete: A callable to invoke when the animation is finished. + level: Minimum level required for the animation to take place (inclusive). """ parent = self.parent if isinstance(parent, Widget): @@ -2700,6 +2818,7 @@ def scroll_visible( easing=easing, force=force, on_complete=on_complete, + level=level, ) def scroll_to_center( @@ -2713,6 +2832,7 @@ def scroll_to_center( force: bool = False, origin_visible: bool = True, on_complete: CallbackType | None = None, + level: AnimationLevel = "basic", ) -> None: """Scroll this widget to the center of self. @@ -2727,6 +2847,7 @@ def scroll_to_center( force: Force scrolling even when prohibited by overflow styling. origin_visible: Ensure that the top left corner of the widget remains visible after the scroll. on_complete: A callable to invoke when the animation is finished. + level: Minimum level required for the animation to take place (inclusive). """ self.call_after_refresh( @@ -2740,6 +2861,7 @@ def scroll_to_center( center=True, origin_visible=origin_visible, on_complete=on_complete, + level=level, ) def can_view(self, widget: Widget) -> bool: @@ -2773,11 +2895,17 @@ def __init_subclass__( inherit_css: bool = True, inherit_bindings: bool = True, ) -> None: - base = cls.__mro__[0] + name = cls.__name__ + if not name[0].isupper() and not name.startswith("_"): + raise BadWidgetName( + f"Widget subclass {name!r} should be capitalised or start with '_'." + ) + super().__init_subclass__( inherit_css=inherit_css, inherit_bindings=inherit_bindings, ) + base = cls.__mro__[0] if issubclass(base, Widget): cls.can_focus = base.can_focus if can_focus is None else can_focus cls.can_focus_children = ( @@ -2787,9 +2915,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. @@ -2806,10 +2937,10 @@ def _get_scrollable_region(self, region: Region) -> Region: scrollbar_size_horizontal = styles.scrollbar_size_horizontal scrollbar_size_vertical = styles.scrollbar_size_vertical - show_vertical_scrollbar: bool = ( + show_vertical_scrollbar = bool( show_vertical_scrollbar and scrollbar_size_vertical ) - show_horizontal_scrollbar: bool = ( + show_horizontal_scrollbar = bool( show_horizontal_scrollbar and scrollbar_size_horizontal ) @@ -2843,10 +2974,10 @@ def _arrange_scrollbars(self, region: Region) -> Iterable[tuple[Widget, Region]] scrollbar_size_horizontal = self.scrollbar_size_horizontal scrollbar_size_vertical = self.scrollbar_size_vertical - show_vertical_scrollbar: bool = ( + show_vertical_scrollbar = bool( show_vertical_scrollbar and scrollbar_size_vertical ) - show_horizontal_scrollbar: bool = ( + show_horizontal_scrollbar = bool( show_horizontal_scrollbar and scrollbar_size_horizontal ) @@ -3034,7 +3165,7 @@ def _size_updated( if layout: self.virtual_size = virtual_size else: - self._reactive_virtual_size = virtual_size + self.set_reactive(Widget.virtual_size, virtual_size) self._container_size = container_size if self.is_scrollable: self._scroll_update(virtual_size) @@ -3163,6 +3294,7 @@ def refresh( *regions: Region, repaint: bool = True, layout: bool = False, + recompose: bool = False, ) -> Self: """Initiate a refresh of the widget. @@ -3181,6 +3313,7 @@ def refresh( *regions: Additional screen regions to mark as dirty. repaint: Repaint the widget (will call render() again). layout: Also layout widgets in the view. + recompose: Re-compose the widget (will remove and re-mount children). Returns: The `Widget` instance. @@ -3195,10 +3328,14 @@ def refresh( break ancestor._clear_arrangement_cache() - if repaint: + if recompose: + self._recompose_required = True + self.call_next(self._check_recompose) + return self + + elif repaint: self._set_dirty(*regions) - self._content_width_cache = (None, 0) - self._content_height_cache = (None, 0) + self.clear_cached_dimensions() self._rich_style_cache.clear() self._repaint_required = True @@ -3215,16 +3352,43 @@ def remove(self) -> AwaitRemove: await_remove = self.app._remove_nodes([self], self.parent) return await_remove - def remove_children(self) -> AwaitRemove: - """Remove all children of this Widget from the DOM. + def remove_children(self, selector: str | type[QueryType] = "*") -> AwaitRemove: + """Remove the immediate children of this Widget from the DOM. + + Args: + selector: A CSS selector to specify which direct children to remove. Returns: - An awaitable object that waits for the children to be removed. + An awaitable object that waits for the direct children to be removed. """ - await_remove = self.app._remove_nodes(list(self.children), self) + if not isinstance(selector, str): + selector = selector.__name__ + parsed_selectors = parse_selectors(selector) + children_to_remove = [ + child for child in self.children if match(parsed_selectors, child) + ] + await_remove = self.app._remove_nodes(children_to_remove, self) return await_remove - def render(self) -> RenderableType: + @asynccontextmanager + async def batch(self) -> AsyncGenerator[None, None]: + """Async context manager that combines widget locking and update batching. + + Use this async context manager whenever you want to acquire the widget lock and + batch app updates at the same time. + + Example: + ```py + async with container.batch(): + await container.remove_children(Button) + await container.mount(Label("All buttons are gone.")) + ``` + """ + async with self.lock: + with self.app.batch_update(): + yield + + def render(self) -> RenderResult: """Get text or Rich renderable for this widget. Implement this for custom widgets. @@ -3403,8 +3567,12 @@ def check_message_enabled(self, message: Message) -> bool: message_type = type(message) if self._is_prevented(message_type): return False - # Otherwise, if this is a mouse event, the widget receiving the - # event must not be disabled at this moment. + # Mouse scroll events should always go through, this allows mouse + # wheel scrolling to pass through disabled widgets. + if isinstance(message, (events.MouseScrollDown, events.MouseScrollUp)): + return True + # Otherwise, if this is any other mouse event, the widget receiving + # the event must not be disabled at this moment. return ( not self._self_or_ancestors_disabled if isinstance(message, (events.MouseEvent, events.Enter, events.Leave)) @@ -3433,7 +3601,11 @@ 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() + await self._compose() + + async def _compose(self) -> None: try: widgets = [*self._pending_children, *compose(self)] self._pending_children.clear() @@ -3538,7 +3710,17 @@ def _on_scroll_right(self, event: ScrollRight) -> None: self.scroll_page_right() event.stop() + def _on_show(self, event: events.Show) -> None: + if self.show_horizontal_scrollbar: + self.horizontal_scrollbar.post_message(event) + if self.show_vertical_scrollbar: + self.vertical_scrollbar.post_message(event) + def _on_hide(self, event: events.Hide) -> None: + if self.show_horizontal_scrollbar: + self.horizontal_scrollbar.post_message(event) + if self.show_vertical_scrollbar: + self.vertical_scrollbar.post_message(event) if self.has_focus: self.blur() diff --git a/src/textual/widgets/_button.py b/src/textual/widgets/_button.py index 83a3237b2d..110646e109 100644 --- a/src/textual/widgets/_button.py +++ b/src/textual/widgets/_button.py @@ -1,7 +1,7 @@ from __future__ import annotations from functools import partial -from typing import cast +from typing import TYPE_CHECKING, cast import rich.repr from rich.console import ConsoleRenderable, RenderableType @@ -9,6 +9,10 @@ from typing_extensions import Literal, Self from .. import events + +if TYPE_CHECKING: + from ..app import RenderResult + from ..binding import Binding from ..css._error_tools import friendly_list from ..message import Message @@ -61,16 +65,16 @@ class Button(Widget, can_focus=True): tint: $background 30%; } - &.-primary { + &.-primary { background: $primary; color: $text; border-top: tall $primary-lighten-3; - border-bottom: tall $primary-darken-3; - + border-bottom: tall $primary-darken-3; + &:hover { background: $primary-darken-2; color: $text; - border-top: tall $primary; + border-top: tall $primary; } &.-active { @@ -220,7 +224,7 @@ def validate_label(self, label: TextType) -> Text: return Text.from_markup(label) return label - def render(self) -> RenderableType: + def render(self) -> RenderResult: assert isinstance(self.label, Text) label = self.label.copy() label.stylize(self.rich_style) diff --git a/src/textual/widgets/_collapsible.py b/src/textual/widgets/_collapsible.py index 79a04816db..8e765e02f6 100644 --- a/src/textual/widgets/_collapsible.py +++ b/src/textual/widgets/_collapsible.py @@ -34,7 +34,9 @@ class CollapsibleTitle(Static, can_focus=True): } """ - BINDINGS = [Binding("enter", "toggle", "Toggle collapsible", show=False)] + BINDINGS = [ + Binding("enter", "toggle_collapsible", "Toggle collapsible", show=False) + ] """ | Key(s) | Description | | :- | :- | @@ -68,7 +70,7 @@ async def _on_click(self, event: events.Click) -> None: event.stop() self.post_message(self.Toggle()) - def action_toggle(self) -> None: + def action_toggle_collapsible(self) -> None: """Toggle the state of the parent collapsible.""" self.post_message(self.Toggle()) @@ -90,7 +92,7 @@ def _watch_collapsed(self, collapsed: bool) -> None: class Collapsible(Widget): """A collapsible container.""" - collapsed = reactive(True) + collapsed = reactive(True, init=False) title = reactive("Toggle") DEFAULT_CSS = """ @@ -192,14 +194,14 @@ def __init__( def _on_collapsible_title_toggle(self, event: CollapsibleTitle.Toggle) -> None: event.stop() self.collapsed = not self.collapsed - if self.collapsed: - self.post_message(self.Collapsed(self)) - else: - self.post_message(self.Expanded(self)) def _watch_collapsed(self, collapsed: bool) -> None: """Update collapsed state when reactive is changed.""" self._update_collapsed(collapsed) + if self.collapsed: + self.post_message(self.Collapsed(self)) + else: + self.post_message(self.Expanded(self)) def _update_collapsed(self, collapsed: bool) -> None: """Update children to match collapsed state.""" diff --git a/src/textual/widgets/_data_table.py b/src/textual/widgets/_data_table.py index 7064f7f10a..245af7938f 100644 --- a/src/textual/widgets/_data_table.py +++ b/src/textual/widgets/_data_table.py @@ -35,8 +35,12 @@ CellCacheKey: TypeAlias = ( "tuple[RowKey, ColumnKey, Style, bool, bool, bool, int, PseudoClasses]" ) -LineCacheKey: TypeAlias = "tuple[int, int, int, int, Coordinate, Coordinate, Style, CursorType, bool, int, PseudoClasses]" -RowCacheKey: TypeAlias = "tuple[RowKey, int, Style, Coordinate, Coordinate, CursorType, bool, bool, int, PseudoClasses]" +LineCacheKey: TypeAlias = ( + "tuple[int, int, int, int, Coordinate, Coordinate, Style, CursorType, bool, int, PseudoClasses]" +) +RowCacheKey: TypeAlias = ( + "tuple[RowKey, int, Style, Coordinate, Coordinate, CursorType, bool, bool, int, PseudoClasses]" +) CursorType = Literal["cell", "row", "column", "none"] """The valid types of cursors for [`DataTable.cursor_type`][textual.widgets.DataTable.cursor_type].""" CellType = TypeVar("CellType") @@ -602,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. @@ -658,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/_digits.py b/src/textual/widgets/_digits.py index d298f4e3e9..3bd04fcc25 100644 --- a/src/textual/widgets/_digits.py +++ b/src/textual/widgets/_digits.py @@ -1,10 +1,11 @@ from __future__ import annotations -from typing import cast +from typing import TYPE_CHECKING, cast from rich.align import Align, AlignMethod -from rich.console import RenderableType +if TYPE_CHECKING: + from ..app import RenderResult from ..geometry import Size from ..renderables.digits import Digits as DigitsRenderable from ..widget import Widget @@ -68,7 +69,7 @@ def update(self, value: str) -> None: self._value = value self.refresh(layout=layout_required) - def render(self) -> RenderableType: + def render(self) -> RenderResult: """Render digits.""" rich_style = self.rich_style digits = DigitsRenderable(self._value, rich_style) diff --git a/src/textual/widgets/_directory_tree.py b/src/textual/widgets/_directory_tree.py index 3af6b4841f..16f9d0a100 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. @@ -261,6 +332,7 @@ async def watch_path(self) -> None: If the path is changed the directory tree will be repopulated using the new value as the root. """ + self.reset_node(self.root, str(self.path), DirEntry(self.PATH(self.path))) await self.reload() def process_label(self, label: TextType) -> Text: @@ -398,7 +470,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 +497,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 +529,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/_footer.py b/src/textual/widgets/_footer.py index b5e772ab60..5f3c60d925 100644 --- a/src/textual/widgets/_footer.py +++ b/src/textual/widgets/_footer.py @@ -1,13 +1,16 @@ from __future__ import annotations from collections import defaultdict -from typing import ClassVar, Optional +from typing import TYPE_CHECKING, ClassVar, Optional import rich.repr -from rich.console import RenderableType from rich.text import Text from .. import events + +if TYPE_CHECKING: + from ..app import RenderResult + from ..reactive import reactive from ..widget import Widget @@ -138,7 +141,7 @@ def notify_style_update(self) -> None: def post_render(self, renderable): return renderable - def render(self) -> RenderableType: + def render(self) -> RenderResult: if self._key_text is None: self._key_text = self._make_key_text() return self._key_text diff --git a/src/textual/widgets/_header.py b/src/textual/widgets/_header.py index ac9aeef649..62ebe8879c 100644 --- a/src/textual/widgets/_header.py +++ b/src/textual/widgets/_header.py @@ -192,10 +192,10 @@ def screen_sub_title(self) -> str: return sub_title def _on_mount(self, _: Mount) -> None: - def set_title() -> None: + async def set_title() -> None: self.query_one(HeaderTitle).text = self.screen_title - def set_sub_title(sub_title: str) -> None: + async def set_sub_title() -> None: self.query_one(HeaderTitle).sub_text = self.screen_sub_title self.watch(self.app, "title", set_title) diff --git a/src/textual/widgets/_input.py b/src/textual/widgets/_input.py index 828f2ff4ac..0d3149f2d3 100644 --- a/src/textual/widgets/_input.py +++ b/src/textual/widgets/_input.py @@ -2,10 +2,11 @@ import re from dataclasses import dataclass -from typing import ClassVar, Iterable +from typing import TYPE_CHECKING, ClassVar, Iterable from rich.cells import cell_len, get_character_cell_size -from rich.console import Console, ConsoleOptions, RenderableType, RenderResult +from rich.console import Console, ConsoleOptions +from rich.console import RenderResult as RichRenderResult from rich.highlighter import Highlighter from rich.segment import Segment from rich.text import Text @@ -13,6 +14,10 @@ from .. import events from .._segment_tools import line_crop + +if TYPE_CHECKING: + from ..app import RenderResult + from ..binding import Binding, BindingType from ..css._error_tools import friendly_list from ..events import Blur, Focus, Mount @@ -47,7 +52,7 @@ def __init__(self, input: Input, cursor_visible: bool) -> None: def __rich_console__( self, console: "Console", options: "ConsoleOptions" - ) -> "RenderResult": + ) -> RichRenderResult: input = self.input result = input._value width = input.content_size.width @@ -460,7 +465,7 @@ def cursor_width(self) -> int: return cell_len(self.placeholder) return self._position_to_cell(len(self.value)) + 1 - def render(self) -> RenderableType: + def render(self) -> RenderResult: self.view_position = self.view_position if not self.value: placeholder = Text(self.placeholder, justify="left") 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..69b1039bb3 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)) @@ -242,7 +256,10 @@ def _on_list_item__child_clicked(self, event: ListItem._ChildClicked) -> None: def _scroll_highlighted_region(self) -> None: """Used to keep the highlighted index within vision""" if self.highlighted_child is not None: - self.scroll_to_widget(self.highlighted_child, animate=False) + self.call_after_refresh( + 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/_loading_indicator.py b/src/textual/widgets/_loading_indicator.py index e7cc4abb47..c715bbb997 100644 --- a/src/textual/widgets/_loading_indicator.py +++ b/src/textual/widgets/_loading_indicator.py @@ -1,11 +1,13 @@ from __future__ import annotations from time import time +from typing import TYPE_CHECKING -from rich.console import RenderableType from rich.style import Style from rich.text import Text +if TYPE_CHECKING: + from ..app import RenderResult from ..color import Gradient from ..events import Mount from ..widget import Widget @@ -53,7 +55,10 @@ def _on_mount(self, _: Mount) -> None: self._start_time = time() self.auto_refresh = 1 / 16 - def render(self) -> RenderableType: + def render(self) -> RenderResult: + if self.app.animation_level == "none": + return Text("Loading...") + elapsed = time() - self._start_time speed = 0.8 dot = "\u25cf" 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 d820ccc723..c37c7770e0 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 @@ -102,6 +103,7 @@ def __init__(self, markdown: Markdown, *args, **kwargs) -> None: self._markdown: Markdown = markdown """A reference to the Markdown document that contains this block.""" self._text = Text() + self._token: Token | None = None self._blocks: list[MarkdownBlock] = [] super().__init__(*args, **kwargs) @@ -117,6 +119,95 @@ async def action_link(self, href: str) -> None: """Called on link click.""" self.post_message(Markdown.LinkClicked(self._markdown, href)) + def notify_style_update(self) -> None: + """If CSS was reloaded, try to rebuild this block from its token.""" + super().notify_style_update() + self.rebuild() + + def rebuild(self) -> None: + """Rebuild the content of the block if we have a source token.""" + if self._token is not None: + self.build_from_token(self._token) + + def build_from_token(self, token: Token) -> None: + """Build the block content from its source token. + + This method allows the block to be rebuilt on demand, which is useful + when the styles assigned to the + [Markdown.COMPONENT_CLASSES][textual.widgets.Markdown.COMPONENT_CLASSES] + change. + + See https://github.com/Textualize/textual/issues/3464 for more information. + + Args: + token: The token from which this block is built. + """ + + self._token = token + style_stack: list[Style] = [Style()] + content = Text() + if token.children: + for child in token.children: + if child.type == "text": + content.append(child.content, style_stack[-1]) + if child.type == "hardbreak": + content.append("\n") + if child.type == "softbreak": + content.append(" ", style_stack[-1]) + elif child.type == "code_inline": + content.append( + child.content, + style_stack[-1] + + self._markdown.get_component_rich_style( + "code_inline", partial=True + ), + ) + elif child.type == "em_open": + style_stack.append( + style_stack[-1] + + self._markdown.get_component_rich_style("em", partial=True) + ) + elif child.type == "strong_open": + style_stack.append( + style_stack[-1] + + self._markdown.get_component_rich_style( + "strong", partial=True + ) + ) + elif child.type == "s_open": + style_stack.append( + style_stack[-1] + + self._markdown.get_component_rich_style("s", partial=True) + ) + elif child.type == "link_open": + href = child.attrs.get("href", "") + action = f"link({href!r})" + style_stack.append( + style_stack[-1] + Style.from_meta({"@click": action}) + ) + elif child.type == "image": + href = child.attrs.get("src", "") + alt = child.attrs.get("alt", "") + + action = f"link({href!r})" + style_stack.append( + style_stack[-1] + Style.from_meta({"@click": action}) + ) + + content.append("🖼 ", style_stack[-1]) + if alt: + content.append(f"({alt})", style_stack[-1]) + if child.children is not None: + for grandchild in child.children: + content.append(grandchild.content, style_stack[-1]) + + style_stack.pop() + + elif child.type.endswith("_close"): + style_stack.pop() + + self.set_content(content) + class MarkdownHeader(MarkdownBlock): """Base class for a Markdown header.""" @@ -498,22 +589,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 +677,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 +769,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. @@ -795,74 +923,10 @@ def update(self, markdown: str) -> AwaitComplete: else: output.append(block) elif token.type == "inline": - style_stack: list[Style] = [Style()] - content = Text() - if token.children: - for child in token.children: - if child.type == "text": - content.append(child.content, style_stack[-1]) - if child.type == "hardbreak": - content.append("\n") - if child.type == "softbreak": - content.append(" ", style_stack[-1]) - elif child.type == "code_inline": - content.append( - child.content, - style_stack[-1] - + self.get_component_rich_style( - "code_inline", partial=True - ), - ) - elif child.type == "em_open": - style_stack.append( - style_stack[-1] - + self.get_component_rich_style("em", partial=True) - ) - elif child.type == "strong_open": - style_stack.append( - style_stack[-1] - + self.get_component_rich_style("strong", partial=True) - ) - elif child.type == "s_open": - style_stack.append( - style_stack[-1] - + self.get_component_rich_style("s", partial=True) - ) - elif child.type == "link_open": - href = child.attrs.get("href", "") - action = f"link({href!r})" - style_stack.append( - style_stack[-1] + Style.from_meta({"@click": action}) - ) - elif child.type == "image": - href = child.attrs.get("src", "") - alt = child.attrs.get("alt", "") - - action = f"link({href!r})" - style_stack.append( - style_stack[-1] + Style.from_meta({"@click": action}) - ) - - content.append("🖼 ", style_stack[-1]) - if alt: - content.append(f"({alt})", style_stack[-1]) - if child.children is not None: - for grandchild in child.children: - content.append(grandchild.content, style_stack[-1]) - - style_stack.pop() - - elif child.type.endswith("_close"): - style_stack.pop() - - stack[-1].set_content(content) + stack[-1].build_from_token(token) 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) @@ -870,7 +934,9 @@ def update(self, markdown: str) -> AwaitComplete: (stack[-1]._blocks if stack else output).append(external) self.post_message( - Markdown.TableOfContentsUpdated(self, self._table_of_contents) + Markdown.TableOfContentsUpdated(self, self._table_of_contents).set_sender( + self + ) ) markdown_block = self.query("MarkdownBlock") @@ -1068,9 +1134,9 @@ def compose(self) -> ComposeResult: def _on_markdown_table_of_contents_updated( self, message: Markdown.TableOfContentsUpdated ) -> None: - self.query_one( - MarkdownTableOfContents - ).table_of_contents = message.table_of_contents + self.query_one(MarkdownTableOfContents).table_of_contents = ( + message.table_of_contents + ) message.stop() def _on_markdown_table_of_contents_selected( 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/_placeholder.py b/src/textual/widgets/_placeholder.py index 9c9adf725c..3b210db921 100644 --- a/src/textual/widgets/_placeholder.py +++ b/src/textual/widgets/_placeholder.py @@ -6,10 +6,13 @@ from typing import TYPE_CHECKING, Iterator from weakref import WeakKeyDictionary -from rich.console import RenderableType from typing_extensions import Literal, Self from .. import events + +if TYPE_CHECKING: + from ..app import RenderResult + from ..css._error_tools import friendly_list from ..reactive import Reactive, reactive from ..widget import Widget @@ -70,6 +73,10 @@ class Placeholder(Widget): content-align: center middle; overflow: hidden; color: $text; + + &:disabled { + opacity: 0.7; + } } Placeholder.-text { padding: 1; @@ -92,6 +99,7 @@ def __init__( name: str | None = None, id: str | None = None, classes: str | None = None, + disabled: bool = False, ) -> None: """Create a Placeholder widget. @@ -103,6 +111,7 @@ def __init__( id: The ID of the placeholder in the DOM. classes: A space separated string with the CSS classes of the placeholder, if any. + disabled: Whether the placeholder is disabled or not. """ # Create and cache renderables for all the variants. self._renderables = { @@ -111,7 +120,7 @@ def __init__( "text": "\n\n".join(_LOREM_IPSUM_PLACEHOLDER_TEXT for _ in range(5)), } - super().__init__(name=name, id=id, classes=classes) + super().__init__(name=name, id=id, classes=classes, disabled=disabled) self.variant = self.validate_variant(variant) """The current variant of the placeholder.""" @@ -128,7 +137,7 @@ async def _on_compose(self, event: events.Compose) -> None: ) self.styles.background = f"{next(colors)} 50%" - def render(self) -> RenderableType: + def render(self) -> RenderResult: """Render the placeholder. Returns: diff --git a/src/textual/widgets/_pretty.py b/src/textual/widgets/_pretty.py index 34d88b3964..ffa89819d8 100644 --- a/src/textual/widgets/_pretty.py +++ b/src/textual/widgets/_pretty.py @@ -59,4 +59,5 @@ def update(self, object: Any) -> None: object: The object to pretty-print. """ self._renderable = PrettyRenderable(object) + self.clear_cached_dimensions() self.refresh(layout=True) diff --git a/src/textual/widgets/_progress_bar.py b/src/textual/widgets/_progress_bar.py index ec8c1b22cb..d7b464cd39 100644 --- a/src/textual/widgets/_progress_bar.py +++ b/src/textual/widgets/_progress_bar.py @@ -2,19 +2,17 @@ from __future__ import annotations -from math import ceil -from time import monotonic -from typing import Callable, Optional +from typing import Optional from rich.style import Style from .._types import UnusedParameter from ..app import ComposeResult, RenderResult -from ..containers import Horizontal +from ..clock import Clock +from ..eta import ETA from ..geometry import clamp from ..reactive import reactive from ..renderables.bar import Bar as BarRenderable -from ..timer import Timer from ..widget import Widget from ..widgets import Label @@ -43,25 +41,24 @@ class Bar(Widget, can_focus=False): Bar { width: 32; height: 1; - } - Bar > .bar--bar { - color: $warning; - background: $foreground 10%; - } - Bar > .bar--indeterminate { - color: $error; - background: $foreground 10%; - } - Bar > .bar--complete { - color: $success; - background: $foreground 10%; + + &> .bar--bar { + color: $warning; + background: $foreground 10%; + } + &> .bar--indeterminate { + color: $error; + background: $foreground 10%; + } + &> .bar--complete { + color: $success; + background: $foreground 10%; + } } """ - _percentage: reactive[float | None] = reactive[Optional[float]](None) + percentage: reactive[float | None] = reactive[Optional[float]](None) """The percentage of progress that has been completed.""" - _start_time: float | None - """The time when the widget started tracking progress.""" def __init__( self, @@ -69,13 +66,22 @@ def __init__( id: str | None = None, classes: str | None = None, disabled: bool = False, + clock: Clock | None = None, ): """Create a bar for a [`ProgressBar`][textual.widgets.ProgressBar].""" + self._clock = (clock or Clock()).clone() super().__init__(name=name, id=id, classes=classes, disabled=disabled) - self._start_time = None - self._percentage = None - def watch__percentage(self, percentage: float | None) -> None: + def _validate_percentage(self, percentage: float | None) -> float | None: + """Avoid updating the bar, if the percentage increase is too small to render.""" + width = self.size.width * 2 + return ( + None + if percentage is None + else (int(percentage * width) / width if width else percentage) + ) + + def watch_percentage(self, percentage: float | None) -> None: """Manage the timer that enables the indeterminate bar animation.""" if percentage is not None: self.auto_refresh = None @@ -84,16 +90,16 @@ def watch__percentage(self, percentage: float | None) -> None: def render(self) -> RenderResult: """Render the bar with the correct portion filled.""" - if self._percentage is None: + if self.percentage is None: return self.render_indeterminate() else: bar_style = ( self.get_component_rich_style("bar--bar") - if self._percentage < 1 + if self.percentage < 1 else self.get_component_rich_style("bar--complete") ) return BarRenderable( - highlight_range=(0, self.size.width * self._percentage), + highlight_range=(0, self.size.width * self.percentage), highlight_style=Style.from_color(bar_style.color), background_style=Style.from_color(bar_style.bgcolor), ) @@ -104,15 +110,20 @@ def render_indeterminate(self) -> RenderResult: highlighted_bar_width = 0.25 * width # Width used to enable the visual effect of the bar going into the corners. total_imaginary_width = width + highlighted_bar_width - - speed = 30 # Cells per second. - # Compute the position of the bar. - start = (speed * self._get_elapsed_time()) % (2 * total_imaginary_width) - if start > total_imaginary_width: - # If the bar is to the right of its width, wrap it back from right to left. - start = 2 * total_imaginary_width - start # = (tiw - (start - tiw)) - start -= highlighted_bar_width - end = start + highlighted_bar_width + start: float + end: float + if self.app.animation_level == "none": + start = 0 + end = width + else: + speed = 30 # Cells per second. + # Compute the position of the bar. + start = (speed * self._clock.time) % (2 * total_imaginary_width) + if start > total_imaginary_width: + # If the bar is to the right of its width, wrap it back from right to left. + start = 2 * total_imaginary_width - start # = (tiw - (start - tiw)) + start -= highlighted_bar_width + end = start + highlighted_bar_width bar_style = self.get_component_rich_style("bar--indeterminate") return BarRenderable( @@ -121,21 +132,6 @@ def render_indeterminate(self) -> RenderResult: background_style=Style.from_color(bar_style.bgcolor), ) - def _get_elapsed_time(self) -> float: - """Get time for the indeterminate progress animation. - - This method ensures that the progress bar animation always starts at the - beginning and it also makes it easier to test the bar if we monkey patch - this method. - - Returns: - The time elapsed since the bar started being animated. - """ - if self._start_time is None: - self._start_time = monotonic() - return 0 - return monotonic() - self._start_time - class PercentageStatus(Label): """A label to display the percentage status of the progress bar.""" @@ -147,32 +143,14 @@ class PercentageStatus(Label): } """ - _label_text: reactive[str] = reactive("", repaint=False) - """This is used as an auxiliary reactive to only refresh the label when needed.""" - _percentage: reactive[float | None] = reactive[Optional[float]](None) + percentage: reactive[int | None] = reactive[Optional[int]](None) """The percentage of progress that has been completed.""" - def __init__( - self, - name: str | None = None, - id: str | None = None, - classes: str | None = None, - disabled: bool = False, - ): - super().__init__(name=name, id=id, classes=classes, disabled=disabled) - self._percentage = None - self._label_text = "--%" - - def watch__percentage(self, percentage: float | None) -> None: - """Manage the text that shows the percentage of progress.""" - if percentage is None: - self._label_text = "--%" - else: - self._label_text = f"{int(100 * percentage)}%" + def _validate_percentage(self, percentage: float | None) -> int | None: + return None if percentage is None else round(percentage * 100) - def watch__label_text(self, label_text: str) -> None: - """If the label text changed, update the renderable (which also refreshes).""" - self.update(label_text) + def render(self) -> RenderResult: + return "--%" if self.percentage is None else f"{self.percentage}%" class ETAStatus(Label): @@ -184,92 +162,33 @@ class ETAStatus(Label): content-align-horizontal: right; } """ + eta: reactive[float | None] = reactive[Optional[float]](None) + """Estimated number of seconds till completion, or `None` if no estimate is available.""" - _label_text: reactive[str] = reactive("", repaint=False) - """This is used as an auxiliary reactive to only refresh the label when needed.""" - _percentage: reactive[float | None] = reactive[Optional[float]](None) - """The percentage of progress that has been completed.""" - _refresh_timer: Timer | None - """Timer to update ETA status even when progress stalls.""" - _start_time: float | None - """The time when the widget started tracking progress.""" - - def __init__( - self, - name: str | None = None, - id: str | None = None, - classes: str | None = None, - disabled: bool = False, - ): - super().__init__(name=name, id=id, classes=classes, disabled=disabled) - self._percentage = None - self._label_text = "--:--:--" - self._start_time = None - self._refresh_timer = None - - def on_mount(self) -> None: - """Periodically refresh the countdown so that the ETA is always up to date.""" - self._refresh_timer = self.set_interval(1 / 2, self.update_eta, pause=True) - - def watch__percentage(self, percentage: float | None) -> None: - if percentage is None: - self._label_text = "--:--:--" - else: - if self._refresh_timer is not None: - self._refresh_timer.reset() - self.update_eta() - - def update_eta(self) -> None: - """Update the ETA display.""" - percentage = self._percentage - delta = self._get_elapsed_time() - # We display --:--:-- if we haven't started, if we are done, - # or if we don't know when we started keeping track of time. - if not percentage or percentage >= 1 or not delta: - self._label_text = "--:--:--" - # If we are done, we can delete the timer that periodically refreshes - # the countdown display. - if percentage is not None and percentage >= 1: - self.auto_refresh = None - # Render a countdown timer with hh:mm:ss, unless it's a LONG time. + def render(self) -> RenderResult: + """Render the ETA display.""" + eta = self.eta + if eta is None: + return "--:--:--" else: - left = ceil((delta / percentage) * (1 - percentage)) - minutes, seconds = divmod(left, 60) + minutes, seconds = divmod(round(eta), 60) hours, minutes = divmod(minutes, 60) if hours > 999999: - self._label_text = "+999999h" + return "+999999h" elif hours > 99: - self._label_text = f"{hours}h" + return f"{hours}h" else: - self._label_text = f"{hours:02}:{minutes:02}:{seconds:02}" - - def _get_elapsed_time(self) -> float: - """Get time to estimate time to progress completion. - - Returns: - The time elapsed since the bar started being animated. - """ - if self._start_time is None: - self._start_time = monotonic() - return 0 - return monotonic() - self._start_time - - def watch__label_text(self, label_text: str) -> None: - """If the ETA label changed, update the renderable (which also refreshes).""" - self.update(label_text) + return f"{hours:02}:{minutes:02}:{seconds:02}" class ProgressBar(Widget, can_focus=False): """A progress bar widget.""" DEFAULT_CSS = """ - ProgressBar > Horizontal { - width: auto; - height: auto; - } ProgressBar { width: auto; height: 1; + layout: horizontal; } """ @@ -295,6 +214,7 @@ class ProgressBar(Widget, can_focus=False): print(progress_bar.percentage) # 0.5 ``` """ + _display_eta: reactive[int | None] = reactive[Optional[int]](None) def __init__( self, @@ -307,6 +227,7 @@ def __init__( id: str | None = None, classes: str | None = None, disabled: bool = False, + clock: Clock | None = None, ): """Create a Progress Bar widget. @@ -331,68 +252,54 @@ def key_space(self): id: The ID of the widget in the DOM. classes: The CSS classes for the widget. disabled: Whether the widget is disabled or not. + clock: An optional clock object (leave as default unless testing). """ + self._clock = clock or Clock() + self._eta = ETA() super().__init__(name=name, id=id, classes=classes, disabled=disabled) + self.total = total self.show_bar = show_bar self.show_percentage = show_percentage self.show_eta = show_eta - self.total = total + def on_mount(self) -> None: + self.update() + self.set_interval(1, self.update) + self._clock.reset() def compose(self) -> ComposeResult: - # We create a closure so that we can determine what are the sub-widgets - # that are present and, therefore, will need to be notified about changes - # to the percentage. - def update_percentage(widget: Widget) -> Callable[[float | None], None]: - """Closure to allow updating the percentage of a given widget.""" - - def updater(percentage: float | None) -> None: - """Update the percentage reactive of the enclosed widget.""" - widget._percentage = percentage - - return updater - - with Horizontal(): - if self.show_bar: - bar = Bar(id="bar") - self.watch(self, "percentage", update_percentage(bar)) - yield bar - if self.show_percentage: - percentage_status = PercentageStatus(id="percentage") - self.watch(self, "percentage", update_percentage(percentage_status)) - yield percentage_status - if self.show_eta: - eta_status = ETAStatus(id="eta") - self.watch(self, "percentage", update_percentage(eta_status)) - yield eta_status - - def validate_progress(self, progress: float) -> float: - """Clamp the progress between 0 and the maximum total.""" - if self.total is not None: - return clamp(progress, 0, self.total) - return progress - - def validate_total(self, total: float | None) -> float | None: + if self.show_bar: + yield Bar(id="bar", clock=self._clock).data_bind(ProgressBar.percentage) + if self.show_percentage: + yield PercentageStatus(id="percentage").data_bind(ProgressBar.percentage) + if self.show_eta: + yield ETAStatus(id="eta").data_bind(eta=ProgressBar._display_eta) + + def _validate_total(self, total: float | None) -> float | None: """Ensure the total is not negative.""" if total is None: return total return max(0, total) - def watch_total(self, total: float | None) -> None: - """Re-validate progress.""" - self.progress = self.progress - - def compute_percentage(self) -> float | None: + def _compute_percentage(self) -> float | None: """Keep the percentage of progress updated automatically. This will report a percentage of `1` if the total is zero. """ if self.total: - return self.progress / self.total + return clamp(self.progress / self.total, 0.0, 1.0) elif self.total == 0: - return 1 + return 1.0 return None + def _watch_progress(self, progress: float) -> None: + """Perform update when progress is modified.""" + self.update(progress=progress) + + def _watch_total(self, total: float) -> None: + """Update when the total is modified.""" + self.update(total=total) + def advance(self, advance: float = 1) -> None: """Advance the progress of the progress bar by the given amount. @@ -404,7 +311,7 @@ def advance(self, advance: float = 1) -> None: Args: advance: Number of steps to advance progress by. """ - self.progress += advance + self.update(advance=advance) def update( self, @@ -428,9 +335,25 @@ def update( progress: Set the progress to the given number of steps. advance: Advance the progress by this number of steps. """ + current_time = self._clock.time if not isinstance(total, UnusedParameter): + if total is None or total != self.total: + self._eta.reset() self.total = total + + def add_sample() -> None: + """Add a new sample.""" + if self.progress is not None and self.total: + self._eta.add_sample(current_time, self.progress / self.total) + if not isinstance(progress, UnusedParameter): self.progress = progress + add_sample() + if not isinstance(advance, UnusedParameter): self.progress += advance + add_sample() + + self._display_eta = ( + None if self.total is None else self._eta.get_eta(current_time) + ) diff --git a/src/textual/widgets/_radio_set.py b/src/textual/widgets/_radio_set.py index 8223e69713..7ea1fea4f0 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 @@ -62,7 +62,7 @@ class RadioSet(Container, can_focus=True, can_focus_children=False): BINDINGS: ClassVar[list[BindingType]] = [ Binding("down,right", "next_button", "", show=False), - Binding("enter,space", "toggle", "Toggle", show=False), + Binding("enter,space", "toggle_button", "Toggle", show=False), Binding("up,left", "previous_button", "", show=False), ] """ @@ -248,60 +248,24 @@ 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: + def action_toggle_button(self) -> None: """Toggle the state of the currently-selected button.""" if self._selected is not None: button = self._nodes[self._selected] diff --git a/src/textual/widgets/_rich_log.py b/src/textual/widgets/_rich_log.py index 809a30b4d6..404760a96b 100644 --- a/src/textual/widgets/_rich_log.py +++ b/src/textual/widgets/_rich_log.py @@ -5,7 +5,7 @@ from typing import TYPE_CHECKING, Optional, cast from rich.console import RenderableType -from rich.highlighter import ReprHighlighter +from rich.highlighter import Highlighter, ReprHighlighter from rich.measure import measure_renderables from rich.pretty import Pretty from rich.protocol import is_renderable @@ -86,11 +86,18 @@ def __init__( """Apply Rich console markup.""" self.auto_scroll = auto_scroll """Automatically scroll to the end on write.""" - self.highlighter = ReprHighlighter() + self.highlighter: Highlighter = ReprHighlighter() + """Rich Highlighter used to highlight content when highlight is True""" + + self._last_container_width: int = min_width + """Record the last width we rendered content at.""" def notify_style_update(self) -> None: self._line_cache.clear() + def on_resize(self) -> None: + self._last_container_width = self.scrollable_content_region.width + def _make_renderable(self, content: RenderableType | object) -> RenderableType: """Make content renderable. @@ -153,14 +160,22 @@ def write( render_width = measure_renderables( console, render_options, [renderable] ).maximum + container_width = ( self.scrollable_content_region.width if width is None else width ) - if container_width: - if expand and render_width < container_width: - render_width = container_width - if shrink and render_width > container_width: - render_width = container_width + + # Use the container_width if it's available, otherwise use the last available width. + container_width = ( + container_width if container_width else self._last_container_width + ) + + if expand and render_width < container_width: + render_width = container_width + if shrink and render_width > container_width: + render_width = container_width + + render_width = max(render_width, self.min_width) segments = self.app.console.render( renderable, render_options.update_width(render_width) diff --git a/src/textual/widgets/_select.py b/src/textual/widgets/_select.py index 93d82379b1..240cffe9ef 100644 --- a/src/textual/widgets/_select.py +++ b/src/textual/widgets/_select.py @@ -95,6 +95,12 @@ def on_option_list_option_selected(self, event: OptionList.OptionSelected) -> No event.stop() self.post_message(self.UpdateSelection(event.option_index)) + def on_option_list_option_highlighted( + self, event: OptionList.OptionHighlighted + ) -> None: + """Stop option list highlighted messages leaking.""" + event.stop() + class SelectCurrent(Horizontal): """Displays the currently selected option.""" @@ -240,7 +246,9 @@ class Select(Generic[SelectType], Vertical, can_focus=True): """True to show the overlay, otherwise False.""" prompt: var[str] = var[str]("Select") """The prompt to show when no value is selected.""" - value: var[SelectType | NoSelection] = var[Union[SelectType, NoSelection]](BLANK) + value: var[SelectType | NoSelection] = var[Union[SelectType, NoSelection]]( + BLANK, init=False + ) """The value of the selection. If the widget has no selection, its value will be [`Select.BLANK`][textual.widgets.Select.BLANK]. @@ -459,6 +467,7 @@ def _watch_value(self, value: SelectType | NoSelection) -> None: select_overlay.highlighted = index select_current.update(prompt) break + self.post_message(self.Changed(self, value)) def compose(self) -> ComposeResult: """Compose Select with overlay and current value.""" @@ -509,7 +518,6 @@ def _update_selection(self, event: SelectOverlay.UpdateSelection) -> None: value = self._options[event.option_index][1] if value != self.value: self.value = value - self.post_message(self.Changed(self, value)) async def update_focus() -> None: """Update focus and reset overlay.""" @@ -523,6 +531,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 f8dece7142..7c0ee3f0a5 100644 --- a/src/textual/widgets/_selection_list.py +++ b/src/textual/widgets/_selection_list.py @@ -11,6 +11,7 @@ from rich.text import Text, TextType from typing_extensions import Self +from .. import events from ..binding import Binding from ..messages import Message from ..strip import Strip @@ -157,7 +158,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: @@ -167,9 +170,9 @@ def __init__(self, selection_list: SelectionList, index: int) -> None: super().__init__() self.selection_list: SelectionList[MessageSelectionType] = selection_list """The selection list that sent the message.""" - self.selection: Selection[ - MessageSelectionType - ] = selection_list.get_option_at_index(index) + self.selection: Selection[MessageSelectionType] = ( + selection_list.get_option_at_index(index) + ) """The highlighted selection.""" self.selection_index: int = index """The index of the selection that the message relates to.""" @@ -189,14 +192,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 +232,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, @@ -250,14 +253,19 @@ def __init__( """Tracking of which values are selected.""" self._send_messages = False """Keep track of when we're ready to start sending messages.""" + options = [self._make_selection(selection) for selection in selections] super().__init__( - *[self._make_selection(selection) for selection in selections], + *options, name=name, id=id, classes=classes, disabled=disabled, wrap=False, ) + self._values: dict[SelectionType, int] = { + option.value: index for index, option in enumerate(options) + } + """Keeps track of which value relates to which option.""" @property def selected(self) -> list[SelectionType]: @@ -270,7 +278,7 @@ def selected(self) -> list[SelectionType]: """ return list(self._selected.keys()) - def _on_mount(self) -> None: + def _on_mount(self, _event: events.Mount) -> None: """Configure the list once the DOM is ready.""" self._send_messages = True @@ -283,7 +291,20 @@ def _message_changed(self) -> None: messages. """ if self._send_messages: - self.post_message(self.SelectedChanged(self)) + self.post_message(self.SelectedChanged(self).set_sender(self)) + + def _message_toggled(self, option_index: int) -> None: + """Post a message that an option was toggled, where appropriate. + + Note: + A message will only be sent if `_send_messages` is `True`. This + makes this safe to call before the widget is ready for posting + messages. + """ + if self._send_messages: + self.post_message( + self.SelectionToggled(self, option_index).set_sender(self) + ) def _apply_to_all(self, state_change: Callable[[SelectionType], bool]) -> Self: """Apply a selection state change to all selection options in the list. @@ -304,11 +325,14 @@ def _apply_to_all(self, state_change: Callable[[SelectionType], bool]) -> Self: changed = False # Next we run through everything and apply the change, preventing - # the changed message because the caller really isn't going to be - # expecting a message storm from this. - with self.prevent(self.SelectedChanged): + # the toggled and changed messages because the caller really isn't + # going to be expecting a message storm from this. + with self.prevent(self.SelectedChanged, self.SelectionToggled): 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: @@ -411,6 +435,7 @@ def _toggle(self, value: SelectionType) -> bool: self._deselect(value) else: self._select(value) + self._message_toggled(self._values[value]) return True def toggle(self, selection: Selection[SelectionType] | SelectionType) -> Self: @@ -440,9 +465,11 @@ def toggle_all(self) -> Self: def _make_selection( self, - selection: Selection - | tuple[TextType, SelectionType] - | tuple[TextType, SelectionType, bool], + selection: ( + Selection[SelectionType] + | tuple[TextType, SelectionType] + | tuple[TextType, SelectionType, bool] + ), ) -> Selection[SelectionType]: """Turn incoming selection data into a `Selection` instance. @@ -586,7 +613,6 @@ def _on_option_list_option_selected(self, event: OptionList.OptionSelected) -> N """ event.stop() self._toggle_highlighted_selection() - self.post_message(self.SelectionToggled(self, event.option_index)) def get_option_at_index(self, index: int) -> Selection[SelectionType]: """Get the selection option at the given index. @@ -625,14 +651,16 @@ def _remove_option(self, index: int) -> None: Raises: IndexError: If there is no selection option of the given index. """ - self._deselect(self.get_option_at_index(index).value) + option = self.get_option_at_index(index) + self._deselect(option.value) + del self._values[option.value] return super()._remove_option(index) def add_options( self, items: Iterable[ NewOptionListContent - | Selection + | Selection[SelectionType] | tuple[TextType, SelectionType] | tuple[TextType, SelectionType, bool] ], @@ -655,7 +683,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( @@ -672,14 +700,25 @@ def add_options( raise SelectionError( "Only Selection or a prompt/value tuple is supported in SelectionList" ) + + # Add the new items to the value mappings. + self._values.update( + { + option.value: index + for index, option in enumerate(cleaned_options, start=self.option_count) + } + ) + return super().add_options(cleaned_options) def add_option( self, - item: NewOptionListContent - | Selection - | tuple[TextType, SelectionType] - | tuple[TextType, SelectionType, bool] = None, + item: ( + NewOptionListContent + | Selection + | tuple[TextType, SelectionType] + | tuple[TextType, SelectionType, bool] + ) = None, ) -> Self: """Add a new selection option to the end of the list. @@ -702,4 +741,5 @@ def clear_options(self) -> Self: The `SelectionList` instance. """ self._selected.clear() + self._values.clear() return super().clear_options() diff --git a/src/textual/widgets/_static.py b/src/textual/widgets/_static.py index a9f485345b..81215fd30d 100644 --- a/src/textual/widgets/_static.py +++ b/src/textual/widgets/_static.py @@ -1,9 +1,14 @@ from __future__ import annotations +from typing import TYPE_CHECKING + from rich.console import RenderableType from rich.protocol import is_renderable from rich.text import Text +if TYPE_CHECKING: + from ..app import RenderResult + from ..errors import RenderError from ..widget import Widget @@ -78,8 +83,9 @@ def renderable(self, renderable: RenderableType) -> None: self._renderable = Text(renderable) else: self._renderable = renderable + self.clear_cached_dimensions() - def render(self) -> RenderableType: + def render(self) -> RenderResult: """Get a rich renderable for the widget's content. Returns: diff --git a/src/textual/widgets/_switch.py b/src/textual/widgets/_switch.py index a6114ff3a8..b6dbe5f6ea 100644 --- a/src/textual/widgets/_switch.py +++ b/src/textual/widgets/_switch.py @@ -2,8 +2,8 @@ from typing import TYPE_CHECKING, ClassVar -from rich.console import RenderableType - +if TYPE_CHECKING: + from ..app import RenderResult from ..binding import Binding, BindingType from ..events import Click from ..geometry import Size @@ -26,7 +26,7 @@ class Switch(Widget, can_focus=True): """ BINDINGS: ClassVar[list[BindingType]] = [ - Binding("enter,space", "toggle", "Toggle", show=False), + Binding("enter,space", "toggle_switch", "Toggle", show=False), ] """ | Key(s) | Description | @@ -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,13 +124,18 @@ 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: target_slider_pos = 1.0 if value else 0.0 if self._should_animate: - self.animate("slider_pos", target_slider_pos, duration=0.3) + self.animate( + "slider_pos", + target_slider_pos, + duration=0.3, + level="basic", + ) else: self.slider_pos = target_slider_pos self.post_message(self.Changed(self, self.value)) @@ -138,7 +143,7 @@ def watch_value(self, value: bool) -> None: def watch_slider_pos(self, slider_pos: float) -> None: self.set_class(slider_pos == 1, "-on") - def render(self) -> RenderableType: + def render(self) -> RenderResult: style = self.get_component_rich_style("switch--slider") return ScrollBarRender( virtual_size=100, @@ -159,7 +164,7 @@ async def _on_click(self, event: Click) -> None: event.stop() self.toggle() - def action_toggle(self) -> None: + def action_toggle_switch(self) -> None: """Toggle the state of the switch.""" self.toggle() diff --git a/src/textual/widgets/_tabbed_content.py b/src/textual/widgets/_tabbed_content.py index 47fa6865c0..54b0c38799 100644 --- a/src/textual/widgets/_tabbed_content.py +++ b/src/textual/widgets/_tabbed_content.py @@ -3,6 +3,7 @@ from asyncio import gather from dataclasses import dataclass from itertools import zip_longest +from typing import Awaitable from rich.repr import Result from rich.text import Text, TextType @@ -228,12 +229,11 @@ class TabbedContent(Widget): """A container with associated tabs to toggle content visibility.""" DEFAULT_CSS = """ - TabbedContent { height: auto; - } - TabbedContent Tabs { - dock: top; + &> ContentTabs { + dock: top; + } } """ @@ -276,7 +276,11 @@ def __rich_repr__(self) -> Result: yield self.pane class Cleared(Message): - """Posted when there are no more tab panes.""" + """Posted when no tab pane is active. + + This can happen if all tab panes are removed or if the currently active tab + pane is unset. + """ def __init__(self, tabbed_content: TabbedContent) -> None: """Initialize message. @@ -321,21 +325,13 @@ def __init__( self._initial = initial super().__init__(name=name, id=id, classes=classes, disabled=disabled) - def validate_active(self, active: str) -> str: - """It doesn't make sense for `active` to be an empty string. - - Args: - active: Attribute to be validated. - - Returns: - Value of `active`. - - Raises: - ValueError: If the active attribute is set to empty string when there are tabs available. - """ - if not active and self.get_child_by_type(ContentSwitcher).current: - raise ValueError("'active' tab must not be empty string.") - return active + @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) @staticmethod def _set_id(content: TabPane, new_id: int) -> TabPane: @@ -358,9 +354,11 @@ def compose(self) -> ComposeResult: # Wrap content in a `TabPane` if required. pane_content = [ self._set_id( - content - if isinstance(content, TabPane) - else TabPane(title or self.render_str(f"Tab {index}"), content), + ( + content + if isinstance(content, TabPane) + else TabPane(title or self.render_str(f"Tab {index}"), content) + ), index, ) for index, (title, content) in enumerate( @@ -439,7 +437,7 @@ def remove_pane(self, pane_id: str) -> AwaitComplete: An optionally awaitable object that waits for the pane to be removed and the Cleared message to be posted. """ - removal_awaitables = [ + removal_awaitables: list[Awaitable] = [ self.get_child_by_type(ContentTabs).remove_tab( ContentTab.add_prefix(pane_id) ) @@ -455,16 +453,10 @@ def remove_pane(self, pane_id: str) -> AwaitComplete: # other means; so allow that to be a no-op. pass - async def _remove_content(cleared_message: TabbedContent.Cleared) -> None: + async def _remove_content() -> None: await gather(*removal_awaitables) - if self.tab_count == 0: - self.post_message(cleared_message) - # Note that I create the Cleared message out here, rather than in - # _remove_content, to ensure that the message's internal - # understanding of who the sender is is correct. - # https://github.com/Textualize/textual/issues/2750 - return AwaitComplete(_remove_content(self.Cleared(self))) + return AwaitComplete(_remove_content()) def clear_panes(self) -> AwaitComplete: """Remove all the panes in the tabbed content. @@ -478,15 +470,10 @@ def clear_panes(self) -> AwaitComplete: self.get_child_by_type(ContentSwitcher).remove_children(), ) - async def _clear_content(cleared_message: TabbedContent.Cleared) -> None: + async def _clear_content() -> None: await await_clear - self.post_message(cleared_message) - # Note that I create the Cleared message out here, rather than in - # _clear_content, to ensure that the message's internal - # understanding of who the sender is is correct. - # https://github.com/Textualize/textual/issues/2750 - return AwaitComplete(_clear_content(self.Cleared(self))) + return AwaitComplete(_clear_content()) def compose_add_child(self, widget: Widget) -> None: """When using the context manager compose syntax, we want to attach nodes to the switcher. @@ -501,13 +488,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 + ), ) ) @@ -536,9 +532,20 @@ def _is_associated_tabs(self, tabs: Tabs) -> bool: def _watch_active(self, active: str) -> None: """Switch tabs when the active attributes changes.""" - with self.prevent(Tabs.TabActivated): + with self.prevent(Tabs.TabActivated, Tabs.Cleared): 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), + ) + ) + else: + self.post_message( + TabbedContent.Cleared(tabbed_content=self).set_sender(self) + ) @property def tab_count(self) -> int: @@ -592,6 +599,8 @@ def get_pane(self, pane_id: str | ContentTab) -> TabPane: def _on_tabs_tab_disabled(self, event: Tabs.TabDisabled) -> None: """Disable the corresponding tab pane.""" + if event.tabs.parent is not self: + return event.stop() tab_id = event.tab.id or "" try: @@ -613,6 +622,8 @@ def _on_tab_pane_disabled(self, event: TabPane.Disabled) -> None: def _on_tabs_tab_enabled(self, event: Tabs.TabEnabled) -> None: """Enable the corresponding tab pane.""" + if event.tabs.parent is not self: + return event.stop() tab_id = event.tab.id or "" try: diff --git a/src/textual/widgets/_tabs.py b/src/textual/widgets/_tabs.py index 0123e17688..96d9c7c554 100644 --- a/src/textual/widgets/_tabs.py +++ b/src/textual/widgets/_tabs.py @@ -165,7 +165,8 @@ def __init__( """ super().__init__(id=id, classes=classes, disabled=disabled) self._label: Text - self.label = label + # Setter takes Text or str + self.label = label # type: ignore[assignment] @property def label(self) -> Text: @@ -285,7 +286,8 @@ class TabShown(TabMessage): class Cleared(Message): """Sent when there are no active tabs. - This can occur when Tabs are cleared, or if all tabs are hidden. + This can occur when Tabs are cleared, if all tabs are hidden, or if the + currently active tab is unset. """ def __init__(self, tabs: Tabs) -> None: @@ -496,7 +498,7 @@ def clear(self) -> AwaitComplete: underline.highlight_end = 0 self.call_after_refresh(self.post_message, self.Cleared(self)) self.active = "" - return AwaitComplete(self.query("#tabs-list > Tab").remove()()) + return AwaitComplete(self.query("#tabs-list > Tab").remove()) def remove_tab(self, tab_or_id: Tab | str | None) -> AwaitComplete: """Remove a tab. @@ -508,7 +510,7 @@ def remove_tab(self, tab_or_id: Tab | str | None) -> AwaitComplete: An optionally awaitable object that waits for the tab to be removed. """ if not tab_or_id: - return AwaitComplete(self.app._remove_nodes([], None)()) + return AwaitComplete(self.app._remove_nodes([], None)) if isinstance(tab_or_id, Tab): remove_tab = tab_or_id @@ -516,7 +518,7 @@ def remove_tab(self, tab_or_id: Tab | str | None) -> AwaitComplete: try: remove_tab = self.query_one(f"#tabs-list > #{tab_or_id}", Tab) except NoMatches: - return AwaitComplete(self.app._remove_nodes([], None)()) + return AwaitComplete(self.app._remove_nodes([], None)) removing_active_tab = remove_tab.has_class("-active") next_tab = self._next_active @@ -527,10 +529,10 @@ def remove_tab(self, tab_or_id: Tab | str | None) -> AwaitComplete: async def do_remove() -> None: """Perform the remove after refresh so the underline bar gets new positions.""" await remove_await - if next_tab is None: + if next_tab is None or (removing_active_tab and next_tab.id is None): self.active = "" elif removing_active_tab: - self.active = next_tab.id + self.active = next_tab.id or "" next_tab.add_class("-active") highlight_updated.set() @@ -575,14 +577,15 @@ def compose(self) -> ComposeResult: def watch_active(self, previously_active: str, active: str) -> None: """Handle a change to the active tab.""" + self.query("#tabs-list > Tab.-active").remove_class("-active") if active: try: active_tab = self.query_one(f"#tabs-list > #{active}", Tab) except NoMatches: return - 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) @@ -590,7 +593,10 @@ def watch_active(self, previously_active: str, active: str) -> None: underline.highlight_end = 0 self.post_message(self.Cleared(self)) - def _highlight_active(self, animate: bool = True) -> None: + def _highlight_active( + self, + animate: bool = True, + ) -> None: """Move the underline bar to under the active tab. Args: @@ -607,7 +613,8 @@ def _highlight_active(self, animate: bool = True) -> None: underline.show_highlight = True tab_region = active_tab.virtual_region.shrink(active_tab.styles.gutter) start, end = tab_region.column_span - if animate: + # This is a basic animation, so we only disable it if we want no animations. + if animate and self.app.animation_level != "none": def animate_underline() -> None: """Animate the underline.""" @@ -620,8 +627,18 @@ def animate_underline() -> None: active_tab.styles.gutter ) start, end = tab_region.column_span - underline.animate("highlight_start", start, duration=0.3) - underline.animate("highlight_end", end, duration=0.3) + underline.animate( + "highlight_start", + start, + duration=0.3, + level="basic", + ) + underline.animate( + "highlight_end", + end, + duration=0.3, + level="basic", + ) self.set_timer(0.02, lambda: self.call_after_refresh(animate_underline)) else: @@ -643,7 +660,6 @@ def _activate_tab(self, tab: Tab) -> None: self.query("#tabs-list Tab.-active").remove_class("-active") tab.add_class("-active") self.active = tab.id or "" - self.query_one("#tabs-scroll").scroll_to_center(tab, force=True) def _on_underline_clicked(self, event: Underline.Clicked) -> None: """The underline was clicked. @@ -685,21 +701,24 @@ def action_previous_tab(self) -> None: self._move_tab(-1) def _move_tab(self, direction: int) -> None: - """Activate the next tab. + """Activate the next enabled tab in the given direction. + + Tab selection wraps around. If no tab is currently active, the "next" + tab is set to be the first and the "previous" tab is the last one. Args: direction: +1 for the next tab, -1 for the previous. """ active_tab = self.active_tab - if active_tab is None: - return tabs = self._potentially_active_tabs if not tabs: return + if not active_tab: + self.active = tabs[0 if direction == 1 else -1].id or "" + return tab_count = len(tabs) new_tab_index = (tabs.index(active_tab) + direction) % tab_count self.active = tabs[new_tab_index].id or "" - self._scroll_active_tab() def _on_tab_disabled(self, event: Tab.Disabled) -> None: """Re-post the disabled message.""" @@ -784,7 +803,7 @@ def hide(self, tab_id: str) -> Tab: next_tab = self._next_active self.active = next_tab.id or "" if next_tab else "" tab_to_hide.add_class("-hidden") - self.post_message(self.TabHidden(self, tab_to_hide)) + self.post_message(self.TabHidden(self, tab_to_hide).set_sender(self)) self.call_after_refresh(self._highlight_active) return tab_to_hide @@ -807,7 +826,7 @@ def show(self, tab_id: str) -> Tab: raise self.TabError(f"There is no tab with ID {tab_id!r} to show.") tab_to_show.remove_class("-hidden") - self.post_message(self.TabShown(self, tab_to_show)) + self.post_message(self.TabShown(self, tab_to_show).set_sender(self)) if not self.active: self._activate_tab(tab_to_show) self.call_after_refresh(self._highlight_active) diff --git a/src/textual/widgets/_text_area.py b/src/textual/widgets/_text_area.py index f534883253..501eef8f40 100644 --- a/src/textual/widgets/_text_area.py +++ b/src/textual/widgets/_text_area.py @@ -1,15 +1,16 @@ from __future__ import annotations +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, 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 @@ -22,12 +23,16 @@ Selection, _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, SyntaxAwareDocumentError, ) -from textual.expand_tabs import expand_tabs_inline +from textual.document._wrapped_document import WrappedDocument +from textual.expand_tabs import expand_tabs_inline, expand_text_tabs_from_widths if TYPE_CHECKING: from tree_sitter import Language @@ -58,16 +63,12 @@ class ThemeDoesNotExist(Exception): This means a theme which is not builtin, or has not been registered. """ - pass - class LanguageDoesNotExist(Exception): """Raised when the user tries to use a language which does not exist. This means a language which is not builtin, or has not been registered. """ - pass - @dataclass class TextAreaLanguage: @@ -84,16 +85,86 @@ class TextAreaLanguage: highlight_query: str -class TextArea(ScrollView, can_focus=True): +class TextArea(ScrollView): DEFAULT_CSS = """\ TextArea { width: 1fr; height: 1fr; + 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; + } + + &: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; + } + } } """ + COMPONENT_CLASSES: ClassVar[set[str]] = { + "text-area--cursor", + "text-area--gutter", + "text-area--cursor-gutter", + "text-area--cursor-line", + "text-area--selection", + "text-area--matching-bracket", + } + """ + `TextArea` offers some component classes which can be used to style aspects of the widget. + + Note that any attributes provided in the chosen `TextAreaTheme` will take priority here. + + | Class | Description | + | :- | :- | + | `text-area--cursor` | Target the cursor. | + | `text-area--gutter` | Target the gutter (line number column). | + | `text-area--cursor-gutter` | Target the gutter area of the line the cursor is on. | + | `text-area--cursor-line` | Target the line the cursor is on. | + | `text-area--selection` | Target the current selection. | + | `text-area--matching-bracket` | Target matching brackets. | + """ + 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), @@ -148,12 +219,18 @@ class TextArea(ScrollView, can_focus=True): Binding( "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+k", + "delete_to_end_of_line_or_delete_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. | @@ -181,6 +258,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) @@ -194,7 +273,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. @@ -203,7 +282,7 @@ class TextArea(ScrollView, can_focus=True): """ selection: Reactive[Selection] = reactive( - Selection(), always_update=True, init=False + Selection(), init=False, always_update=True ) """The selection start and end locations (zero-based line_index, offset). @@ -216,25 +295,36 @@ class TextArea(ScrollView, can_focus=True): The text selected in the document is available via the `TextArea.selected_text` property. """ - show_line_numbers: Reactive[bool] = reactive(True) + show_line_numbers: Reactive[bool] = reactive(False, init=False) """True to show the line number column on the left edge, otherwise False. Changing this value will immediately re-render the `TextArea`.""" - indent_width: Reactive[int] = reactive(4) + indent_width: Reactive[int] = reactive(4, init=False) """The width of tabs or the multiple of spaces to align to on pressing the `tab` key. If the document currently open contains tabs that are currently visible on screen, altering this value will immediately change the display width of the visible tabs. """ - match_cursor_bracket: Reactive[bool] = reactive(True) + match_cursor_bracket: Reactive[bool] = reactive(True, init=False) """If the cursor is at a bracket, highlight the matching bracket (if found).""" - cursor_blink: Reactive[bool] = reactive(True) + cursor_blink: Reactive[bool] = reactive(True, init=False) """True if the cursor should blink.""" - _cursor_blink_visible: Reactive[bool] = reactive(True, repaint=False) + soft_wrap: Reactive[bool] = reactive(True, init=False) + """True if text should soft wrap.""" + + 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.""" @@ -274,7 +364,12 @@ def __init__( text: str = "", *, language: str | None = None, - theme: str | None = None, + theme: str = "css", + soft_wrap: bool = True, + 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, @@ -286,13 +381,17 @@ def __init__( 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_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.""" @@ -306,12 +405,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._last_intentional_cell_width: int = 0 - """Tracks the last column (measured in terms of cell length, since we care here about where the cursor - visually moves rather than logical characters) the user explicitly navigated to so that we can reset to it - whenever possible.""" - - 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 @@ -325,20 +423,89 @@ 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) """The document this widget is currently editing.""" - self._theme: TextAreaTheme | None = None + self.wrapped_document: WrappedDocument = WrappedDocument(self.document) + """The wrapped view of the document.""" + + self.navigator: DocumentNavigator = DocumentNavigator(self.wrapped_document) + """Queried to determine where the cursor should move given a navigation + action, accounting for wrapping etc.""" + + self._cursor_offset = (0, 0) + """The virtual offset of the cursor (not screen-space offset).""" + + self._set_document(text, language) + + self.language = language + self.theme = theme + + 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.language = language + 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.theme = theme + 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) + + @classmethod + def code_editor( + cls, + text: str = "", + *, + language: str | None = None, + theme: str = "monokai", + soft_wrap: bool = False, + tab_behavior: Literal["focus", "indent"] = "indent", + read_only: bool = False, + show_line_numbers: bool = True, + max_checkpoints: int = 50, + name: str | None = None, + id: str | None = None, + classes: str | None = None, + disabled: bool = False, + ) -> TextArea: + """Construct a new `TextArea` with sensible defaults for editing code. + + This instantiates a `TextArea` with line numbers enabled, soft wrapping + 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_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 cls( + text, + language=language, + theme=theme, + soft_wrap=soft_wrap, + tab_behavior=tab_behavior, + read_only=read_only, + show_line_numbers=show_line_numbers, + max_checkpoints=max_checkpoints, + name=name, + id=id, + classes=classes, + disabled=disabled, + ) @staticmethod def _get_builtin_highlight_query(language_name: str) -> str: @@ -390,10 +557,24 @@ 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_selection(self, selection: Selection) -> None: + 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 + ) -> None: """When the cursor moves, scroll it into view.""" - self.scroll_cursor_visible() + # Find the visual offset of the cursor in the document cursor_location = selection.end + + self.scroll_cursor_visible() + cursor_row, cursor_column = cursor_location try: @@ -405,12 +586,30 @@ def _watch_selection(self, selection: Selection) -> None: match_location = self.find_matching_bracket(character, cursor_location) self._matching_bracket_location = match_location if match_location is not None: - match_row, match_column = match_location - if match_row in range(*self._visible_line_indices): - self.refresh_lines(match_row) + _, offset_y = self._cursor_offset + self.refresh_lines(offset_y) self.app.cursor_position = self.cursor_screen_offset - self.post_message(self.SelectionChanged(selection, self)) + 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( + self.cursor_location + ) def find_matching_bracket( self, bracket: str, search_from: Location @@ -426,7 +625,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 @@ -476,42 +675,46 @@ 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.""" - self._refresh_size() + self._rewrap_and_refresh_virtual_size() + self.scroll_cursor_visible() def _watch_indent_width(self) -> None: - """Changing width of tabs will change document display width.""" - self._refresh_size() + """Changing width of tabs will change the document display width.""" + self._rewrap_and_refresh_virtual_size() + self.scroll_cursor_visible() - def _watch_theme(self, theme: str | None) -> None: + def _watch_show_vertical_scrollbar(self) -> None: + self._rewrap_and_refresh_virtual_size() + self.scroll_cursor_visible() + + 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) - 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) + def _app_dark_toggled(self) -> None: + self._set_theme(self._theme.name) + def _set_theme(self, theme: str) -> None: + theme_object: TextAreaTheme | None + + # 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 = theme_object + self._theme = dataclasses.replace(theme_object) if theme_object: base_style = theme_object.base_style if base_style: @@ -568,7 +771,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. @@ -625,7 +828,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 @@ -653,7 +856,11 @@ def _set_document(self, text: str, language: str | None) -> None: document = Document(text) self.document = document + self.wrapped_document = WrappedDocument(document, tab_width=self.indent_width) + self.navigator = DocumentNavigator(self.wrapped_document) self._build_highlight_map() + self.move_cursor((0, 0)) + self._rewrap_and_refresh_virtual_size() @property def _visible_line_indices(self) -> tuple[int, int]: @@ -674,23 +881,36 @@ 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) - self.move_cursor((0, 0)) - self._refresh_size() + self.post_message(self.Changed(self).set_sender(self)) - def load_document(self, document: DocumentBase) -> None: - """Load a document into the TextArea. + def _on_resize(self) -> None: + self._rewrap_and_refresh_virtual_size() - Args: - document: The document to load into the TextArea. + def _watch_soft_wrap(self) -> None: + self._rewrap_and_refresh_virtual_size() + self.call_after_refresh(self.scroll_cursor_visible, center=True) + + @property + def wrap_width(self) -> int: + """The width which gets used when the document wraps. + + Accounts for gutter, scrollbars, etc. """ - self.document = document - self.move_cursor((0, 0)) + width, _ = self.scrollable_content_region.size + cursor_width = 1 + if self.soft_wrap: + return width - self.gutter_width - cursor_width + return 0 + + def _rewrap_and_refresh_virtual_size(self) -> None: + self.wrapped_document.wrap(self.wrap_width, tab_width=self.indent_width) self._refresh_size() @property @@ -741,64 +961,66 @@ def _yield_character_locations_reverse( def _refresh_size(self) -> None: """Update the virtual size of the TextArea.""" - width, height = self.document.get_size(self.indent_width) - # +1 width to make space for the cursor resting at the end of the line - self.virtual_size = Size(width + self.gutter_width + 1, height) + if self.soft_wrap: + self.virtual_size = Size(0, self.wrapped_document.height) + else: + # +1 width to make space for the cursor resting at the end of the line + 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. """ + theme = self._theme + if theme: + theme.apply_css(self) + document = self.document + wrapped_document = self.wrapped_document scroll_x, scroll_y = self.scroll_offset # Account for how much the TextArea is scrolled. - line_index = 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 - # Render the lines beyond the valid line numbers - out_of_bounds = line_index >= document.line_count if out_of_bounds: return Strip.blank(self.size.width) - theme = self._theme + # Get the line corresponding to this offset + try: + line_info = wrapped_document._offset_to_line_info[y_offset] + except IndexError: + line_info = None + + if line_info is None: + return Strip.blank(self.size.width) + + line_index, section_offset = line_info # 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 - virtual_width, virtual_height = self.virtual_size - expanded_length = max(virtual_width, self.size.width) - line.set_length(expanded_length) + line.set_length(line_character_count + 1) # space at end for cursor + virtual_width, _virtual_height = self.virtual_size selection = self.selection start, end = selection + cursor_row, cursor_column = end + selection_top, selection_bottom = sorted(selection) 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_row, cursor_column = end 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) @@ -812,7 +1034,6 @@ def render_line(self, widget_y: int) -> Strip: if line_character_count == 0 and line_index != cursor_row: # A simple highlight to show empty lines are included in the selection line = Text("▌", end="", style=Style(color=selection_style.bgcolor)) - line.set_length(self.virtual_size.width) else: if line_index == selection_top_row == selection_bottom_row: # Selection within a single line @@ -834,6 +1055,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 @@ -842,8 +1078,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_blink_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 @@ -877,44 +1115,76 @@ def render_line(self, widget_y: int) -> Strip: gutter_width = self.gutter_width if self.show_line_numbers: if cursor_row == line_index: - gutter_style = theme.cursor_line_gutter_style if theme else None + gutter_style = theme.cursor_line_gutter_style else: - gutter_style = theme.gutter_style if theme else None + gutter_style = theme.gutter_style gutter_width_no_margin = gutter_width - 2 + gutter_content = str(line_index + 1) if section_offset == 0 else "" gutter = Text( - f"{line_index + 1:>{gutter_width_no_margin}} ", + f"{gutter_content:>{gutter_width_no_margin}} ", style=gutter_style or "", end="", ) else: gutter = Text("", end="") - # Render the gutter and the text of this line + # TODO: Lets not apply the division each time through render_line. + # We should cache sections with the edit counts. + wrap_offsets = wrapped_document.get_offsets(line_index) + if wrap_offsets: + sections = line.divide(wrap_offsets) # TODO cache result with edit count + line = sections[section_offset] + line_tab_widths = wrapped_document.get_tab_widths(line_index) + line.end = "" + + # Get the widths of the tabs corresponding only to the section of the + # line that is currently being rendered. We don't care about tabs in + # other sections of the same line. + + # Count the tabs before this section. + tabs_before = 0 + for section_index in range(section_offset): + tabs_before += sections[section_index].plain.count("\t") + + # Count the tabs in this section. + tabs_within = line.plain.count("\t") + section_tab_widths = line_tab_widths[ + tabs_before : tabs_before + tabs_within + ] + line = expand_text_tabs_from_widths(line, section_tab_widths) + else: + line.expand_tabs(self.indent_width) + + base_width = ( + self.scrollable_content_region.size.width + if self.soft_wrap + else max(virtual_width, self.region.size.width) + ) + target_width = base_width - self.gutter_width console = self.app.console gutter_segments = console.render(gutter) - text_segments = console.render( - line, - console.options.update_width(expanded_length), + + text_segments = list( + console.render(line, console.options.update_width(target_width)) ) - # Crop the line to show only the visible part (some may be scrolled out of view) gutter_strip = Strip(gutter_segments, cell_length=gutter_width) - text_strip = Strip(text_segments).crop( - scroll_x, scroll_x + virtual_width - gutter_width - ) + text_strip = Strip(text_segments) + + # Crop the line to show only the visible part (some may be scrolled out of view) + if not self.soft_wrap: + text_strip = text_strip.crop( + scroll_x, scroll_x + virtual_width - gutter_width + ) # Stylize the line the cursor is currently on. if cursor_row == line_index: - text_strip = text_strip.extend_cell_length( - expanded_length, cursor_line_style - ) + line_style = cursor_line_style else: - text_strip = text_strip.extend_cell_length( - expanded_length, theme.base_style if theme else None - ) + line_style = theme.base_style if theme else None - # Join and return the gutter and the visible portion of this line + text_strip = text_strip.extend_cell_length(target_width, line_style) strip = Strip.join([gutter_strip, text_strip]).simplify() return strip.apply_style( @@ -932,6 +1202,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. """ @@ -956,7 +1228,7 @@ def get_text_range(self, start: Location, end: Location) -> str: start, end = sorted((start, end)) return self.document.get_text_range(start, end) - def edit(self, edit: Edit) -> Any: + def edit(self, edit: Edit) -> EditResult: """Perform an Edit. Args: @@ -966,21 +1238,157 @@ def edit(self, edit: Edit) -> Any: Data relating to the edit that may be useful. The data returned may be different depending on the edit performed. """ + 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.top, + edit.bottom, + result.end_location, + ) + self._refresh_size() edit.after(self) self._build_highlight_map() 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].top + 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.bottom + + 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 = { - "tab": " " * self._find_columns_to_next_tab_stop(), "enter": "\n", } - self._restart_blink() + 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() + if event.is_printable or key in insert_values: event.stop() event.prevent_default() @@ -989,7 +1397,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. @@ -1023,16 +1431,10 @@ def get_target_document_location(self, event: MouseEvent) -> Location: """ scroll_x, scroll_y = self.scroll_offset target_x = event.x - self.gutter_width + scroll_x - self.gutter.left - target_x = max(target_x, 0) - target_row = clamp( - event.y + scroll_y - self.gutter.top, - 0, - self.document.line_count - 1, - ) - target_column = self.cell_width_to_column_index(target_x, target_row) - return target_row, target_column + target_y = event.y + scroll_y - self.gutter.top + location = self.wrapped_document.offset_to_location(Offset(target_x, target_y)) + return location - # --- Lower level event/key handling @property def gutter_width(self) -> int: """The width of the gutter (the left column containing line numbers). @@ -1043,41 +1445,39 @@ def gutter_width(self) -> int: # The longest number in the gutter plus two extra characters: `│ `. gutter_margin = 2 gutter_width = ( - len(str(self.document.line_count + 1)) + gutter_margin + len(str(self.document.line_count)) + gutter_margin if self.show_line_numbers else 0 ) 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_blink_visible = not self._cursor_blink_visible - cursor_row, _ = self.cursor_location - self.refresh_lines(cursor_row) + 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: - self._cursor_blink_visible = True + self._cursor_visible = True self.blink_timer.reset() def _pause_blink(self, visible: bool = True) -> None: """Pause the cursor blinking but ensure it stays visible.""" - self._cursor_blink_visible = visible + self._cursor_visible = visible self.blink_timer.pause() async def _on_mouse_down(self, event: events.MouseDown) -> None: @@ -1089,6 +1489,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.""" @@ -1097,16 +1498,27 @@ async def _on_mouse_move(self, event: events.MouseMove) -> None: selection_start, _ = self.selection 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.""" + def _end_mouse_selection(self) -> None: + """Finalize the selection that has been made using the mouse.""" self._selecting = False self.release_mouse() self.record_cursor_width() self._restart_blink() + async def _on_mouse_up(self, event: events.MouseUp) -> None: + """Finalize the selection that has been made using the mouse.""" + self._end_mouse_selection() + + async def _on_hide(self, event: events.Hide) -> None: + """Finalize the selection that has been made using the mouse when thew widget is hidden.""" + self._end_mouse_selection() + 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. @@ -1156,11 +1568,11 @@ def scroll_cursor_visible( Returns: The offset that was scrolled to bring the cursor into view. """ - row, column = self.selection.end - text = self.document[row][:column] - column_offset = cell_len(expand_tabs_inline(text, self.indent_width)) + self._recompute_cursor_offset() + + x, y = self._cursor_offset scroll_offset = self.scroll_to_region( - Region(x=column_offset, y=row, width=3, height=1), + Region(x, y, width=3, height=1), spacing=Spacing(right=self.gutter_width), animate=animate, force=True, @@ -1186,7 +1598,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) @@ -1197,6 +1609,8 @@ def move_cursor( if center: self.scroll_cursor_visible(center) + self.history.checkpoint() + def move_cursor_relative( self, rows: int = 0, @@ -1205,7 +1619,7 @@ def move_cursor_relative( center: bool = False, record_width: bool = True, ) -> None: - """Move the cursor relative to its current location. + """Move the cursor relative to its current location in document-space. Args: rows: The number of rows to move down by (negative to move up) @@ -1217,7 +1631,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) @@ -1273,17 +1687,12 @@ def cursor_location(self, location: Location) -> None: @property def cursor_screen_offset(self) -> Offset: """The offset of the cursor relative to the screen.""" - cursor_row, cursor_column = self.cursor_location + cursor_x, cursor_y = self._cursor_offset scroll_x, scroll_y = self.scroll_offset region_x, region_y, _width, _height = self.content_region - offset_x = ( - region_x - + self.get_column_width(cursor_row, cursor_column) - - scroll_x - + self.gutter_width - ) - offset_y = region_y + cursor_row - scroll_y + offset_x = region_x + cursor_x - scroll_x + self.gutter_width + offset_y = region_y + cursor_y - scroll_y return Offset(offset_x, offset_y) @@ -1330,8 +1739,8 @@ def action_cursor_left(self, select: bool = False) -> None: Args: select: If True, select the text while moving. """ - new_cursor_location = self.get_cursor_left_location() - self.move_cursor(new_cursor_location, select=select) + target = self.get_cursor_left_location() + self.move_cursor(target, select=select) def get_cursor_left_location(self) -> Location: """Get the location the cursor will move to if it moves left. @@ -1339,13 +1748,7 @@ def get_cursor_left_location(self) -> Location: Returns: The location of the cursor if it moves left. """ - if self.cursor_at_start_of_text: - return 0, 0 - cursor_row, cursor_column = self.selection.end - length_of_row_above = len(self.document[cursor_row - 1]) - target_row = cursor_row if cursor_column != 0 else cursor_row - 1 - target_column = cursor_column - 1 if cursor_column != 0 else length_of_row_above - return target_row, target_column + return self.navigator.get_location_left(self.cursor_location) def action_cursor_right(self, select: bool = False) -> None: """Move the cursor one location to the right. @@ -1364,12 +1767,7 @@ def get_cursor_right_location(self) -> Location: Returns: the location the cursor will move to if it moves right. """ - if self.cursor_at_end_of_text: - return self.selection.end - cursor_row, cursor_column = self.selection.end - target_row = cursor_row + 1 if self.cursor_at_end_of_line else cursor_row - target_column = 0 if self.cursor_at_end_of_line else cursor_column + 1 - return target_row, target_column + return self.navigator.get_location_right(self.cursor_location) def action_cursor_down(self, select: bool = False) -> None: """Move the cursor down one cell. @@ -1386,17 +1784,7 @@ def get_cursor_down_location(self) -> Location: Returns: The location the cursor will move to if it moves down. """ - cursor_row, cursor_column = self.selection.end - if self.cursor_at_last_line: - return cursor_row, len(self.document[cursor_row]) - - target_row = min(self.document.line_count - 1, cursor_row + 1) - # Attempt to snap last intentional cell length - target_column = self.cell_width_to_column_index( - self._last_intentional_cell_width, target_row - ) - target_column = clamp(target_column, 0, len(self.document[target_row])) - return target_row, target_column + return self.navigator.get_location_below(self.cursor_location) def action_cursor_up(self, select: bool = False) -> None: """Move the cursor up one cell. @@ -1413,16 +1801,7 @@ def get_cursor_up_location(self) -> Location: Returns: The location the cursor will move to if it moves up. """ - if self.cursor_at_first_line: - return 0, 0 - cursor_row, cursor_column = self.selection.end - target_row = max(0, cursor_row - 1) - # Attempt to snap last intentional cell length - target_column = self.cell_width_to_column_index( - self._last_intentional_cell_width, target_row - ) - target_column = clamp(target_column, 0, len(self.document[target_row])) - return target_row, target_column + return self.navigator.get_location_above(self.cursor_location) def action_cursor_line_end(self, select: bool = False) -> None: """Move the cursor to the end of the line.""" @@ -1435,39 +1814,27 @@ def get_cursor_line_end_location(self) -> Location: Returns: The (row, column) location of the end of the cursors current line. """ - start, end = self.selection - cursor_row, cursor_column = end - target_column = len(self.document[cursor_row]) - return cursor_row, target_column + return self.navigator.get_location_end(self.cursor_location) def action_cursor_line_start(self, select: bool = False) -> None: """Move the cursor to the start of the line.""" + target = self.get_cursor_line_start_location(smart_home=True) + self.move_cursor(target, select=select) - cursor_row, cursor_column = self.cursor_location - line = self.document[cursor_row] - - first_non_whitespace = 0 - for index, code_point in enumerate(line): - if not code_point.isspace(): - first_non_whitespace = index - break - - if cursor_column <= first_non_whitespace and cursor_column != 0: - target = self.get_cursor_line_start_location() - self.move_cursor(target, select=select) - else: - target = cursor_row, first_non_whitespace - self.move_cursor(target, select=select) - - def get_cursor_line_start_location(self) -> Location: + def get_cursor_line_start_location(self, smart_home: bool = False) -> Location: """Get the location of the start of the current line. + Args: + smart_home: If True, use "smart home key" behavior - go to the first + non-whitespace character on the line, and if already there, go to + offset 0. Smart home only works when wrapping is disabled. + Returns: The (row, column) location of the start of the cursors current line. """ - _start, end = self.selection - cursor_row, _cursor_column = end - return cursor_row, 0 + return self.navigator.get_location_home( + self.cursor_location, smart_home=smart_home + ) def action_cursor_word_left(self, select: bool = False) -> None: """Move the cursor left by a single word, skipping trailing whitespace. @@ -1537,8 +1904,10 @@ def action_cursor_page_up(self) -> None: """Move the cursor and scroll up one page.""" height = self.content_size.height _, cursor_location = self.selection - row, column = cursor_location - target = (row - height, column) + target = self.navigator.get_location_at_y_offset( + cursor_location, + -height, + ) self.scroll_relative(y=-height, animate=False) self.move_cursor(target) @@ -1546,8 +1915,10 @@ def action_cursor_page_down(self) -> None: """Move the cursor and scroll down one page.""" height = self.content_size.height _, cursor_location = self.selection - row, column = cursor_location - target = (row + height, column) + target = self.navigator.get_location_at_y_offset( + cursor_location, + height, + ) self.scroll_relative(y=height, animate=False) self.move_cursor(target) @@ -1572,9 +1943,10 @@ def record_cursor_width(self) -> None: content, then we go down to another row, we want our cursor to jump back to the same offset that we were originally at. """ - row, column = self.selection.end - column_cell_length = self.get_column_width(row, column) - self._last_intentional_cell_width = column_cell_length + cursor_x_offset, _ = self.wrapped_document.location_to_offset( + self.cursor_location + ) + self.navigator.last_x_offset = cursor_x_offset # --- Editor operations def insert( @@ -1621,8 +1993,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, @@ -1648,12 +2019,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. @@ -1666,7 +2079,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. @@ -1679,13 +2092,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 @@ -1696,21 +2109,41 @@ 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) + 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 - cursor_row, cursor_column = from_location - to_location = (cursor_row, 0) - self.delete(from_location, to_location, maintain_selection_offset=False) + to_location = self.get_cursor_line_start_location() + 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 - cursor_row, cursor_column = from_location - to_location = (cursor_row, len(self.document[cursor_row])) - self.delete(from_location, to_location, maintain_selection_offset=False) + to_location = self.get_cursor_line_end_location() + self._delete_via_keyboard(from_location, to_location) + + async def action_delete_to_end_of_line_or_delete_line(self) -> None: + """Deletes from the cursor location to the end of the line, or deletes the line. + + The line will be deleted if the line is empty. + """ + # Assume we're just going to delete to the end of the line. + action = "delete_to_end_of_line" + if self.get_cursor_line_start_location() == self.get_cursor_line_end_location(): + # The line is empty, so we'll simply remove the line itself. + action = "delete_line" + elif ( + self.selection.start + == self.selection.end + == self.get_cursor_line_end_location() + ): + # We're at the end of the line, so the kill delete operation + # should join the next line to this. + action = "delete_right" + await self.run_action(action) def action_delete_word_left(self) -> None: """Deletes the word to the left of the cursor and updates the cursor location.""" @@ -1721,11 +2154,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. @@ -1739,7 +2172,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 @@ -1759,135 +2192,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) @@ -1900,7 +2205,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/_toast.py b/src/textual/widgets/_toast.py index ab920ebe63..a8198f4b53 100644 --- a/src/textual/widgets/_toast.py +++ b/src/textual/widgets/_toast.py @@ -2,12 +2,15 @@ from __future__ import annotations -from typing import ClassVar +from typing import TYPE_CHECKING, ClassVar -from rich.console import RenderableType from rich.text import Text from .. import on + +if TYPE_CHECKING: + from ..app import RenderResult + from ..containers import Container from ..css.query import NoMatches from ..events import Click, Mount @@ -93,6 +96,8 @@ class Toast(Static, inherit_css=False): | `toast--title` | Targets the title of the toast. | """ + DEFAULT_CLASSES = "-textual-system" + def __init__(self, notification: Notification) -> None: """Initialise the toast. @@ -103,7 +108,7 @@ def __init__(self, notification: Notification) -> None: self._notification = notification self._timeout = notification.time_left - def render(self) -> RenderableType: + def render(self) -> RenderResult: """Render the toast's content. Returns: @@ -158,6 +163,7 @@ class ToastRack(Container, inherit_css=False): margin-right: 1; } """ + DEFAULT_CLASSES = "-textual-system" @staticmethod def _toast_id(notification: Notification) -> str: diff --git a/src/textual/widgets/_toggle_button.py b/src/textual/widgets/_toggle_button.py index 93059f610b..6af1ea6a82 100644 --- a/src/textual/widgets/_toggle_button.py +++ b/src/textual/widgets/_toggle_button.py @@ -32,7 +32,7 @@ class ToggleButton(Static, can_focus=True): """ BINDINGS: ClassVar[list[BindingType]] = [ - Binding("enter,space", "toggle", "Toggle", show=False), + Binding("enter,space", "toggle_button", "Toggle", show=False), ] """ | Key(s) | Description | @@ -238,7 +238,7 @@ def toggle(self) -> Self: self.value = not self.value return self - def action_toggle(self) -> None: + def action_toggle_button(self) -> None: """Toggle the value of the widget when called as an action. This would normally be used for a keyboard binding. diff --git a/src/textual/widgets/_tooltip.py b/src/textual/widgets/_tooltip.py index 94664edb47..afe64d11d4 100644 --- a/src/textual/widgets/_tooltip.py +++ b/src/textual/widgets/_tooltip.py @@ -17,3 +17,4 @@ class Tooltip(Static, inherit_css=False): display: none; } """ + DEFAULT_CLASSES = "-textual-system" diff --git a/src/textual/widgets/_tree.py b/src/textual/widgets/_tree.py index 43a079a5eb..686382433d 100644 --- a/src/textual/widgets/_tree.py +++ b/src/textual/widgets/_tree.py @@ -215,7 +215,7 @@ def _expand(self, expand_all: bool) -> None: """ self._expanded = True self._updates += 1 - self._tree.post_message(Tree.NodeExpanded(self)) + self._tree.post_message(Tree.NodeExpanded(self).set_sender(self._tree)) if expand_all: for child in self.children: child._expand(expand_all) @@ -248,7 +248,7 @@ def _collapse(self, collapse_all: bool) -> None: """ self._expanded = False self._updates += 1 - self._tree.post_message(Tree.NodeCollapsed(self)) + self._tree.post_message(Tree.NodeCollapsed(self).set_sender(self._tree)) if collapse_all: for child in self.children: child._collapse(collapse_all) @@ -615,7 +615,7 @@ 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 super().__init__(name=name, id=id, classes=classes, disabled=disabled) @@ -815,7 +815,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 +948,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 +957,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 +990,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 +1148,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 82a69e38b3..058f823542 100644 --- a/src/textual/widgets/text_area.py +++ b/src/textual/widgets/text_area.py @@ -6,10 +6,13 @@ Location, 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, @@ -22,8 +25,10 @@ "BUILTIN_LANGUAGES", "Document", "DocumentBase", + "DocumentNavigator", "Edit", "EditResult", + "EditHistory", "EndColumn", "Highlight", "HighlightName", @@ -34,4 +39,5 @@ "SyntaxAwareDocument", "TextAreaTheme", "ThemeDoesNotExist", + "WrappedDocument", ] diff --git a/src/textual/worker.py b/src/textual/worker.py index d858fbe8c5..7719cd4dca 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, @@ -137,7 +138,7 @@ def __rich_repr__(self) -> rich.repr.Result: def __init__( self, node: DOMNode, - work: WorkType | None = None, + work: WorkType, *, name: str = "", group: str = "default", @@ -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 @@ -313,9 +316,9 @@ def run_callable(work: Callable[[], ResultType]) -> ResultType: else: raise WorkerError("Unsupported attempt to run a thread worker") - return await asyncio.get_running_loop().run_in_executor( - None, runner, self._work - ) + loop = asyncio.get_running_loop() + assert loop is not None + return await loop.run_in_executor(None, runner, self._work) async def _run_async(self) -> ResultType: """Run an async worker. @@ -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/animations/test_disabling_animations.py b/tests/animations/test_disabling_animations.py new file mode 100644 index 0000000000..2880262025 --- /dev/null +++ b/tests/animations/test_disabling_animations.py @@ -0,0 +1,163 @@ +""" +Test that generic animations can be disabled. +""" + +from textual.app import App, ComposeResult +from textual.color import Color +from textual.widgets import Label + + +class SingleLabelApp(App[None]): + """Single label whose background colour we'll animate.""" + + CSS = """ + Label { + background: red; + } + """ + + def compose(self) -> ComposeResult: + yield Label() + + +async def test_style_animations_via_animate_work_on_full() -> None: + app = SingleLabelApp() + app.animation_level = "full" + + async with app.run_test(): + label = app.query_one(Label) + # Sanity check. + assert label.styles.background == Color.parse("red") + animator = app.animator + # Freeze time at 0 before triggering the animation. + animator._get_time = lambda *_: 0 + label.styles.animate("background", "blue", duration=1) + assert len(animator._animations) > 0 # Sanity check. + # Freeze time around the animation midpoint. + animator._get_time = lambda *_: 0.5 + # Move to the next frame. + animator() + # The animation shouldn't have completed. + assert label.styles.background != Color.parse("red") + assert label.styles.background != Color.parse("blue") + + +async def test_style_animations_via_animate_are_disabled_on_basic() -> None: + app = SingleLabelApp() + app.animation_level = "basic" + + async with app.run_test(): + label = app.query_one(Label) + # Sanity check. + assert label.styles.background == Color.parse("red") + animator = app.animator + # Freeze time at 0 before triggering the animation. + animator._get_time = lambda *_: 0 + label.styles.animate("background", "blue", duration=1) + assert len(animator._animations) > 0 # Sanity check. + # Freeze time after the animation start and before animation end. + animator._get_time = lambda *_: 0.01 + # Move to the next frame. + animator() + # The animation should have completed. + assert label.styles.background == Color.parse("blue") + + +async def test_style_animations_via_animate_are_disabled_on_none() -> None: + app = SingleLabelApp() + app.animation_level = "none" + + async with app.run_test(): + label = app.query_one(Label) + # Sanity check. + assert label.styles.background == Color.parse("red") + animator = app.animator + # Freeze time at 0 before triggering the animation. + animator._get_time = lambda *_: 0 + label.styles.animate("background", "blue", duration=1) + assert len(animator._animations) > 0 # Sanity check. + # Freeze time after the animation start and before animation end. + animator._get_time = lambda *_: 0.01 + # Move to the next frame. + animator() + # The animation should have completed. + assert label.styles.background == Color.parse("blue") + + +class LabelWithTransitionsApp(App[None]): + """Single label whose background is set to animate with TCSS.""" + + CSS = """ + Label { + background: red; + transition: background 1s; + } + + Label.blue-bg { + background: blue; + } + """ + + def compose(self) -> ComposeResult: + yield Label() + + +async def test_style_animations_via_transition_work_on_full() -> None: + app = LabelWithTransitionsApp() + app.animation_level = "full" + + async with app.run_test(): + label = app.query_one(Label) + # Sanity check. + assert label.styles.background == Color.parse("red") + animator = app.animator + # Free time at 0 before triggering the animation. + animator._get_time = lambda *_: 0 + label.add_class("blue-bg") + assert len(animator._animations) > 0 # Sanity check. + # Freeze time in the middle of the animation. + animator._get_time = lambda *_: 0.5 + animator() + # The animation should be undergoing. + assert label.styles.background != Color.parse("red") + assert label.styles.background != Color.parse("blue") + + +async def test_style_animations_via_transition_are_disabled_on_basic() -> None: + app = LabelWithTransitionsApp() + app.animation_level = "basic" + + async with app.run_test(): + label = app.query_one(Label) + # Sanity check. + assert label.styles.background == Color.parse("red") + animator = app.animator + # Free time at 0 before triggering the animation. + animator._get_time = lambda *_: 0 + label.add_class("blue-bg") + assert len(animator._animations) > 0 # Sanity check. + # Freeze time after the animation start and before animation end. + animator._get_time = lambda *_: 0.01 + animator() + # The animation should have completed. + assert label.styles.background == Color.parse("blue") + + +async def test_style_animations_via_transition_are_disabled_on_none() -> None: + app = LabelWithTransitionsApp() + app.animation_level = "none" + + async with app.run_test(): + label = app.query_one(Label) + # Sanity check. + assert label.styles.background == Color.parse("red") + animator = app.animator + # Free time at 0 before triggering the animation. + animator._get_time = lambda *_: 0 + label.add_class("blue-bg") + assert len(animator._animations) > 0 # Sanity check. + # Freeze time after the animation start and before animation end. + animator._get_time = lambda *_: 0.01 + animator() + # The animation should have completed. + assert label.styles.background == Color.parse("blue") diff --git a/tests/animations/test_environment_variable.py b/tests/animations/test_environment_variable.py new file mode 100644 index 0000000000..49359d6a75 --- /dev/null +++ b/tests/animations/test_environment_variable.py @@ -0,0 +1,32 @@ +import pytest + +from textual import constants +from textual.app import App +from textual.constants import _get_textual_animations + + +@pytest.mark.parametrize( + ["env_variable", "value"], + [ + ("", "full"), # default + ("FULL", "full"), + ("BASIC", "basic"), + ("NONE", "none"), + ("garbanzo beans", "full"), # fallback + ], +) +def test__get_textual_animations(monkeypatch, env_variable, value): # type: ignore + """Test that we parse the correct values from the env variable.""" + monkeypatch.setenv("TEXTUAL_ANIMATIONS", env_variable) + assert _get_textual_animations() == value + + +@pytest.mark.parametrize( + ["value"], + [("full",), ("basic",), ("none",)], +) +def test_app_show_animations(monkeypatch, value): # type: ignore + """Test that the app gets the value of `show_animations` correctly.""" + monkeypatch.setattr(constants, "TEXTUAL_ANIMATIONS", value) + app = App() + assert app.animation_level == value diff --git a/tests/animations/test_loading_indicator_animation.py b/tests/animations/test_loading_indicator_animation.py new file mode 100644 index 0000000000..3f1df80a55 --- /dev/null +++ b/tests/animations/test_loading_indicator_animation.py @@ -0,0 +1,43 @@ +""" +Tests for the loading indicator animation, which is considered a basic animation. +(An animation that also plays on the level BASIC.) +""" + +from textual.app import App +from textual.widgets import LoadingIndicator + + +async def test_loading_indicator_is_not_static_on_full() -> None: + """The loading indicator doesn't fall back to the static render on FULL.""" + app = App() + app.animation_level = "full" + + async with app.run_test() as pilot: + app.screen.loading = True + await pilot.pause() + indicator = app.query_one(LoadingIndicator) + assert str(indicator.render()) != "Loading..." + + +async def test_loading_indicator_is_not_static_on_basic() -> None: + """The loading indicator doesn't fall back to the static render on BASIC.""" + app = App() + app.animation_level = "basic" + + async with app.run_test() as pilot: + app.screen.loading = True + await pilot.pause() + indicator = app.query_one(LoadingIndicator) + assert str(indicator.render()) != "Loading..." + + +async def test_loading_indicator_is_static_on_none() -> None: + """The loading indicator falls back to the static render on NONE.""" + app = App() + app.animation_level = "none" + + async with app.run_test() as pilot: + app.screen.loading = True + await pilot.pause() + indicator = app.query_one(LoadingIndicator) + assert str(indicator.render()) == "Loading..." diff --git a/tests/animations/test_progress_bar_animation.py b/tests/animations/test_progress_bar_animation.py new file mode 100644 index 0000000000..82e2add599 --- /dev/null +++ b/tests/animations/test_progress_bar_animation.py @@ -0,0 +1,47 @@ +""" +Tests for the indeterminate progress bar animation, which is considered a basic +animation. (An animation that also plays on the level BASIC.) +""" + +from textual.app import App, ComposeResult +from textual.widgets import ProgressBar +from textual.widgets._progress_bar import Bar + + +class ProgressBarApp(App[None]): + def compose(self) -> ComposeResult: + yield ProgressBar() + + +async def test_progress_bar_animates_on_full() -> None: + """An indeterminate progress bar is not fully highlighted when animating.""" + app = ProgressBarApp() + app.animation_level = "full" + + async with app.run_test(): + bar_renderable = app.query_one(Bar).render() + start, end = bar_renderable.highlight_range + assert start != 0 or end != app.query_one(Bar).size.width + + +async def test_progress_bar_animates_on_basic() -> None: + """An indeterminate progress bar is not fully highlighted when animating.""" + app = ProgressBarApp() + app.animation_level = "basic" + + async with app.run_test(): + bar_renderable = app.query_one(Bar).render() + start, end = bar_renderable.highlight_range + assert start != 0 or end != app.query_one(Bar).size.width + + +async def test_progress_bar_does_not_animate_on_none() -> None: + """An indeterminate progress bar is fully highlighted when not animating.""" + app = ProgressBarApp() + app.animation_level = "none" + + async with app.run_test(): + bar_renderable = app.query_one(Bar).render() + start, end = bar_renderable.highlight_range + assert start == 0 + assert end == app.query_one(Bar).size.width diff --git a/tests/animations/test_scrolling_animation.py b/tests/animations/test_scrolling_animation.py new file mode 100644 index 0000000000..172dc09c61 --- /dev/null +++ b/tests/animations/test_scrolling_animation.py @@ -0,0 +1,69 @@ +""" +Tests for scrolling animations, which are considered a basic animation. +(An animation that also plays on the level BASIC.) +""" + +from textual.app import App, ComposeResult +from textual.containers import VerticalScroll +from textual.widgets import Label + + +class TallApp(App[None]): + def compose(self) -> ComposeResult: + with VerticalScroll(): + for _ in range(100): + yield Label() + + +async def test_scrolling_animates_on_full() -> None: + app = TallApp() + app.animation_level = "full" + + async with app.run_test() as pilot: + vertical_scroll = app.query_one(VerticalScroll) + animator = app.animator + # Freeze time at 0 before triggering the animation. + animator._get_time = lambda *_: 0 + vertical_scroll.scroll_end(duration=10000) + await pilot.pause() + # Freeze time after the animation start and before animation end. + animator._get_time = lambda *_: 0.01 + # Move to the next frame. + animator() + assert animator.is_being_animated(vertical_scroll, "scroll_y") + + +async def test_scrolling_animates_on_basic() -> None: + app = TallApp() + app.animation_level = "basic" + + async with app.run_test() as pilot: + vertical_scroll = app.query_one(VerticalScroll) + animator = app.animator + # Freeze time at 0 before triggering the animation. + animator._get_time = lambda *_: 0 + vertical_scroll.scroll_end(duration=10000) + await pilot.pause() + # Freeze time after the animation start and before animation end. + animator._get_time = lambda *_: 0.01 + # Move to the next frame. + animator() + assert animator.is_being_animated(vertical_scroll, "scroll_y") + + +async def test_scrolling_does_not_animate_on_none() -> None: + app = TallApp() + app.animation_level = "none" + + async with app.run_test() as pilot: + vertical_scroll = app.query_one(VerticalScroll) + animator = app.animator + # Freeze time at 0 before triggering the animation. + animator._get_time = lambda *_: 0 + vertical_scroll.scroll_end(duration=10000) + await pilot.pause() + # Freeze time after the animation start and before animation end. + animator._get_time = lambda *_: 0.01 + # Move to the next frame. + animator() + assert not animator.is_being_animated(vertical_scroll, "scroll_y") diff --git a/tests/animations/test_switch_animation.py b/tests/animations/test_switch_animation.py new file mode 100644 index 0000000000..91903f1228 --- /dev/null +++ b/tests/animations/test_switch_animation.py @@ -0,0 +1,69 @@ +""" +Tests for the switch toggle animation, which is considered a basic animation. +(An animation that also plays on the level BASIC.) +""" + +from textual.app import App, ComposeResult +from textual.widgets import Switch + + +class SwitchApp(App[None]): + def compose(self) -> ComposeResult: + yield Switch() + + +async def test_switch_animates_on_full() -> None: + app = SwitchApp() + app.animation_level = "full" + + async with app.run_test() as pilot: + switch = app.query_one(Switch) + animator = app.animator + # Freeze time at 0 before triggering the animation. + animator._get_time = lambda *_: 0 + switch.action_toggle_switch() + await pilot.pause() + # Freeze time after the animation start and before animation end. + animator._get_time = lambda *_: 0.01 + # Move to the next frame. + animator() + # The animation should still be running. + assert app.animator.is_being_animated(switch, "slider_pos") + + +async def test_switch_animates_on_basic() -> None: + app = SwitchApp() + app.animation_level = "basic" + + async with app.run_test() as pilot: + switch = app.query_one(Switch) + animator = app.animator + # Freeze time at 0 before triggering the animation. + animator._get_time = lambda *_: 0 + switch.action_toggle_switch() + await pilot.pause() + # Freeze time after the animation start and before animation end. + animator._get_time = lambda *_: 0.01 + # Move to the next frame. + animator() + # The animation should still be running. + assert app.animator.is_being_animated(switch, "slider_pos") + + +async def test_switch_does_not_animate_on_none() -> None: + app = SwitchApp() + app.animation_level = "none" + + async with app.run_test() as pilot: + switch = app.query_one(Switch) + animator = app.animator + # Freeze time at 0 before triggering the animation. + animator._get_time = lambda *_: 0 + switch.action_toggle_switch() + await pilot.pause() + # Freeze time after the animation start and before animation end. + animator._get_time = lambda *_: 0.01 + # Move to the next frame. + animator() + # The animation should still be running. + assert not app.animator.is_being_animated(switch, "slider_pos") diff --git a/tests/animations/test_tabs_underline_animation.py b/tests/animations/test_tabs_underline_animation.py new file mode 100644 index 0000000000..05e83e9e5d --- /dev/null +++ b/tests/animations/test_tabs_underline_animation.py @@ -0,0 +1,75 @@ +""" +Tests for the tabs underline animation, which is considered a basic animation. +(An animation that also plays on the level BASIC.) +""" + +from textual.app import App, ComposeResult +from textual.widgets import Label, TabbedContent, Tabs +from textual.widgets._tabs import Underline + + +class TabbedContentApp(App[None]): + def compose(self) -> ComposeResult: + with TabbedContent(): + for _ in range(10): + yield Label("Hey!") + + +async def test_tabs_underline_animates_on_full() -> None: + """The underline takes some time to move when animated.""" + app = TabbedContentApp() + app.animation_level = "full" + + async with app.run_test() as pilot: + underline = app.query_one(Underline) + animator = app.animator + # Freeze time at 0 before triggering the animation. + animator._get_time = lambda *_: 0 + app.query_one(Tabs).action_previous_tab() + await pilot.pause() + # Freeze time after the animation start and before animation end. + animator._get_time = lambda *_: 0.01 + # Move to the next frame. + animator() + assert animator.is_being_animated(underline, "highlight_start") + assert animator.is_being_animated(underline, "highlight_end") + + +async def test_tabs_underline_animates_on_basic() -> None: + """The underline takes some time to move when animated.""" + app = TabbedContentApp() + app.animation_level = "basic" + + async with app.run_test() as pilot: + underline = app.query_one(Underline) + animator = app.animator + # Freeze time at 0 before triggering the animation. + animator._get_time = lambda *_: 0 + app.query_one(Tabs).action_previous_tab() + await pilot.pause() + # Freeze time after the animation start and before animation end. + animator._get_time = lambda *_: 0.01 + # Move to the next frame. + animator() + assert animator.is_being_animated(underline, "highlight_start") + assert animator.is_being_animated(underline, "highlight_end") + + +async def test_tabs_underline_does_not_animate_on_none() -> None: + """The underline jumps to its final position when not animated.""" + app = TabbedContentApp() + app.animation_level = "none" + + async with app.run_test() as pilot: + underline = app.query_one(Underline) + animator = app.animator + # Freeze time at 0 before triggering the animation. + animator._get_time = lambda *_: 0 + app.query_one(Tabs).action_previous_tab() + await pilot.pause() + # Freeze time after the animation start and before animation end. + animator._get_time = lambda *_: 0.01 + # Move to the next frame. + animator() + assert not animator.is_being_animated(underline, "highlight_start") + assert not animator.is_being_animated(underline, "highlight_end") 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_css_reloading.py b/tests/css/test_css_reloading.py index e690604155..b572e97dcd 100644 --- a/tests/css/test_css_reloading.py +++ b/tests/css/test_css_reloading.py @@ -1,7 +1,4 @@ -""" -Regression test for https://github.com/Textualize/textual/issues/3931 -""" - +import os from pathlib import Path from textual.app import App, ComposeResult @@ -34,7 +31,7 @@ def on_mount(self) -> None: async def test_css_reloading_applies_to_non_top_screen(monkeypatch) -> None: # type: ignore - """Regression test for https://github.com/Textualize/textual/issues/2063.""" + """Regression test for https://github.com/Textualize/textual/issues/3931""" monkeypatch.setenv( "TEXTUAL", "debug" @@ -65,3 +62,25 @@ async def test_css_reloading_applies_to_non_top_screen(monkeypatch) -> None: # # Height should fall back to 1. assert first_label.styles.height is not None assert first_label.styles.height.value == 1 + + +async def test_css_reloading_file_not_found(monkeypatch, tmp_path): + """Regression test for https://github.com/Textualize/textual/issues/3996 + + Files can become temporarily unavailable during saving on certain environments. + """ + monkeypatch.setenv("TEXTUAL", "debug") + + css_path = tmp_path / "test_css_reloading_file_not_found.tcss" + with open(css_path, "w") as css_file: + css_file.write("#a {color: red;}") + + class TextualApp(App): + CSS_PATH = css_path + + app = TextualApp() + async with app.run_test() as pilot: + await pilot.app._on_css_change() + os.remove(css_path) + assert not css_path.exists() + await pilot.app._on_css_change() diff --git a/tests/css/test_nested_css.py b/tests/css/test_nested_css.py index 821c655ff3..e73d67e686 100644 --- a/tests/css/test_nested_css.py +++ b/tests/css/test_nested_css.py @@ -7,7 +7,7 @@ from textual.containers import Vertical from textual.css.parse import parse from textual.css.tokenizer import EOFError, TokenError -from textual.widgets import Label +from textual.widgets import Button, Label class NestedApp(App): @@ -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"), [ @@ -63,3 +110,76 @@ def test_parse_errors(css: str, exception: type[Exception]) -> None: """Check some CSS which should fail.""" with pytest.raises(exception): list(parse("", css, ("foo", ""))) + + +class PseudoClassesInNestedApp(App[None]): + CSS = """ + Vertical { + Button:light, Button:dark { + background: red; + } + + min-height: 3; # inconsequential rule to add entropy. + + #two, *:focus { + background: green !important; + } + + height: auto; # inconsequential rule to add entropy. + + Label { + background: yellow; + + &:light, &:dark { + background: red; + } + + &:hover { + background: green !important; + } + } + } + """ + + AUTO_FOCUS = "Button" + + def compose(self) -> ComposeResult: + with Vertical(): + yield Button(id="one", classes="first_half") + yield Button(id="two", classes="first_half") + with Vertical(): + yield Label("Hello, world!", id="three", classes="first_half") + yield Label("Hello, world!", id="four", classes="first_half") + with Vertical(): + yield Button(id="five", classes="second_half") + yield Button(id="six", classes="second_half") + yield Label("Hello, world!", id="seven", classes="second_half") + yield Label("Hello, world!", id="eight", classes="second_half") + + +async def test_pseudo_classes_work_in_nested_css() -> None: + """Makes sure pseudo-classes are correctly understood in nested TCSS. + + Regression test for https://github.com/Textualize/textual/issues/4039. + """ + + app = PseudoClassesInNestedApp() + green = Color.parse("green") + red = Color.parse("red") + async with app.run_test() as pilot: + assert app.query_one("#one").styles.background == green + assert app.query_one("#two").styles.background == green + assert app.query_one("#five").styles.background == red + assert app.query_one("#six").styles.background == red + + assert app.query_one("#three").styles.background == red + assert app.query_one("#four").styles.background == red + assert app.query_one("#seven").styles.background == red + assert app.query_one("#eight").styles.background == red + + await pilot.hover("#eight") + + assert app.query_one("#three").styles.background == red + assert app.query_one("#four").styles.background == red + assert app.query_one("#seven").styles.background == red + assert app.query_one("#eight").styles.background == green diff --git a/tests/css/test_stylesheet.py b/tests/css/test_stylesheet.py index 258190d836..96f0591da8 100644 --- a/tests/css/test_stylesheet.py +++ b/tests/css/test_stylesheet.py @@ -207,6 +207,55 @@ def test_did_you_mean_for_css_property_names( assert help_text.summary == expected_summary +@pytest.mark.parametrize( + "css_property_name,expected_property_name_suggestion", + [ + ["backgroundu", "background"], + ["bckgroundu", "background"], + ["ofset-x", "offset-x"], + ["ofst_y", "offset-y"], + ["colr", "color"], + ["colour", "color"], + ["wdth", "width"], + ["wth", "width"], + ["wh", None], + ["xkcd", None], + ], +) +def test_did_you_mean_for_property_names_in_nested_css( + css_property_name: str, expected_property_name_suggestion: "str | None" +) -> None: + """Test that we get nice errors with mistyped declaractions in nested CSS. + + When implementing pseudo-class support in nested TCSS + (https://github.com/Textualize/textual/issues/4039), the first iterations didn't + preserve this so we add these tests to make sure we don't take this feature away + unintentionally. + """ + stylesheet = Stylesheet() + css = """ + Screen { + * { + border: blue; + ${PROPERTY}: red; + } + } + """.replace( + "${PROPERTY}", css_property_name + ) + + stylesheet.add_source(css) + with pytest.raises(StylesheetParseError) as err: + stylesheet.parse() + + _, help_text = err.value.errors.rules[1].errors[0] + displayed_css_property_name = css_property_name.replace("_", "-") + expected_summary = f"Invalid CSS property {displayed_css_property_name!r}" + if expected_property_name_suggestion: + expected_summary += f". Did you mean '{expected_property_name_suggestion}'?" + assert help_text.summary == expected_summary + + @pytest.mark.parametrize( "css_property_name,css_property_value,expected_color_suggestion", [ diff --git a/tests/css/test_tokenize.py b/tests/css/test_tokenize.py index d4dfba888e..7b36e542aa 100644 --- a/tests/css/test_tokenize.py +++ b/tests/css/test_tokenize.py @@ -898,3 +898,83 @@ def test_allow_new_lines(): ), ] assert list(tokenize(css, ("", ""))) == expected + + +@pytest.mark.parametrize( + ["pseudo_class", "expected"], + [ + ("blue", "blur"), + ("br", "blur"), + ("canfocus", "can-focus"), + ("can_focus", "can-focus"), + ("can-foc", "can-focus"), + ("drk", "dark"), + ("ark", "dark"), + ("disssabled", "disabled"), + ("enalbed", "enabled"), + ("focoswithin", "focus-within"), + ("focus_whitin", "focus-within"), + ("fcus", "focus"), + ("huver", "hover"), + ("LIght", "light"), + ], +) +def test_did_you_mean_pseudo_classes(pseudo_class: str, expected: str) -> None: + """Make sure we get the correct suggestion for pseudo-classes with typos.""" + + css = f""" + Button:{pseudo_class} {{ + background: red; + }} + """ + + with pytest.raises(TokenError) as err: + list(tokenize(css, ("", ""))) + + assert f"unknown pseudo-class {pseudo_class!r}" in str(err.value) + assert f"did you mean {expected!r}" in str(err.value) + + +@pytest.mark.parametrize( + ["pseudo_class", "expected"], + [ + ("blue", "blur"), + ("br", "blur"), + ("canfocus", "can-focus"), + ("can_focus", "can-focus"), + ("can-foc", "can-focus"), + ("drk", "dark"), + ("ark", "dark"), + ("disssabled", "disabled"), + ("enalbed", "enabled"), + ("focoswithin", "focus-within"), + ("focus_whitin", "focus-within"), + ("fcus", "focus"), + ("huver", "hover"), + ("LIght", "light"), + ], +) +def test_did_you_mean_pseudo_classes_in_nested_css( + pseudo_class: str, expected: str +) -> None: + """Test that we get nice errors for pseudo-classes with typos in nested TCSS. + + When implementing pseudo-class support in nested TCSS + (https://github.com/Textualize/textual/issues/4039), the first iterations didn't + preserve this so we add these tests to make sure we don't take this feature away + unintentionally. + """ + + css = f""" + Screen {{ + Button:{pseudo_class} {{ + background: red; + }} + }} + """ + + with pytest.raises(TokenError) as err: + list(tokenize(css, ("", ""))) + + assert f"unknown pseudo-class {pseudo_class!r}" in str(err.value) + assert f"did you mean {expected!r}" in str(err.value) diff --git a/tests/directory_tree/test_change_path.py b/tests/directory_tree/test_change_path.py new file mode 100644 index 0000000000..1b73f3c09d --- /dev/null +++ b/tests/directory_tree/test_change_path.py @@ -0,0 +1,20 @@ +from pathlib import Path + +from textual.app import App, ComposeResult +from textual.widgets import DirectoryTree + + +class DirectoryTreeApp(App[None]): + + def compose(self) -> ComposeResult: + yield DirectoryTree(".") + + +async def test_change_directory_tree_path(tmpdir: Path) -> None: + """The DirectoryTree should react to the path changing.""" + + async with DirectoryTreeApp().run_test() as pilot: + assert pilot.app.query_one(DirectoryTree).root.data.path == Path(".") + pilot.app.query_one(DirectoryTree).path = tmpdir + await pilot.pause() + assert pilot.app.query_one(DirectoryTree).root.data.path == tmpdir diff --git a/tests/document/test_document_navigator.py b/tests/document/test_document_navigator.py new file mode 100644 index 0000000000..d5dbe616e5 --- /dev/null +++ b/tests/document/test_document_navigator.py @@ -0,0 +1,94 @@ +import pytest + +from textual.document._document import Document +from textual.document._document_navigator import DocumentNavigator +from textual.document._wrapped_document import WrappedDocument + +TEXT = """\ +01 3456 +01234""" + + +# wrapped width = 4: +# line_index | wrapped_lines +# 0 | 01_ +# | 3456 +# 1 | 0123 +# | 4 + + +def make_navigator(text, width): + document = Document(text) + wrapped_document = WrappedDocument(document, width) + navigator = DocumentNavigator(wrapped_document) + return navigator + + +@pytest.mark.parametrize( + "start,end", + [ + [(0, 0), (0, 0)], + [(0, 1), (0, 0)], + [(0, 2), (0, 0)], + [(0, 3), (0, 0)], + [(0, 4), (0, 1)], + [(0, 5), (0, 2)], + [(0, 6), (0, 2)], # clamps to valid index + [(0, 7), (0, 2)], # clamps to the last valid index + [(1, 0), (0, 3)], + [(1, 1), (0, 4)], + [(1, 5), (1, 1)], + ], +) +def test_get_location_above(start, end): + assert make_navigator(TEXT, 4).get_location_above(start) == end + + +@pytest.mark.parametrize( + "start,end", + [ + [(0, 0), (0, 3)], + [(0, 1), (0, 4)], + [(0, 2), (0, 5)], + [(0, 3), (1, 0)], + [(0, 4), (1, 1)], + [(0, 5), (1, 2)], + [(0, 6), (1, 3)], + [(0, 7), (1, 3)], + [(1, 3), (1, 5)], + ], +) +def test_get_location_below(start, end): + assert make_navigator(TEXT, 4).get_location_below(start) == end + + +@pytest.mark.parametrize( + "start,end", + [ + [(0, 0), (0, 0)], + [(0, 2), (0, 0)], + [(0, 3), (0, 3)], + [(0, 6), (0, 3)], + [(0, 7), (0, 3)], + [(1, 0), (1, 0)], + [(1, 3), (1, 0)], + [(1, 4), (1, 4)], + [(1, 5), (1, 4)], + ], +) +def test_get_location_home(start, end): + assert make_navigator(TEXT, 4).get_location_home(start) == end + + +@pytest.mark.parametrize( + "start,end", + [ + [(0, 0), (0, 2)], + [(0, 2), (0, 2)], + [(0, 3), (0, 7)], + [(0, 5), (0, 7)], + [(1, 2), (1, 3)], + ], +) +def test_get_location_end(start, end): + assert make_navigator(TEXT, 4).get_location_end(start) == end diff --git a/tests/document/test_wrapped_document.py b/tests/document/test_wrapped_document.py new file mode 100644 index 0000000000..2acc5f3358 --- /dev/null +++ b/tests/document/test_wrapped_document.py @@ -0,0 +1,204 @@ +import pytest + +from textual.document._document import Document +from textual.document._wrapped_document import WrappedDocument +from textual.geometry import Offset + +SIMPLE_TEXT = "123 4567\n12345\n123456789\n" + + +def test_wrap(): + document = Document(SIMPLE_TEXT) + wrapped_document = WrappedDocument(document, width=4) + + assert wrapped_document.lines == [ + ["123 ", "4567"], + ["1234", "5"], + ["1234", "5678", "9"], + [""], + ] + + +def test_wrap_empty_document(): + document = Document("") + wrapped_document = WrappedDocument(document, width=4) + + assert wrapped_document.lines == [[""]] + + +def test_wrap_width_zero_no_wrapping(): + document = Document(SIMPLE_TEXT) + wrapped_document = WrappedDocument(document, width=0) + + assert wrapped_document.lines == [ + ["123 4567"], + ["12345"], + ["123456789"], + [""], + ] + + +def test_refresh_range(): + """The post-edit content is not wrapped.""" + document = Document(SIMPLE_TEXT) + wrapped_document = WrappedDocument(document, width=4) + + start_location = (1, 0) + old_end_location = (3, 0) + + edit_result = document.replace_range(start_location, old_end_location, "123") + + # Inform the wrapped document about the range impacted by the edit + wrapped_document.wrap_range( + start_location, + old_end_location, + edit_result.end_location, + ) + + # Now confirm the resulting wrapped version is as we would expect + assert wrapped_document.lines == [["123 ", "4567"], ["123"]] + + +def test_refresh_range_new_text_wrapped(): + """The post-edit content itself must be wrapped.""" + document = Document(SIMPLE_TEXT) + wrapped_document = WrappedDocument(document, width=4) + + start_location = (1, 0) + old_end_location = (3, 0) + + edit_result = document.replace_range( + start_location, old_end_location, "12 34567 8901" + ) + + # Inform the wrapped document about the range impacted by the edit + wrapped_document.wrap_range( + start_location, old_end_location, edit_result.end_location + ) + + # Now confirm the resulting wrapped version is as we would expect + assert wrapped_document.lines == [ + ["123 ", "4567"], + ["12 ", "3456", "7 ", "8901"], + ] + + +def test_refresh_range_wrapping_at_previously_unavailable_range(): + """When we insert new content at the end of the document, ensure it wraps correctly.""" + document = Document(SIMPLE_TEXT) + wrapped_document = WrappedDocument(document, width=4) + + edit_result = document.replace_range((3, 0), (3, 0), "012 3456\n78 90123\n45") + wrapped_document.wrap_range((3, 0), (3, 0), edit_result.end_location) + + assert wrapped_document.lines == [ + ["123 ", "4567"], + ["1234", "5"], + ["1234", "5678", "9"], + ["012 ", "3456"], + ["78 ", "9012", "3"], + ["45"], + ] + + +def test_refresh_range_wrapping_disabled_previously_unavailable_range(): + document = Document(SIMPLE_TEXT) + wrapped_document = WrappedDocument(document, width=0) # wrapping disabled + + edit_result = document.replace_range((3, 0), (3, 0), "012 3456\n78 90123\n45") + wrapped_document.wrap_range((3, 0), (3, 0), edit_result.end_location) + + assert wrapped_document.lines == [ + ["123 4567"], + ["12345"], + ["123456789"], + ["012 3456"], + ["78 90123"], + ["45"], + ] + + +@pytest.mark.parametrize( + "offset,location", # location is (row, column) + [ + (Offset(x=0, y=0), (0, 0)), + (Offset(x=1, y=0), (0, 1)), + (Offset(x=2, y=1), (0, 6)), + (Offset(x=0, y=3), (1, 4)), + (Offset(x=1, y=3), (1, 5)), + (Offset(x=200, y=3), (1, 5)), + (Offset(x=0, y=6), (2, 8)), + (Offset(x=0, y=7), (3, 0)), # Clicking on the final, empty line + (Offset(x=0, y=1000), (3, 0)), + ], +) +def test_offset_to_location_wrapping_enabled(offset, location): + document = Document(SIMPLE_TEXT) + wrapped_document = WrappedDocument(document, width=4) + + assert wrapped_document.offset_to_location(offset) == location + + +@pytest.mark.parametrize( + "offset,location", # location is (row, column) + [ + (Offset(x=0, y=0), (0, 0)), + (Offset(x=1, y=0), (0, 1)), + (Offset(x=2, y=1), (1, 2)), + (Offset(x=0, y=3), (3, 0)), + (Offset(x=1, y=3), (3, 0)), + (Offset(x=200, y=3), (3, 0)), + (Offset(x=200, y=200), (3, 0)), # Clicking below the document + ], +) +def test_offset_to_location_wrapping_disabled(offset, location): + document = Document(SIMPLE_TEXT) + wrapped_document = WrappedDocument(document, width=0) + + assert wrapped_document.offset_to_location(offset) == location + + +@pytest.mark.parametrize( + "offset,location", + [ + [Offset(-3, 0), (0, 0)], + [Offset(0, -10), (0, 0)], + ], +) +def test_offset_to_location_invalid_offset_clamps_to_valid_offset(offset, location): + document = Document(SIMPLE_TEXT) + wrapped_document = WrappedDocument(document, width=4) + + result = wrapped_document.offset_to_location(offset) + assert result == location + + +@pytest.mark.parametrize( + "line_index, offsets", + [ + (0, [4]), + (1, [4]), + (2, [4, 8]), + ], +) +def test_get_offsets(line_index, offsets): + document = Document(SIMPLE_TEXT) + wrapped_document = WrappedDocument(document, width=4) + + assert wrapped_document.get_offsets(line_index) == offsets + + +def test_get_offsets_no_wrapping(): + document = Document("abc") + wrapped_document = WrappedDocument(document, width=4) + + assert wrapped_document.get_offsets(0) == [] + + +@pytest.mark.parametrize("line_index", [-4, 10000]) +def test_get_offsets_invalid_line_index(line_index): + document = Document(SIMPLE_TEXT) + wrapped_document = WrappedDocument(document, width=4) + + with pytest.raises(ValueError): + wrapped_document.get_offsets(line_index) 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/notifications/test_notification.py b/tests/notifications/test_notification.py index c4bca1b649..4dd91d64b1 100644 --- a/tests/notifications/test_notification.py +++ b/tests/notifications/test_notification.py @@ -22,7 +22,7 @@ def test_default_severity_level() -> None: def test_default_timeout() -> None: """The default timeout should be as expected.""" - assert Notification("test").timeout == 3 + assert Notification("test").timeout == Notification.timeout def test_identity_is_unique() -> None: 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/renderables/test_sparkline.py b/tests/renderables/test_sparkline.py index 12e53ea728..1478e00b32 100644 --- a/tests/renderables/test_sparkline.py +++ b/tests/renderables/test_sparkline.py @@ -1,3 +1,8 @@ +from collections import UserList, deque +from typing import Sequence + +import pytest + from tests.utilities.render import render from textual.renderables.sparkline import Sparkline @@ -46,3 +51,24 @@ def test_sparkline_color_blend(): render(Sparkline([1, 2, 3], width=3)) == f"{GREEN}▁{STOP}{BLENDED}▄{STOP}{RED}█{STOP}" ) + + +@pytest.mark.parametrize( + "data", + [ + (1, 2, 3), + [1, 2, 3], + bytearray((1, 2, 3)), + bytes((1, 2, 3)), + deque([1, 2, 3]), + range(1, 4), + UserList((1, 2, 3)), + ], +) +def test_sparkline_sequence_types(data: Sequence[int]): + """Sparkline should work with common Sequence types.""" + assert issubclass(type(data), Sequence) + assert ( + render(Sparkline(data, width=3)) + == f"{GREEN}▁{STOP}{BLENDED}▄{STOP}{RED}█{STOP}" + ) diff --git a/tests/renderables/test_tint.py b/tests/renderables/test_tint.py index 52e54aa60c..6c1b7c2525 100644 --- a/tests/renderables/test_tint.py +++ b/tests/renderables/test_tint.py @@ -1,8 +1,11 @@ import io from rich.console import Console +from rich.segment import Segments +from rich.terminal_theme import DIMMED_MONOKAI from rich.text import Text +from textual._ansi_theme import DEFAULT_TERMINAL_THEME from textual.color import Color from textual.renderables.tint import Tint @@ -10,8 +13,36 @@ def test_tint(): console = Console(file=io.StringIO(), force_terminal=True, color_system="truecolor") renderable = Text.from_markup("[#aabbcc on #112233]foo") - console.print(Tint(renderable, Color(0, 100, 0, 0.5))) + segments = list(console.render(renderable)) + console.print( + Segments( + Tint.process_segments( + segments=segments, + color=Color(0, 100, 0, 0.5), + ansi_theme=DEFAULT_TERMINAL_THEME, + ) + ) + ) output = console.file.getvalue() print(repr(output)) expected = "\x1b[38;2;85;143;102;48;2;8;67;25mfoo\x1b[0m\n" assert output == expected + + +def test_tint_ansi_mapping(): + console = Console(file=io.StringIO(), force_terminal=True, color_system="truecolor") + renderable = Text.from_markup("[red on yellow]foo") + segments = list(console.render(renderable)) + console.print( + Segments( + Tint.process_segments( + segments=segments, + color=Color(0, 100, 0, 0.5), + ansi_theme=DIMMED_MONOKAI, + ) + ) + ) + output = console.file.getvalue() + print(repr(output)) + expected = "\x1b[38;2;95;81;36;48;2;98;133;26mfoo\x1b[0m\n" + assert output == expected diff --git a/tests/select/test_changed_message.py b/tests/select/test_changed_message.py index 7f876ac1ff..166d676972 100644 --- a/tests/select/test_changed_message.py +++ b/tests/select/test_changed_message.py @@ -55,3 +55,13 @@ async def test_same_selection_does_not_post_message(): await pilot.click(SelectOverlay, offset=(2, 3)) await pilot.pause() assert len(app.changed_messages) == 1 + + +async def test_setting_value_posts_message() -> None: + """Setting the value of a Select should post a message.""" + + async with (app := SelectApp()).run_test() as pilot: + assert len(app.changed_messages) == 0 + app.query_one(Select).value = 2 + await pilot.pause() + assert len(app.changed_messages) == 1 diff --git a/tests/selection_list/test_selection_messages.py b/tests/selection_list/test_selection_messages.py index d8717d8c84..ce1d138731 100644 --- a/tests/selection_list/test_selection_messages.py +++ b/tests/selection_list/test_selection_messages.py @@ -28,17 +28,21 @@ def compose(self) -> ComposeResult: @on(SelectionList.SelectedChanged) def _record( self, - event: OptionList.OptionMessage - | SelectionList.SelectionMessage - | SelectionList.SelectedChanged, + event: ( + OptionList.OptionMessage + | SelectionList.SelectionMessage + | SelectionList.SelectedChanged + ), ) -> None: assert event.control == self.query_one(SelectionList) self.messages.append( ( event.__class__.__name__, - event.selection_index - if isinstance(event, SelectionList.SelectionMessage) - else None, + ( + event.selection_index + if isinstance(event, SelectionList.SelectionMessage) + else None + ), ) ) @@ -71,6 +75,7 @@ async def test_toggle() -> None: assert pilot.app.messages == [ ("SelectionHighlighted", 0), ("SelectedChanged", None), + ("SelectionToggled", 0), ] diff --git a/tests/snapshot_tests/__snapshots__/test_snapshots.ambr b/tests/snapshot_tests/__snapshots__/test_snapshots.ambr index e6fe76171f..d480c9835c 100644 --- a/tests/snapshot_tests/__snapshots__/test_snapshots.ambr +++ b/tests/snapshot_tests/__snapshots__/test_snapshots.ambr @@ -161,6 +161,504 @@ ''' # --- +# name: test_ansi_color_mapping[False] + ''' + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + AnsiMappingApp + + + + + + + + + + Foreground & background + red + dim red + green + dim green + yellow + dim yellow + blue + dim blue + magenta + dim magenta + cyan + dim cyan + white + dim white + black + dim black + + + + + + + + + + + + ''' +# --- +# name: test_ansi_color_mapping[True] + ''' + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + AnsiMappingApp + + + + + + + + + + Foreground & background + red + dim red + green + dim green + yellow + dim yellow + blue + dim blue + magenta + dim magenta + cyan + dim cyan + white + dim white + black + dim black + + + + + + + + + + + + ''' +# --- +# name: test_app_blur + ''' + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + AppBlurApp + + + + + + + + + + + + + + + + + + ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ + This should be the blur style + ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ + + ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ + This should also be the blur style + ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ + + + + + + + + + + + + + + ''' +# --- # name: test_auto_fr ''' @@ -1663,6 +2161,166 @@ ''' # --- +# name: test_button_widths + ''' + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + HorizontalWidthAutoApp + + + + + + + + + + ──────────────────────────── + ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ + This is a very wide button + ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ + ──────────────────────────── + ──────────────────────────────────────────────────────── + ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ + This is a very wide buttonThis is a very wide button + ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ + ──────────────────────────────────────────────────────── + + + + + + + + + + + + + + + + + + + ''' +# --- # name: test_buttons_render ''' @@ -1872,144 +2530,144 @@ font-weight: 700; } - .terminal-1822845634-matrix { + .terminal-3087759104-matrix { font-family: Fira Code, monospace; font-size: 20px; line-height: 24.4px; font-variant-east-asian: full-width; } - .terminal-1822845634-title { + .terminal-3087759104-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-3087759104-r1 { fill: #e1e1e1 } + .terminal-3087759104-r2 { fill: #c5c8c6 } + .terminal-3087759104-r3 { fill: #262626 } + .terminal-3087759104-r4 { fill: #e2e2e2 } + .terminal-3087759104-r5 { fill: #4a4a4a } + .terminal-3087759104-r6 { fill: #2e2e2e;font-weight: bold } + .terminal-3087759104-r7 { fill: #e3e3e3 } + .terminal-3087759104-r8 { fill: #e3e3e3;font-weight: bold } + .terminal-3087759104-r9 { fill: #f4005f } + .terminal-3087759104-r10 { fill: #4ebf71;font-weight: bold } + .terminal-3087759104-r11 { fill: #0178d4 } + .terminal-3087759104-r12 { fill: #14191f } + .terminal-3087759104-r13 { fill: #5d5d5d } + .terminal-3087759104-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 + ▁▁▁▁▁▁▁▁▁▁▁▁▁ + ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ @@ -2992,137 +3650,298 @@ font-weight: 700; } - .terminal-174430999-matrix { + .terminal-454793765-matrix { font-family: Fira Code, monospace; font-size: 20px; line-height: 24.4px; font-variant-east-asian: full-width; } - .terminal-174430999-title { + .terminal-454793765-title { font-size: 18px; font-weight: bold; font-family: arial; } - .terminal-174430999-r1 { fill: #a2a2a2 } - .terminal-174430999-r2 { fill: #c5c8c6 } - .terminal-174430999-r3 { fill: #004578 } - .terminal-174430999-r4 { fill: #e2e3e3 } - .terminal-174430999-r5 { fill: #00ff00 } - .terminal-174430999-r6 { fill: #24292f } - .terminal-174430999-r7 { fill: #1e1e1e } - .terminal-174430999-r8 { fill: #fea62b;font-weight: bold } + .terminal-454793765-r1 { fill: #a2a2a2 } + .terminal-454793765-r2 { fill: #c5c8c6 } + .terminal-454793765-r3 { fill: #004578 } + .terminal-454793765-r4 { fill: #e2e3e3 } + .terminal-454793765-r5 { fill: #00ff00 } + .terminal-454793765-r6 { fill: #000000 } + .terminal-454793765-r7 { fill: #1e1e1e } + .terminal-454793765-r8 { fill: #fea62b;font-weight: bold } - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - CommandPaletteApp + CommandPaletteApp - + - - - - - ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ - - 🔎A - - - This is a test of this code 9 - This is a test of this code 8 - This is a test of this code 7 - This is a test of this code 6 - This is a test of this code 5 - This is a test of this code 4 - This is a test of this code 3 - This is a test of this code 2 - This is a test of this code 1 - This is a test of this code 0 - ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ - - - - + + + + + ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ + + 🔎A + + + This is a test of this code 9 + This is a test of this code 8 + This is a test of this code 7 + This is a test of this code 6 + This is a test of this code 5 + This is a test of this code 4 + This is a test of this code 3 + This is a test of this code 2 + This is a test of this code 1 + This is a test of this code 0 + ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ + + + + + + + + + + ''' +# --- +# 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 + ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ + + + + @@ -4690,133 +5509,133 @@ font-weight: 700; } - .terminal-2436490509-matrix { + .terminal-2091190527-matrix { font-family: Fira Code, monospace; font-size: 20px; line-height: 24.4px; font-variant-east-asian: full-width; } - .terminal-2436490509-title { + .terminal-2091190527-title { font-size: 18px; font-weight: bold; font-family: arial; } - .terminal-2436490509-r1 { fill: #e1e1e1 } - .terminal-2436490509-r2 { fill: #0178d4 } - .terminal-2436490509-r3 { fill: #c5c8c6 } - .terminal-2436490509-r4 { fill: #1e1e1e } + .terminal-2091190527-r1 { fill: #e1e1e1 } + .terminal-2091190527-r2 { fill: #c5c8c6 } + .terminal-2091190527-r3 { fill: #0178d4 } + .terminal-2091190527-r4 { fill: #1e1e1e } - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - AllBordersApp + AllBordersApp - - - - +------------------+╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍ - |ascii|blankdashed - +------------------+╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍ - - - ══════════════════━━━━━━━━━━━━━━━━━━ - doubleheavyhidden/none - ══════════════════━━━━━━━━━━━━━━━━━━ - - - ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ - hkeyinnerouter - ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄ - - - ────────────────────────────────────▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ - roundsolidtall - ────────────────────────────────────▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ - - - ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ - thickvkeywide - ▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ + + + + + +----------------+╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍═════════════════ + |ascii|blankdasheddouble + +----------------+╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍═════════════════ + + + + ━━━━━━━━━━━━━━━━▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄ + heavyhidden/nonehkeyinner + ━━━━━━━━━━━━━━━━▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ + + + + ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀█████████████████───────────────────────────────── + outerpanelroundsolid + ▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁───────────────────────────────── + + + + ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ + tallthickvkeywide + ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ + @@ -4847,141 +5666,141 @@ font-weight: 700; } - .terminal-2893669067-matrix { + .terminal-2586053582-matrix { font-family: Fira Code, monospace; font-size: 20px; line-height: 24.4px; font-variant-east-asian: full-width; } - .terminal-2893669067-title { + .terminal-2586053582-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-2586053582-r1 { fill: #e1e1e1 } + .terminal-2586053582-r2 { fill: #c5c8c6 } + .terminal-2586053582-r3 { fill: #fea62b } + .terminal-2586053582-r4 { fill: #fea62b;font-weight: bold } + .terminal-2586053582-r5 { fill: #fea62b;font-weight: bold;font-style: italic; } + .terminal-2586053582-r6 { fill: #f4005f;font-weight: bold } + .terminal-2586053582-r7 { fill: #1e1e1e } + .terminal-2586053582-r8 { fill: #1e1e1e;text-decoration: underline; } + .terminal-2586053582-r9 { fill: #fea62b;text-decoration: underline; } + .terminal-2586053582-r10 { fill: #1a1a1a;text-decoration: underline; } + .terminal-2586053582-r11 { fill: #4ebf71 } + .terminal-2586053582-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 +17074,137 @@ font-weight: 700; } - .terminal-1146140386-matrix { + .terminal-3159150741-matrix { font-family: Fira Code, monospace; font-size: 20px; line-height: 24.4px; font-variant-east-asian: full-width; } - .terminal-1146140386-title { + .terminal-3159150741-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-3159150741-r1 { fill: #e1e1e1 } + .terminal-3159150741-r2 { fill: #c5c8c6 } + .terminal-3159150741-r3 { fill: #dde6ed;font-weight: bold } + .terminal-3159150741-r4 { fill: #dde6ed } + .terminal-3159150741-r5 { fill: #fea62b;font-weight: bold;font-style: italic; } + .terminal-3159150741-r6 { fill: #e1e2e3 } + .terminal-3159150741-r7 { fill: #f4005f } + .terminal-3159150741-r8 { fill: #f4005f;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,7 +17559,7 @@ ''' # --- -# name: test_disabled_widgets +# name: test_directory_tree_reloading ''' @@ -16763,169 +17582,329 @@ font-weight: 700; } - .terminal-3209943725-matrix { + .terminal-1907613470-matrix { font-family: Fira Code, monospace; font-size: 20px; line-height: 24.4px; font-variant-east-asian: full-width; } - .terminal-3209943725-title { + .terminal-1907613470-title { font-size: 18px; font-weight: bold; font-family: arial; } - .terminal-3209943725-r1 { fill: #454a50 } - .terminal-3209943725-r2 { fill: #507bb3 } - .terminal-3209943725-r3 { fill: #7ae998 } - .terminal-3209943725-r4 { fill: #ffcf56 } - .terminal-3209943725-r5 { fill: #e76580 } - .terminal-3209943725-r6 { fill: #c5c8c6 } - .terminal-3209943725-r7 { fill: #24292f;font-weight: bold } - .terminal-3209943725-r8 { fill: #dde6ed;font-weight: bold } - .terminal-3209943725-r9 { fill: #0a180e;font-weight: bold } - .terminal-3209943725-r10 { fill: #211505;font-weight: bold } - .terminal-3209943725-r11 { fill: #f5e5e9;font-weight: bold } - .terminal-3209943725-r12 { fill: #000000 } - .terminal-3209943725-r13 { fill: #001541 } - .terminal-3209943725-r14 { fill: #008139 } - .terminal-3209943725-r15 { fill: #b86b00 } - .terminal-3209943725-r16 { fill: #780028 } - .terminal-3209943725-r17 { fill: #303336 } - .terminal-3209943725-r18 { fill: #364b66 } - .terminal-3209943725-r19 { fill: #4a8159 } - .terminal-3209943725-r20 { fill: #8b7439 } - .terminal-3209943725-r21 { fill: #80404d } - .terminal-3209943725-r22 { fill: #a7a7a7;font-weight: bold } - .terminal-3209943725-r23 { fill: #a5a9ac;font-weight: bold } - .terminal-3209943725-r24 { fill: #0e1510;font-weight: bold } - .terminal-3209943725-r25 { fill: #19140c;font-weight: bold } - .terminal-3209943725-r26 { fill: #b0a8aa;font-weight: bold } - .terminal-3209943725-r27 { fill: #0f0f0f } - .terminal-3209943725-r28 { fill: #0f192e } - .terminal-3209943725-r29 { fill: #0f4e2a } - .terminal-3209943725-r30 { fill: #68430f } - .terminal-3209943725-r31 { fill: #4a0f22 } - .terminal-3209943725-r32 { fill: #e2e3e3;font-weight: bold } + .terminal-1907613470-r1 { fill: #e2e3e3 } + .terminal-1907613470-r2 { fill: #e2e3e3;font-weight: bold } + .terminal-1907613470-r3 { fill: #c5c8c6 } + .terminal-1907613470-r4 { fill: #008139 } + .terminal-1907613470-r5 { fill: #211505;font-weight: bold } + .terminal-1907613470-r6 { fill: #fea62b;font-weight: bold } + .terminal-1907613470-r7 { fill: #e2e3e3;font-style: italic; } - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - WidgetDisableTestApp + DirectoryTreeReloadApp - - - - ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ - ButtonButtonButtonButtonButton - ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ - ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ - ButtonButtonButtonButtonButton - ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ - ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ - ButtonButtonButtonButtonButton - ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ - ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ - ButtonButtonButtonButtonButton - ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ - ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ - ButtonButtonButtonButtonButton - ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ - ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ - ButtonButtonButtonButtonButton - ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ - ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ - ButtonButtonButtonButtonButton - ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ - ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ - ButtonButtonButtonButtonButton - ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ + + + + 📂 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_dock_layout_sidebar +# name: test_disabled_widgets + ''' + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + WidgetDisableTestApp + + + + + + + + + + ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ + ButtonButtonButtonButtonButton + ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ + ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ + ButtonButtonButtonButtonButton + ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ + ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ + ButtonButtonButtonButtonButton + ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ + ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ + ButtonButtonButtonButtonButton + ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ + ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ + ButtonButtonButtonButtonButton + ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ + ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ + ButtonButtonButtonButtonButton + ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ + ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ + ButtonButtonButtonButtonButton + ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ + ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ + ButtonButtonButtonButtonButton + ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ + + + + + ''' +# --- +# name: test_dock_layout_sidebar ''' @@ -20772,6 +21751,165 @@ ''' # --- +# name: test_input_percentage_width + ''' + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + InputVsTextArea + + + + + + + + + + 01234567890123456789012345678901234567890123456789012345678901234567890123456789 + ────────────────────────────────────── + + + + ────────────────────────────────────── + ────────────────────────────────────── + + + + + ────────────────────────────────────── + ────────────────────────────────────── + + + + + ────────────────────────────────────── + ────────────────────────────────────── + + Button + + + ────────────────────────────────────── + + + + + ''' +# --- # name: test_input_suggestions ''' @@ -21592,136 +22730,136 @@ font-weight: 700; } - .terminal-2540665408-matrix { + .terminal-3085198851-matrix { font-family: Fira Code, monospace; font-size: 20px; line-height: 24.4px; font-variant-east-asian: full-width; } - .terminal-2540665408-title { + .terminal-3085198851-title { font-size: 18px; font-weight: bold; font-family: arial; } - .terminal-2540665408-r1 { fill: #c5c8c6 } - .terminal-2540665408-r2 { fill: #e3e3e3 } - .terminal-2540665408-r3 { fill: #e1e1e1 } - .terminal-2540665408-r4 { fill: #ff0000 } - .terminal-2540665408-r5 { fill: #dde8f3;font-weight: bold } - .terminal-2540665408-r6 { fill: #ddedf9 } + .terminal-3085198851-r1 { fill: #c5c8c6 } + .terminal-3085198851-r2 { fill: #e3e3e3 } + .terminal-3085198851-r3 { fill: #e1e1e1 } + .terminal-3085198851-r4 { fill: #ff0000 } + .terminal-3085198851-r5 { fill: #dde8f3;font-weight: bold } + .terminal-3085198851-r6 { fill: #ddedf9 } - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - DialogIssueApp + DialogIssueApp - - - - DialogIssueApp - - - - - - ─────────────────────────────────────── - - - - - - This should not cause a scrollbar to ap - - - - - - ─────────────────────────────────────── - - - - -  D  Toggle the dialog  + + + + DialogIssueApp + + + + + + ────────────────────────────────────── + + + + + This should not cause a scrollbar to a + + + + + + ────────────────────────────────────── + + + + + +  D  Toggle the dialog  @@ -22365,6 +23503,164 @@ ''' # --- +# name: test_listview_index + ''' + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ListViewIndexApp + + + + + + + + + + 10 + 12 + 14 + 16▆▆ + 18 + 20 + 22 + 24 + 26 + 28 + + + + + + + + + + + + + + + + + + + ''' +# --- # name: test_loading_indicator ''' @@ -22996,6 +24292,339 @@ ''' # --- +# name: test_markdown_component_classes_reloading + ''' + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + MyApp + + + + + + + + + + ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ + + This is a header + + ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ + + ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ + + col1                              col2                              +  ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━  + value 1                           value 2                           + + ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ + + Here's some code: from itertools import productBold textEmphasized  + textstrikethrough + + + print("Hello, world!") + + + That was some code. + + + + + + + ''' +# --- +# name: test_markdown_dark_theme_override + ''' + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + MarkdownThemeSwitchertApp + + + + + + + + + + ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ + + This is a H1 + + ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ + + + defmain(): + │   print("Hello world!") + + + + + + + + + + + + + + + + + + + + ''' +# --- # name: test_markdown_example ''' @@ -23160,6 +24789,335 @@ ''' # --- +# 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 ''' @@ -23991,133 +25949,133 @@ font-weight: 700; } - .terminal-1197668808-matrix { + .terminal-1136068071-matrix { font-family: Fira Code, monospace; font-size: 20px; line-height: 24.4px; font-variant-east-asian: full-width; } - .terminal-1197668808-title { + .terminal-1136068071-title { font-size: 18px; font-weight: bold; font-family: arial; } - .terminal-1197668808-r1 { fill: #e1e1e1 } - .terminal-1197668808-r2 { fill: #c5c8c6 } - .terminal-1197668808-r3 { fill: #00ff00 } - .terminal-1197668808-r4 { fill: #ffdddd } + .terminal-1136068071-r1 { fill: #e1e1e1 } + .terminal-1136068071-r2 { fill: #c5c8c6 } + .terminal-1136068071-r3 { fill: #00ff00 } + .terminal-1136068071-r4 { fill: #ffdddd } - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - BrokenClassesApp + BrokenClassesApp - - - - - - - - - ─────────────────────────────────────── - This should have a red background - - - - - - - - - - - ─────────────────────────────────────── - - - - - + + + + + + + + + + ────────────────────────────────────── + This should have a red background + + + + + + + + + + ────────────────────────────────────── + + + + + @@ -24780,135 +26738,135 @@ font-weight: 700; } - .terminal-1795141768-matrix { + .terminal-2734338184-matrix { font-family: Fira Code, monospace; font-size: 20px; line-height: 24.4px; font-variant-east-asian: full-width; } - .terminal-1795141768-title { + .terminal-2734338184-title { font-size: 18px; font-weight: bold; font-family: arial; } - .terminal-1795141768-r1 { fill: #e1e1e1 } - .terminal-1795141768-r2 { fill: #c5c8c6 } - .terminal-1795141768-r3 { fill: #56c278 } - .terminal-1795141768-r4 { fill: #1d1d1d } - .terminal-1795141768-r5 { fill: #e3e4e4 } - .terminal-1795141768-r6 { fill: #e3e4e4;text-decoration: underline; } + .terminal-2734338184-r1 { fill: #e1e1e1 } + .terminal-2734338184-r2 { fill: #c5c8c6 } + .terminal-2734338184-r3 { fill: #56c278 } + .terminal-2734338184-r4 { fill: #1d1d1d } + .terminal-2734338184-r5 { fill: #e3e4e4 } + .terminal-2734338184-r6 { fill: #e3e4e4;text-decoration: underline; } - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - NotifyWithInlineLinkApp + NotifyWithInlineLinkApp - - - - - - - - - - - - - - - - - - - - - - - - - Click here for the bell sound. - + + + + + + + + + + + + + + + + + + + + + + + + + Click here for the bell sound. + @@ -24939,135 +26897,135 @@ font-weight: 700; } - .terminal-3756307740-matrix { + .terminal-500610332-matrix { font-family: Fira Code, monospace; font-size: 20px; line-height: 24.4px; font-variant-east-asian: full-width; } - .terminal-3756307740-title { + .terminal-500610332-title { font-size: 18px; font-weight: bold; font-family: arial; } - .terminal-3756307740-r1 { fill: #e1e1e1 } - .terminal-3756307740-r2 { fill: #c5c8c6 } - .terminal-3756307740-r3 { fill: #56c278 } - .terminal-3756307740-r4 { fill: #1d1d1d } - .terminal-3756307740-r5 { fill: #e3e4e4 } - .terminal-3756307740-r6 { fill: #ddedf9;font-weight: bold } + .terminal-500610332-r1 { fill: #e1e1e1 } + .terminal-500610332-r2 { fill: #c5c8c6 } + .terminal-500610332-r3 { fill: #56c278 } + .terminal-500610332-r4 { fill: #1d1d1d } + .terminal-500610332-r5 { fill: #e3e4e4 } + .terminal-500610332-r6 { fill: #ddedf9;font-weight: bold } - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - NotifyWithInlineLinkApp + NotifyWithInlineLinkApp - - - - - - - - - - - - - - - - - - - - - - - - - Click here for the bell sound. - + + + + + + + + + + + + + + + + + + + + + + + + + Click here for the bell sound. + @@ -25098,139 +27056,139 @@ font-weight: 700; } - .terminal-99275963-matrix { + .terminal-3535066299-matrix { font-family: Fira Code, monospace; font-size: 20px; line-height: 24.4px; font-variant-east-asian: full-width; } - .terminal-99275963-title { + .terminal-3535066299-title { font-size: 18px; font-weight: bold; font-family: arial; } - .terminal-99275963-r1 { fill: #e1e1e1 } - .terminal-99275963-r2 { fill: #c5c8c6 } - .terminal-99275963-r3 { fill: #56c278 } - .terminal-99275963-r4 { fill: #1d1d1d } - .terminal-99275963-r5 { fill: #e3e4e4 } - .terminal-99275963-r6 { fill: #feaa35 } - .terminal-99275963-r7 { fill: #e89719;font-weight: bold } - .terminal-99275963-r8 { fill: #e3e4e4;font-weight: bold } - .terminal-99275963-r9 { fill: #e3e4e4;font-weight: bold;font-style: italic; } - .terminal-99275963-r10 { fill: #bc4563 } + .terminal-3535066299-r1 { fill: #e1e1e1 } + .terminal-3535066299-r2 { fill: #c5c8c6 } + .terminal-3535066299-r3 { fill: #56c278 } + .terminal-3535066299-r4 { fill: #1d1d1d } + .terminal-3535066299-r5 { fill: #e3e4e4 } + .terminal-3535066299-r6 { fill: #feaa35 } + .terminal-3535066299-r7 { fill: #e89719;font-weight: bold } + .terminal-3535066299-r8 { fill: #e3e4e4;font-weight: bold } + .terminal-3535066299-r9 { fill: #e3e4e4;font-weight: bold;font-style: italic; } + .terminal-3535066299-r10 { fill: #bc4563 } - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - ToastApp + ToastApp - - - - - - - - It's an older code, sir, but it  - checks out. - - - - Possible trap detected - Now witness the firepower of this - fully ARMED and OPERATIONAL - battle station! - - - - It's a trap! - - - - It's against my programming to  - impersonate a deity. - + + + + + + + + It's an older code, sir, but it  + checks out. + + + + Possible trap detected + Now witness the firepower of this  + fully ARMED and OPERATIONAL battle  + station! + + + + It's a trap! + + + + It's against my programming to  + impersonate a deity. + @@ -25261,117 +27219,117 @@ font-weight: 700; } - .terminal-61440561-matrix { + .terminal-4012933681-matrix { font-family: Fira Code, monospace; font-size: 20px; line-height: 24.4px; font-variant-east-asian: full-width; } - .terminal-61440561-title { + .terminal-4012933681-title { font-size: 18px; font-weight: bold; font-family: arial; } - .terminal-61440561-r1 { fill: #c5c8c6 } - .terminal-61440561-r2 { fill: #56c278 } - .terminal-61440561-r3 { fill: #1d1d1d } - .terminal-61440561-r4 { fill: #e3e4e4 } + .terminal-4012933681-r1 { fill: #c5c8c6 } + .terminal-4012933681-r2 { fill: #56c278 } + .terminal-4012933681-r3 { fill: #1d1d1d } + .terminal-4012933681-r4 { fill: #e3e4e4 } - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - LoadingOverlayApp + LoadingOverlayApp - - - - - - - - - - - This is a big notification. - This is a big notification. - This is a big notification. - This is a big notification. - This is a big notification. - This is a big notification. - This is a big notification. - This is a big notification. - This is a big notification. - This is a big notification. - - + + + + + + + + + + + This is a big notification. + This is a big notification. + This is a big notification. + This is a big notification. + This is a big notification. + This is a big notification. + This is a big notification. + This is a big notification. + This is a big notification. + This is a big notification. + + @@ -25402,134 +27360,134 @@ font-weight: 700; } - .terminal-2569815150-matrix { + .terminal-1373062254-matrix { font-family: Fira Code, monospace; font-size: 20px; line-height: 24.4px; font-variant-east-asian: full-width; } - .terminal-2569815150-title { + .terminal-1373062254-title { font-size: 18px; font-weight: bold; font-family: arial; } - .terminal-2569815150-r1 { fill: #e1e1e1 } - .terminal-2569815150-r2 { fill: #56c278 } - .terminal-2569815150-r3 { fill: #c5c8c6 } - .terminal-2569815150-r4 { fill: #1d1d1d } - .terminal-2569815150-r5 { fill: #e3e4e4 } + .terminal-1373062254-r1 { fill: #e1e1e1 } + .terminal-1373062254-r2 { fill: #56c278 } + .terminal-1373062254-r3 { fill: #c5c8c6 } + .terminal-1373062254-r4 { fill: #1d1d1d } + .terminal-1373062254-r5 { fill: #e3e4e4 } - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - NotifyThroughModesApp + NotifyThroughModesApp - - - - This is a mode screen - 4 - - - - 5 - - - - 6 - - - - 7 - - - - 8 - - - - 9 - + + + + This is a mode screen + 4 + + + + 5 + + + + 6 + + + + 7 + + + + 8 + + + + 9 + @@ -25560,134 +27518,134 @@ font-weight: 700; } - .terminal-4257366247-matrix { + .terminal-3060613351-matrix { font-family: Fira Code, monospace; font-size: 20px; line-height: 24.4px; font-variant-east-asian: full-width; } - .terminal-4257366247-title { + .terminal-3060613351-title { font-size: 18px; font-weight: bold; font-family: arial; } - .terminal-4257366247-r1 { fill: #e1e1e1 } - .terminal-4257366247-r2 { fill: #56c278 } - .terminal-4257366247-r3 { fill: #c5c8c6 } - .terminal-4257366247-r4 { fill: #1d1d1d } - .terminal-4257366247-r5 { fill: #e3e4e4 } + .terminal-3060613351-r1 { fill: #e1e1e1 } + .terminal-3060613351-r2 { fill: #56c278 } + .terminal-3060613351-r3 { fill: #c5c8c6 } + .terminal-3060613351-r4 { fill: #1d1d1d } + .terminal-3060613351-r5 { fill: #e3e4e4 } - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - NotifyDownScreensApp + NotifyDownScreensApp - - - - Screen 10 - 4 - - - - 5 - - - - 6 - - - - 7 - - - - 8 - - - - 9 - + + + + Screen 10 + 4 + + + + 5 + + + + 6 + + + + 7 + + + + 8 + + + + 9 + @@ -25875,138 +27833,138 @@ font-weight: 700; } - .terminal-2860072847-matrix { + .terminal-43804987-matrix { font-family: Fira Code, monospace; font-size: 20px; line-height: 24.4px; font-variant-east-asian: full-width; } - .terminal-2860072847-title { + .terminal-43804987-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-43804987-r1 { fill: #1e1e1e } + .terminal-43804987-r2 { fill: #0178d4 } + .terminal-43804987-r3 { fill: #c5c8c6 } + .terminal-43804987-r4 { fill: #ddedf9;font-weight: bold } + .terminal-43804987-r5 { fill: #e2e2e2;font-weight: bold } + .terminal-43804987-r6 { fill: #e2e2e2 } + .terminal-43804987-r7 { fill: #434343 } + .terminal-43804987-r8 { fill: #f4005f } + .terminal-43804987-r9 { fill: #e1e1e1 } - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - OptionListApp + OptionListApp - + - - ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ - OneOneOne - TwoTwoTwo - ──────────────────────────────────────────────────────────────────── - ThreeThreeThree - ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ - - - - - - - - - - - - - - - - - + + ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ + OneOneOne + TwoTwoTwo + ──────────────────────────────────────────────────────────────────── + ThreeThreeThree + ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ + + + + + + + + + + + + + + + + + @@ -26037,140 +27995,140 @@ font-weight: 700; } - .terminal-371403050-matrix { + .terminal-2682133191-matrix { font-family: Fira Code, monospace; font-size: 20px; line-height: 24.4px; font-variant-east-asian: full-width; } - .terminal-371403050-title { + .terminal-2682133191-title { font-size: 18px; font-weight: bold; font-family: arial; } - .terminal-371403050-r1 { fill: #c5c8c6 } - .terminal-371403050-r2 { fill: #e3e3e3 } - .terminal-371403050-r3 { fill: #e1e1e1 } - .terminal-371403050-r4 { fill: #1e1e1e } - .terminal-371403050-r5 { fill: #0178d4 } - .terminal-371403050-r6 { fill: #ddedf9;font-weight: bold } - .terminal-371403050-r7 { fill: #e2e2e2 } - .terminal-371403050-r8 { fill: #434343 } - .terminal-371403050-r9 { fill: #787878 } - .terminal-371403050-r10 { fill: #14191f } - .terminal-371403050-r11 { fill: #ddedf9 } + .terminal-2682133191-r1 { fill: #c5c8c6 } + .terminal-2682133191-r2 { fill: #e3e3e3 } + .terminal-2682133191-r3 { fill: #e1e1e1 } + .terminal-2682133191-r4 { fill: #1e1e1e } + .terminal-2682133191-r5 { fill: #0178d4 } + .terminal-2682133191-r6 { fill: #ddedf9;font-weight: bold } + .terminal-2682133191-r7 { fill: #e2e2e2 } + .terminal-2682133191-r8 { fill: #434343 } + .terminal-2682133191-r9 { fill: #787878 } + .terminal-2682133191-r10 { fill: #14191f } + .terminal-2682133191-r11 { fill: #ddedf9 } - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - OptionListApp + OptionListApp - - - - OptionListApp - - - ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ - Aerilon - Aquaria - ─────────────────────────────────────────────────── - Canceron - Caprica - ─────────────────────────────────────────────────── - Gemenon - ─────────────────────────────────────────────────── - Leonis - Libran - ─────────────────────────────────────────────────── - Picon - ─────────────────────────────────────────────────── - Sagittaron▄▄ - Scorpia - ─────────────────────────────────────────────────── - ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ - - + + + + OptionListApp + + + ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ + Aerilon + Aquaria + ────────────────────────────────────────────────── + Canceron + Caprica + ────────────────────────────────────────────────── + Gemenon + ────────────────────────────────────────────────── + Leonis + Libran + ────────────────────────────────────────────────── + Picon▁▁ + ────────────────────────────────────────────────── + Sagittaron + Scorpia + ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ + + + @@ -26661,6 +28619,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 ''' @@ -26684,137 +28801,137 @@ font-weight: 700; } - .terminal-2341816165-matrix { + .terminal-534841697-matrix { font-family: Fira Code, monospace; font-size: 20px; line-height: 24.4px; font-variant-east-asian: full-width; } - .terminal-2341816165-title { + .terminal-534841697-title { font-size: 18px; font-weight: bold; font-family: arial; } - .terminal-2341816165-r1 { fill: #c5c8c6 } - .terminal-2341816165-r2 { fill: #e3e3e3 } - .terminal-2341816165-r3 { fill: #e1e1e1 } - .terminal-2341816165-r4 { fill: #1e1e1e } - .terminal-2341816165-r5 { fill: #0178d4 } - .terminal-2341816165-r6 { fill: #ddedf9;font-weight: bold } - .terminal-2341816165-r7 { fill: #e2e2e2 } - .terminal-2341816165-r8 { fill: #ddedf9 } + .terminal-534841697-r1 { fill: #c5c8c6 } + .terminal-534841697-r2 { fill: #e3e3e3 } + .terminal-534841697-r3 { fill: #e1e1e1 } + .terminal-534841697-r4 { fill: #1e1e1e } + .terminal-534841697-r5 { fill: #0178d4 } + .terminal-534841697-r6 { fill: #ddedf9;font-weight: bold } + .terminal-534841697-r7 { fill: #e2e2e2 } + .terminal-534841697-r8 { fill: #ddedf9 } - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - OptionListApp + OptionListApp - - - - OptionListApp - - - ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ - Aerilon - Aquaria - Canceron - Caprica - Gemenon - Leonis - Libran - Picon - Sagittaron - Scorpia - Tauron - Virgon - - - - - ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ - - + + + + OptionListApp + + + ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ + Aerilon + Aquaria + Canceron + Caprica + Gemenon + Leonis + Libran + Picon + Sagittaron + Scorpia + Tauron + Virgon + + + + ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ + + + @@ -26845,141 +28962,141 @@ font-weight: 700; } - .terminal-228828675-matrix { + .terminal-1755547624-matrix { font-family: Fira Code, monospace; font-size: 20px; line-height: 24.4px; font-variant-east-asian: full-width; } - .terminal-228828675-title { + .terminal-1755547624-title { font-size: 18px; font-weight: bold; font-family: arial; } - .terminal-228828675-r1 { fill: #c5c8c6 } - .terminal-228828675-r2 { fill: #e3e3e3 } - .terminal-228828675-r3 { fill: #e1e1e1 } - .terminal-228828675-r4 { fill: #1e1e1e } - .terminal-228828675-r5 { fill: #0178d4 } - .terminal-228828675-r6 { fill: #ddedf9;font-weight: bold;font-style: italic; } - .terminal-228828675-r7 { fill: #e2e2e2 } - .terminal-228828675-r8 { fill: #ddedf9;font-weight: bold } - .terminal-228828675-r9 { fill: #14191f } - .terminal-228828675-r10 { fill: #e2e2e2;font-style: italic; } - .terminal-228828675-r11 { fill: #e2e2e2;font-weight: bold } - .terminal-228828675-r12 { fill: #ddedf9 } + .terminal-1755547624-r1 { fill: #c5c8c6 } + .terminal-1755547624-r2 { fill: #e3e3e3 } + .terminal-1755547624-r3 { fill: #e1e1e1 } + .terminal-1755547624-r4 { fill: #1e1e1e } + .terminal-1755547624-r5 { fill: #0178d4 } + .terminal-1755547624-r6 { fill: #ddedf9;font-weight: bold;font-style: italic; } + .terminal-1755547624-r7 { fill: #e2e2e2 } + .terminal-1755547624-r8 { fill: #ddedf9;font-weight: bold } + .terminal-1755547624-r9 { fill: #14191f } + .terminal-1755547624-r10 { fill: #e2e2e2;font-style: italic; } + .terminal-1755547624-r11 { fill: #e2e2e2;font-weight: bold } + .terminal-1755547624-r12 { fill: #ddedf9 } - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - OptionListApp + OptionListApp - - - - OptionListApp - - - ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ -                  Data for Aerilon                   - ┏━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━┓ - Patron God   Population    Capital City   - ┡━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━┩▃▃ - Demeter      1.2 Billion   Gaoth          - └───────────────┴────────────────┴────────────────┘ -                  Data for Aquaria                   - ┏━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━┓ - Patron God   Population   Capital City    - ┡━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━┩ - Hermes       75,000       None            - └───────────────┴───────────────┴─────────────────┘ -                  Data for Canceron                  - ┏━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━┓ - Patron God   Population    Capital City   - ┡━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━┩ - ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ - - + + + + OptionListApp + + + ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ +                  Data for Aerilon                  + ┏━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━┓ + Patron God   Population   Capital City   + ┡━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━┩▇▇ + Demeter      1.2 Billion  Gaoth          + └───────────────┴───────────────┴────────────────┘ +                  Data for Aquaria                  + ┏━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━┓ + Patron God   Population   Capital City   + ┡━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━┩ + Hermes       75,000       None           + └───────────────┴───────────────┴────────────────┘ +                 Data for Canceron                  + ┏━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━┓ + Patron God   Population   Capital City   + ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ + + + @@ -27169,136 +29286,391 @@ font-weight: 700; } - .terminal-4133316721-matrix { + .terminal-4133316721-matrix { + font-family: Fira Code, monospace; + font-size: 20px; + line-height: 24.4px; + font-variant-east-asian: full-width; + } + + .terminal-4133316721-title { + font-size: 18px; + font-weight: bold; + font-family: arial; + } + + .terminal-4133316721-r1 { fill: #ffff00 } + .terminal-4133316721-r2 { fill: #e3e3e3 } + .terminal-4133316721-r3 { fill: #c5c8c6 } + .terminal-4133316721-r4 { fill: #ddeedd } + .terminal-4133316721-r5 { fill: #dde8f3;font-weight: bold } + .terminal-4133316721-r6 { fill: #ddedf9 } + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Layers + + + + + + + + + + ──────────────────────────────────Layers + It's full of stars! My God! It's full of sta + + This should float over the top + + + ────────────────────────────────── + + + + + + + + + + + + + + + + +  T  Toggle Screen  + + + + + ''' +# --- +# name: test_pilot_resize_terminal + ''' + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + SingleLabelApp + + + + + + + + + + 12345678901234567890 + 12345678901234567890 + 12345678901234567890 + 12345678901234567890 + 12345678901234567890 + 12345678901234567890 + 12345678901234567890 + 12345678901234567890 + 12345678901234567890 + 12345678901234567890 + + + + + ''' +# --- +# name: test_placeholder_disabled + ''' + + + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - Layers + DisabledPlaceholderApp - - - - ──────────────────────────────────Layers - It's full of stars! My God! It's full of sta - - This should float over the top - - - ────────────────────────────────── - - - - - - - - - - - - - - - - -  T  Toggle Screen  + + + + + + + + + Placeholder + + + + + + + + + + + + Placeholder + + + + + + @@ -27471,6 +29843,97 @@ ''' # --- +# name: test_pretty_grid_gutter_interaction + ''' + + + + + + + + + + + + + + + + + + + + + + + + + + + + + MyApp + + + + + + + + + + ['This is a string that has some chars'] + + This should be 1 cell away from ^ + + + + + + + + + ''' +# --- # name: test_print_capture ''' @@ -28123,136 +30586,136 @@ font-weight: 700; } - .terminal-2764447286-matrix { + .terminal-2114723073-matrix { font-family: Fira Code, monospace; font-size: 20px; line-height: 24.4px; font-variant-east-asian: full-width; } - .terminal-2764447286-title { + .terminal-2114723073-title { font-size: 18px; font-weight: bold; font-family: arial; } - .terminal-2764447286-r1 { fill: #e1e1e1 } - .terminal-2764447286-r2 { fill: #c5c8c6 } - .terminal-2764447286-r3 { fill: #fea62b } - .terminal-2764447286-r4 { fill: #323232 } - .terminal-2764447286-r5 { fill: #dde8f3;font-weight: bold } - .terminal-2764447286-r6 { fill: #ddedf9 } + .terminal-2114723073-r1 { fill: #e1e1e1 } + .terminal-2114723073-r2 { fill: #c5c8c6 } + .terminal-2114723073-r3 { fill: #fea62b } + .terminal-2114723073-r4 { fill: #323232 } + .terminal-2114723073-r5 { fill: #dde8f3;font-weight: bold } + .terminal-2114723073-r6 { fill: #ddedf9 } - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - IndeterminateProgressBar + IndeterminateProgressBar - + - - - - - - - - - - - - - ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━39%00:00:07 - - - - - - - - - - - -  S  Start  + + + + + + + + + + + + + ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━39%00:00:07 + + + + + + + + + + + +  S  Start  @@ -28282,138 +30745,138 @@ font-weight: 700; } - .terminal-3956614203-matrix { + .terminal-1351164996-matrix { font-family: Fira Code, monospace; font-size: 20px; line-height: 24.4px; font-variant-east-asian: full-width; } - .terminal-3956614203-title { + .terminal-1351164996-title { font-size: 18px; font-weight: bold; font-family: arial; } - .terminal-3956614203-r1 { fill: #e1e1e1 } - .terminal-3956614203-r2 { fill: #c5c8c6 } - .terminal-3956614203-r3 { fill: #004578 } - .terminal-3956614203-r4 { fill: #152939 } - .terminal-3956614203-r5 { fill: #1e1e1e } - .terminal-3956614203-r6 { fill: #e1e1e1;text-decoration: underline; } - .terminal-3956614203-r7 { fill: #dde8f3;font-weight: bold } - .terminal-3956614203-r8 { fill: #ddedf9 } + .terminal-1351164996-r1 { fill: #e1e1e1 } + .terminal-1351164996-r2 { fill: #c5c8c6 } + .terminal-1351164996-r3 { fill: #004578 } + .terminal-1351164996-r4 { fill: #152939 } + .terminal-1351164996-r5 { fill: #1e1e1e } + .terminal-1351164996-r6 { fill: #e1e1e1;text-decoration: underline; } + .terminal-1351164996-r7 { fill: #dde8f3;font-weight: bold } + .terminal-1351164996-r8 { fill: #ddedf9 } - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - StyledProgressBar + StyledProgressBar - + - - - - - - - - - - - - - ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━39%00:00:07 - - - - - - - - - - - -  S  Start  + + + + + + + + + + + + + ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━39%00:00:07 + + + + + + + + + + + +  S  Start  @@ -28602,145 +31065,304 @@ font-weight: 700; } - .terminal-1175498223-matrix { + .terminal-1175498223-matrix { + font-family: Fira Code, monospace; + font-size: 20px; + line-height: 24.4px; + font-variant-east-asian: full-width; + } + + .terminal-1175498223-title { + font-size: 18px; + font-weight: bold; + font-family: arial; + } + + .terminal-1175498223-r1 { fill: #e1e1e1 } + .terminal-1175498223-r2 { fill: #c5c8c6 } + .terminal-1175498223-r3 { fill: #fea62b } + .terminal-1175498223-r4 { fill: #004578 } + .terminal-1175498223-r5 { fill: #1e1e1e } + .terminal-1175498223-r6 { fill: #e1e1e1;text-decoration: underline; } + .terminal-1175498223-r7 { fill: #dde8f3;font-weight: bold } + .terminal-1175498223-r8 { fill: #ddedf9 } + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + StyledProgressBar + + + + + + + + + + + + + + + + + + + + + ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━--%--:--:-- + + + + + + + + + + + +  S  Start  + + + + + ''' +# --- +# name: test_quickly_change_tabs + ''' + + + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - StyledProgressBar + QuicklyChangeTabsApp - - - - - - - - - - - - - - - ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━--%--:--:-- - - - - - - - - - - - -  S  Start  + + + + + onetwothree + ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + three + + + + + + + + + + + + + + + + + + + ''' # --- -# name: test_quickly_change_tabs +# name: test_radio_button_example ''' @@ -28763,135 +31385,139 @@ font-weight: 700; } - .terminal-1103805314-matrix { + .terminal-2068636424-matrix { font-family: Fira Code, monospace; font-size: 20px; line-height: 24.4px; font-variant-east-asian: full-width; } - .terminal-1103805314-title { + .terminal-2068636424-title { font-size: 18px; font-weight: bold; font-family: arial; } - .terminal-1103805314-r1 { fill: #c5c8c6 } - .terminal-1103805314-r2 { fill: #e1e1e1 } - .terminal-1103805314-r3 { fill: #737373 } - .terminal-1103805314-r4 { fill: #e1e1e1;font-weight: bold } - .terminal-1103805314-r5 { fill: #474747 } - .terminal-1103805314-r6 { fill: #0178d4 } + .terminal-2068636424-r1 { fill: #e1e1e1 } + .terminal-2068636424-r2 { fill: #c5c8c6 } + .terminal-2068636424-r3 { fill: #1e1e1e } + .terminal-2068636424-r4 { fill: #0178d4 } + .terminal-2068636424-r5 { fill: #575757 } + .terminal-2068636424-r6 { fill: #262626;font-weight: bold } + .terminal-2068636424-r7 { fill: #e2e2e2 } + .terminal-2068636424-r8 { fill: #e2e2e2;text-decoration: underline; } + .terminal-2068636424-r9 { fill: #434343 } + .terminal-2068636424-r10 { fill: #4ebf71;font-weight: bold } - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - QuicklyChangeTabsApp + RadioChoicesApp - - - - - onetwothree - ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ - - three - - - - - - - - - - - - - - - - - - + + + + + + + + + + ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ + Battlestar Galactica +  Dune 1984 +  Dune 2021 +  Serenity +  Star Trek: The Motion Picture +  Star Wars: A New Hope +  The Last Starfighter +  Total Recall 👉 🔴 +  Wing Commander + ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ + + + + + + @@ -28899,7 +31525,7 @@ ''' # --- -# name: test_radio_button_example +# name: test_radio_set_example ''' @@ -28922,139 +31548,140 @@ font-weight: 700; } - .terminal-3755206349-matrix { + .terminal-2351407483-matrix { font-family: Fira Code, monospace; font-size: 20px; line-height: 24.4px; font-variant-east-asian: full-width; } - .terminal-3755206349-title { + .terminal-2351407483-title { font-size: 18px; font-weight: bold; font-family: arial; } - .terminal-3755206349-r1 { fill: #e1e1e1 } - .terminal-3755206349-r2 { fill: #c5c8c6 } - .terminal-3755206349-r3 { fill: #1e1e1e } - .terminal-3755206349-r4 { fill: #0178d4 } - .terminal-3755206349-r5 { fill: #575757 } - .terminal-3755206349-r6 { fill: #262626;font-weight: bold } - .terminal-3755206349-r7 { fill: #e2e2e2 } - .terminal-3755206349-r8 { fill: #e2e2e2;text-decoration: underline; } - .terminal-3755206349-r9 { fill: #434343 } - .terminal-3755206349-r10 { fill: #4ebf71;font-weight: bold } + .terminal-2351407483-r1 { fill: #e1e1e1 } + .terminal-2351407483-r2 { fill: #c5c8c6 } + .terminal-2351407483-r3 { fill: #1e1e1e } + .terminal-2351407483-r4 { fill: #0178d4 } + .terminal-2351407483-r5 { fill: #575757 } + .terminal-2351407483-r6 { fill: #262626;font-weight: bold } + .terminal-2351407483-r7 { fill: #e2e2e2 } + .terminal-2351407483-r8 { fill: #e2e2e2;text-decoration: underline; } + .terminal-2351407483-r9 { fill: #434343 } + .terminal-2351407483-r10 { fill: #4ebf71;font-weight: bold } + .terminal-2351407483-r11 { fill: #f4005f;font-weight: bold;font-style: italic; } - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - RadioChoicesApp + RadioChoicesApp - - - - - - - - - - ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ - Battlestar Galactica -  Dune 1984 -  Dune 2021 -  Serenity -  Star Trek: The Motion Picture -  Star Wars: A New Hope -  The Last Starfighter -  Total Recall 👉 🔴 -  Wing Commander - ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ - - - - - - + + + + + + + + + + ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ + 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 + ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ + + + + + + @@ -29062,7 +31689,7 @@ ''' # --- -# name: test_radio_set_example +# name: test_recompose ''' @@ -29085,140 +31712,135 @@ font-weight: 700; } - .terminal-3259211563-matrix { + .terminal-1786282230-matrix { font-family: Fira Code, monospace; font-size: 20px; line-height: 24.4px; font-variant-east-asian: full-width; } - .terminal-3259211563-title { + .terminal-1786282230-title { font-size: 18px; font-weight: bold; font-family: arial; } - .terminal-3259211563-r1 { fill: #e1e1e1 } - .terminal-3259211563-r2 { fill: #c5c8c6 } - .terminal-3259211563-r3 { fill: #1e1e1e } - .terminal-3259211563-r4 { fill: #0178d4 } - .terminal-3259211563-r5 { fill: #575757 } - .terminal-3259211563-r6 { fill: #262626;font-weight: bold } - .terminal-3259211563-r7 { fill: #e2e2e2 } - .terminal-3259211563-r8 { fill: #e2e2e2;text-decoration: underline; } - .terminal-3259211563-r9 { fill: #434343 } - .terminal-3259211563-r10 { fill: #4ebf71;font-weight: bold } - .terminal-3259211563-r11 { fill: #cc555a;font-weight: bold;font-style: italic; } + .terminal-1786282230-r1 { fill: #ff0000 } + .terminal-1786282230-r2 { fill: #c5c8c6 } + .terminal-1786282230-r3 { fill: #e1e1e1;font-weight: bold } + .terminal-1786282230-r4 { fill: #e1e1e1 } + .terminal-1786282230-r5 { fill: #fea62b } + .terminal-1786282230-r6 { fill: #323232 } - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - RadioChoicesApp + RecomposeApp - - - - - - - - - - ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ - Battlestar Galactica Amanda -  Dune 1984 Connor MacLeod -  Dune 2021 Duncan MacLeod -  Serenity Heather MacLeod -  Star Trek: The Motion Picture Joe Dawson -  Star Wars: A New Hope Kurgan, The -  The Last Starfighter Methos -  Total Recall 👉 🔴 Rachel Ellenstein -  Wing Commander Ramírez - ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ - - - - - - + + + + ────────────────────────────────────────────────────────────────── +  ┓ ┏━┓ ┓  ┓  ┓ ╺━┓ ┓ ╺━┓ ┓ ╻ ╻ ┓ ┏━╸ ┓ ┏━╸ +  ┃ ┃ ┃ ┃  ┃  ┃ ┏━┛ ┃  ━┫ ┃ ┗━┫ ┃ ┗━┓ ┃ ┣━┓ + ╺┻╸┗━┛╺┻╸╺┻╸╺┻╸┗━╸╺┻╸╺━┛╺┻╸  ╹╺┻╸╺━┛╺┻╸┗━┛ + ────────────────────────────────────────────────────────────────── + + + + + + + + ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━50% + + + + + + + + + + @@ -29696,6 +32318,161 @@ ''' # --- +# name: test_richlog_width + ''' + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + RichLogWidth + + + + + + + + + +               hello1 +               world2 +               hello3 +               world4 + + + + + + + + + + + + + + + + + + + + + + + + + ''' +# --- # name: test_rule_horizontal_rules ''' @@ -30512,137 +33289,137 @@ font-weight: 700; } - .terminal-1777085921-matrix { + .terminal-1340160965-matrix { font-family: Fira Code, monospace; font-size: 20px; line-height: 24.4px; font-variant-east-asian: full-width; } - .terminal-1777085921-title { + .terminal-1340160965-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-1340160965-r1 { fill: #e1e1e1 } + .terminal-1340160965-r2 { fill: #c5c8c6 } + .terminal-1340160965-r3 { fill: #004578 } + .terminal-1340160965-r4 { fill: #23568b } + .terminal-1340160965-r5 { fill: #fea62b } + .terminal-1340160965-r6 { fill: #f4005f } + .terminal-1340160965-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<<@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@ + ▇▇ + ▄▄ + + + + + + + + ──────────────────────────────────────────────────────────────────────────── @@ -31634,136 +34411,136 @@ font-weight: 700; } - .terminal-201211408-matrix { + .terminal-4010426174-matrix { font-family: Fira Code, monospace; font-size: 20px; line-height: 24.4px; font-variant-east-asian: full-width; } - .terminal-201211408-title { + .terminal-4010426174-title { font-size: 18px; font-weight: bold; font-family: arial; } - .terminal-201211408-r1 { fill: #c5c8c6 } - .terminal-201211408-r2 { fill: #e3e3e3 } - .terminal-201211408-r3 { fill: #e1e1e1 } - .terminal-201211408-r4 { fill: #1e1e1e } - .terminal-201211408-r5 { fill: #0178d4 } - .terminal-201211408-r6 { fill: #e2e2e2 } - .terminal-201211408-r7 { fill: #a8a8a8 } + .terminal-4010426174-r1 { fill: #c5c8c6 } + .terminal-4010426174-r2 { fill: #e3e3e3 } + .terminal-4010426174-r3 { fill: #e1e1e1 } + .terminal-4010426174-r4 { fill: #1e1e1e } + .terminal-4010426174-r5 { fill: #0178d4 } + .terminal-4010426174-r6 { fill: #e2e2e2 } + .terminal-4010426174-r7 { fill: #a8a8a8 } - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - SelectApp + SelectApp - - - - SelectApp - - - ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ - I must not fear. - ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ - - - - - - - - - - - - - - - - - + + + + I must not fear. + + + ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ + I must not fear. + ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ + + + + + + + + + + + + + + + + + @@ -31957,136 +34734,136 @@ font-weight: 700; } - .terminal-3070247369-matrix { + .terminal-202000048-matrix { font-family: Fira Code, monospace; font-size: 20px; line-height: 24.4px; font-variant-east-asian: full-width; } - .terminal-3070247369-title { + .terminal-202000048-title { font-size: 18px; font-weight: bold; font-family: arial; } - .terminal-3070247369-r1 { fill: #c5c8c6 } - .terminal-3070247369-r2 { fill: #e3e3e3 } - .terminal-3070247369-r3 { fill: #e1e1e1 } - .terminal-3070247369-r4 { fill: #1e1e1e } - .terminal-3070247369-r5 { fill: #0178d4 } - .terminal-3070247369-r6 { fill: #e2e2e2 } - .terminal-3070247369-r7 { fill: #a8a8a8 } + .terminal-202000048-r1 { fill: #c5c8c6 } + .terminal-202000048-r2 { fill: #e3e3e3 } + .terminal-202000048-r3 { fill: #e1e1e1 } + .terminal-202000048-r4 { fill: #1e1e1e } + .terminal-202000048-r5 { fill: #0178d4 } + .terminal-202000048-r6 { fill: #e2e2e2 } + .terminal-202000048-r7 { fill: #a8a8a8 } - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - SelectApp + SelectApp - - - - SelectApp - - - ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ - Twinkle, twinkle, little star, - ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ - - - - - - - - - - - - - - - - - + + + + Twinkle, twinkle, little star, + + + ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ + Twinkle, twinkle, little star, + ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ + + + + + + + + + + + + + + + + + @@ -32117,141 +34894,141 @@ font-weight: 700; } - .terminal-2124086361-matrix { + .terminal-1590895671-matrix { font-family: Fira Code, monospace; font-size: 20px; line-height: 24.4px; font-variant-east-asian: full-width; } - .terminal-2124086361-title { + .terminal-1590895671-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-1590895671-r1 { fill: #c5c8c6 } + .terminal-1590895671-r2 { fill: #e3e3e3 } + .terminal-1590895671-r3 { fill: #e1e1e1 } + .terminal-1590895671-r4 { fill: #0178d4 } + .terminal-1590895671-r5 { fill: #e1e1e1;font-weight: bold } + .terminal-1590895671-r6 { fill: #575757 } + .terminal-1590895671-r7 { fill: #4ebf71;font-weight: bold } + .terminal-1590895671-r8 { fill: #ddedf9;font-weight: bold } + .terminal-1590895671-r9 { fill: #98e024 } + .terminal-1590895671-r10 { fill: #262626;font-weight: bold } + .terminal-1590895671-r11 { fill: #e2e2e2 } + .terminal-1590895671-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 + + ────────────────────────────── + + + + + + + @@ -32282,139 +35059,139 @@ font-weight: 700; } - .terminal-3401996005-matrix { + .terminal-2928221602-matrix { font-family: Fira Code, monospace; font-size: 20px; line-height: 24.4px; font-variant-east-asian: full-width; } - .terminal-3401996005-title { + .terminal-2928221602-title { font-size: 18px; font-weight: bold; font-family: arial; } - .terminal-3401996005-r1 { fill: #c5c8c6 } - .terminal-3401996005-r2 { fill: #e3e3e3 } - .terminal-3401996005-r3 { fill: #e1e1e1 } - .terminal-3401996005-r4 { fill: #0178d4 } - .terminal-3401996005-r5 { fill: #575757 } - .terminal-3401996005-r6 { fill: #4ebf71;font-weight: bold } - .terminal-3401996005-r7 { fill: #ddedf9;font-weight: bold } - .terminal-3401996005-r8 { fill: #262626;font-weight: bold } - .terminal-3401996005-r9 { fill: #e2e2e2 } - .terminal-3401996005-r10 { fill: #ddedf9 } + .terminal-2928221602-r1 { fill: #c5c8c6 } + .terminal-2928221602-r2 { fill: #e3e3e3 } + .terminal-2928221602-r3 { fill: #e1e1e1 } + .terminal-2928221602-r4 { fill: #0178d4 } + .terminal-2928221602-r5 { fill: #575757 } + .terminal-2928221602-r6 { fill: #4ebf71;font-weight: bold } + .terminal-2928221602-r7 { fill: #ddedf9;font-weight: bold } + .terminal-2928221602-r8 { fill: #262626;font-weight: bold } + .terminal-2928221602-r9 { fill: #e2e2e2 } + .terminal-2928221602-r10 { fill: #ddedf9 } - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - SelectionListApp + SelectionListApp - - - - SelectionListApp - - -  Shall we play some games? ────────────────────────────────── - - XFalken's Maze - XBlack Jack - XGin Rummy - XHearts - XBridge - XCheckers - XChess - XPoker - XFighter Combat - - - - - - - ────────────────────────────────────────────────────────────── - - + + + + SelectionListApp + + +  Shall we play some games? ────────────────────────────────── + + XFalken's Maze + XBlack Jack + XGin Rummy + XHearts + XBridge + XCheckers + XChess + XPoker + XFighter Combat + + + + + + ────────────────────────────────────────────────────────────── + + + @@ -32445,139 +35222,139 @@ font-weight: 700; } - .terminal-3401996005-matrix { + .terminal-2928221602-matrix { font-family: Fira Code, monospace; font-size: 20px; line-height: 24.4px; font-variant-east-asian: full-width; } - .terminal-3401996005-title { + .terminal-2928221602-title { font-size: 18px; font-weight: bold; font-family: arial; } - .terminal-3401996005-r1 { fill: #c5c8c6 } - .terminal-3401996005-r2 { fill: #e3e3e3 } - .terminal-3401996005-r3 { fill: #e1e1e1 } - .terminal-3401996005-r4 { fill: #0178d4 } - .terminal-3401996005-r5 { fill: #575757 } - .terminal-3401996005-r6 { fill: #4ebf71;font-weight: bold } - .terminal-3401996005-r7 { fill: #ddedf9;font-weight: bold } - .terminal-3401996005-r8 { fill: #262626;font-weight: bold } - .terminal-3401996005-r9 { fill: #e2e2e2 } - .terminal-3401996005-r10 { fill: #ddedf9 } + .terminal-2928221602-r1 { fill: #c5c8c6 } + .terminal-2928221602-r2 { fill: #e3e3e3 } + .terminal-2928221602-r3 { fill: #e1e1e1 } + .terminal-2928221602-r4 { fill: #0178d4 } + .terminal-2928221602-r5 { fill: #575757 } + .terminal-2928221602-r6 { fill: #4ebf71;font-weight: bold } + .terminal-2928221602-r7 { fill: #ddedf9;font-weight: bold } + .terminal-2928221602-r8 { fill: #262626;font-weight: bold } + .terminal-2928221602-r9 { fill: #e2e2e2 } + .terminal-2928221602-r10 { fill: #ddedf9 } - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - SelectionListApp + SelectionListApp - - - - SelectionListApp - - -  Shall we play some games? ────────────────────────────────── - - XFalken's Maze - XBlack Jack - XGin Rummy - XHearts - XBridge - XCheckers - XChess - XPoker - XFighter Combat - - - - - - - ────────────────────────────────────────────────────────────── - - + + + + SelectionListApp + + +  Shall we play some games? ────────────────────────────────── + + XFalken's Maze + XBlack Jack + XGin Rummy + XHearts + XBridge + XCheckers + XChess + XPoker + XFighter Combat + + + + + + ────────────────────────────────────────────────────────────── + + + @@ -32585,6 +35362,166 @@ ''' # --- +# name: test_sort_children + ''' + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + SortApp + + + + + + + + + + ────────────────────────────────────────────────────────────────────────── + 515 + ───────────────────────── + ───────────────────────── + 2 + + ────────────────────────────────────────────────────────────────────────── + ────────────────────────────────────────────────────────────────────────── + 134 + ──────────────────────── + ──────────────────────── + 3───────────────────────── + ────────────────────────────────────────────────── + 4───────────────────────── + ────────────────────────3 + ──────────────────────── + 2 + ────────────────────────────────────────────────── + ────────────────────────────────────────────────────────────────────────── + ────────────────────────52 + 4 + ───────────────────────── + ───────────────────────── + 1 + ────────────────────────────────────────────────────────────────────────── + + + + + ''' +# --- # name: test_sparkline_component_classes_colors ''' @@ -34015,6 +36952,169 @@ ''' # --- +# name: test_tabbed_content_styling_not_leaking + ''' + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + TabbedContentStyleLeakTestApp + + + + + + + + + + + Leak Test + ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + This label should come first + ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ + This button should come second + ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ + + TheseTabsShouldComeLast + ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + + + + + + + + + + + + + + + + + ''' +# --- # name: test_tabbed_content_with_modified_tabs ''' @@ -34202,136 +37302,136 @@ font-weight: 700; } - .terminal-2727430444-matrix { + .terminal-2580112047-matrix { font-family: Fira Code, monospace; font-size: 20px; line-height: 24.4px; font-variant-east-asian: full-width; } - .terminal-2727430444-title { + .terminal-2580112047-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-2580112047-r1 { fill: #e1e1e1 } + .terminal-2580112047-r2 { fill: #c5c8c6 } + .terminal-2580112047-r3 { fill: #e1e1e1;font-weight: bold } + .terminal-2580112047-r4 { fill: #98e024;font-weight: bold;font-style: italic; } + .terminal-2580112047-r5 { fill: #f4005f;font-weight: bold } + .terminal-2580112047-r6 { fill: #e1e1e1;font-style: italic; } + .terminal-2580112047-r7 { fill: #e1e1e1;text-decoration: underline; } - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - TableStaticApp + TableStaticApp - + - - ┏━━━━━━━━━━━━━━┳━━━━━━━━┳━━━━━━━━━━━┓ - FooBar   baz       - ┡━━━━━━━━━━━━━━╇━━━━━━━━╇━━━━━━━━━━━┩ - Hello World!ItalicUnderline - └──────────────┴────────┴───────────┘ - - - - - - - - - - - - - - - - - - + + ┏━━━━━━━━━━━━━━┳━━━━━━━━┳━━━━━━━━━━━┓ + FooBar   baz       + ┡━━━━━━━━━━━━━━╇━━━━━━━━╇━━━━━━━━━━━┩ + Hello World!ItalicUnderline + └──────────────┴────────┴───────────┘ + + + + + + + + + + + + + + + + + + @@ -34499,9 +37599,112 @@ ''' # --- +# name: test_text_area_alternate_screen + ''' + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + TABug + + + + + + + + + + ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ + foo                                          + bar  + baz  + + + + + + ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ + + + + + ''' +# --- # name: test_text_area_language_rendering[css] ''' - + - - + + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + + + + + + + - TextAreaSnapshot - - - - - - - - - -  1  /* This is a comment in CSS */ -  2   -  3  /* Basic selectors and properties */ -  4  body {                                 -  5      font-family: Arial, sans-serif;    -  6      background-color: #f4f4f4;         -  7      margin: 0;                         -  8      padding: 0;                        -  9  }                                      - 10   - 11  /* Class and ID selectors */ - 12  .header {                              - 13      background-color: #333;            - 14      color: #fff;                       - 15      padding: 10px0;                   - 16      text-align: center;                - 17  }                                      - 18   - 19  #logo {                                - 20      font-size: 24px;                   - 21      font-weight: bold;                 - 22  }                                      - 23   - 24  /* Descendant and child selectors */ - 25  .nav ul {                              - 26      list-style-type: none;             - 27      padding: 0;                        - 28  }                                      - 29   - 30  .nav > li {                            - 31      display: inline-block;             - 32      margin-right: 10px;                - 33  }                                      - 34   - 35  /* Pseudo-classes */ - 36  a:hover {                              - 37      text-decoration: underline;        - 38  }                                      - 39   - 40  input:focus {                          - 41      border-color: #007BFF;             - 42  }                                      - 43   - 44  /* Media query */ - 45  @media (max-width: 768px) {            - 46      body {                             - 47          font-size: 16px;               - 48      }                                  - 49   - 50      .header {                          - 51          padding: 5px0;                - 52      }                                  - 53  }                                      - 54   - 55  /* Keyframes animation */ - 56  @keyframes slideIn {                   - 57  from {                             - 58          transform: translateX(-100%);  - 59      }                                  - 60  to {                               - 61          transform: translateX(0);      - 62      }                                  - 63  }                                      - 64   - 65  .slide-in-element {                    - 66      animation: slideIn 0.5s forwards;  - 67  }                                      - 68   - + TextAreaSnapshot + + + + + + + + + + ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ +  1  /* This is a comment in CSS */ +  2   +  3  /* Basic selectors and properties */ +  4  body {  +  5      font-family: Arial, sans-serif;  +  6      background-color: #f4f4f4 +  7      margin: 0 +  8      padding: 0 +  9   + 10   + 11  /* Class and ID selectors */ + 12  .header {  + 13      background-color: #333 + 14      color: #fff + 15      padding: 10px0 + 16      text-align: center;  + 17   + 18   + 19  #logo {  + 20      font-size: 24px + 21      font-weight: bold;  + 22   + 23   + 24  /* Descendant and child selectors */ + 25  .nav ul {  + 26      list-style-type: none;  + 27      padding: 0 + 28   + 29   + 30  .nav > li {  + 31      display: inline-block;  + 32      margin-right: 10px + 33   + 34   + 35  /* Pseudo-classes */ + 36  a:hover {  + 37      text-decoration: underline;  + 38   + 39   + 40  input:focus {  + 41      border-color: #007BFF + 42   + 43   + 44  /* Media query */ + 45  @media (max-width: 768px) {  + 46      body {  + 47          font-size: 16px + 48      }  + 49   + 50      .header {  + 51          padding: 5px0 + 52      }  + 53   + 54   + 55  /* Keyframes animation */ + 56  @keyframes slideIn {  + 57  from {  + 58          transform: translateX(-100%);  + 59      }  + 60  to {  + 61          transform: translateX(0);  + 62      }  + 63   + 64   + 65  .slide-in-element {  + 66      animation: slideIn 0.5s forwards;  + 67   + 68   + + ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ @@ -34844,7 +38057,7 @@ # --- # name: test_text_area_language_rendering[html] ''' - + - - + + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + + + + + + + - TextAreaSnapshot - - - - - - - - - -  1  <!DOCTYPE html>                                                              -  2  <html lang="en">                                                            -  3   -  4  <head>                                                                      -  5  <!-- Meta tags --> -  6      <meta charset="UTF-8">                                                  -  7      <meta name="viewport" content="width=device-width, initial-scale=1.0" -  8  <!-- Title --> -  9      <title>HTML Test Page</title>                                           - 10  <!-- Link to CSS --> - 11      <link rel="stylesheet" href="styles.css">                               - 12  </head>                                                                     - 13   - 14  <body>                                                                      - 15  <!-- Header section --> - 16      <header class="header">                                                 - 17          <h1 id="logo">HTML Test Page</h1>                                   - 18      </header>                                                               - 19   - 20  <!-- Navigation --> - 21      <nav class="nav">                                                       - 22          <ul>                                                                - 23              <li><a href="#">Home</a></li>                                   - 24              <li><a href="#">About</a></li>                                  - 25              <li><a href="#">Contact</a></li>                                - 26          </ul>                                                               - 27      </nav>                                                                  - 28   - 29  <!-- Main content area --> - 30      <main>                                                                  - 31          <article>                                                           - 32              <h2>Welcome to the Test Page</h2>                               - 33              <p>This is a paragraph to test the HTML structure.</p>          - 34              <img src="test-image.jpg" alt="Test Image" width="300">         - 35          </article>                                                          - 36      </main>                                                                 - 37   - 38  <!-- Form --> - 39      <section>                                                               - 40          <form action="/submit" method="post">                               - 41              <label for="name">Name:</label>                                 - 42              <input type="text" id="name" name="name">                       - 43              <input type="submit" value="Submit">                            - 44          </form>                                                             - 45      </section>                                                              - 46   - 47  <!-- Footer --> - 48      <footer>                                                                - 49          <p>&copy; 2023 HTML Test Page</p>                                   - 50      </footer>                                                               - 51   - 52  <!-- Script tag --> - 53      <script src="scripts.js"></script>                                      - 54  </body>                                                                     - 55   - 56  </html>                                                                     - 57   - + TextAreaSnapshot + + + + + + + + + + ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ +  1  <!DOCTYPE html>                                                          +  2  <html lang="en" +  3   +  4  <head +  5  <!-- Meta tags --> +  6      <meta charset="UTF-8" +  7      <meta name="viewport" content="width=device-width, initial-scale=1.0 +  8  <!-- Title --> +  9      <title>HTML Test Page</title + 10  <!-- Link to CSS --> + 11      <link rel="stylesheet" href="styles.css" + 12  </head + 13   + 14  <body + 15  <!-- Header section --> + 16      <header class="header" + 17          <h1 id="logo">HTML Test Page</h1 + 18      </header + 19   + 20  <!-- Navigation --> + 21      <nav class="nav" + 22          <ul + 23              <li><a href="#">Home</a></li + 24              <li><a href="#">About</a></li + 25              <li><a href="#">Contact</a></li + 26          </ul + 27      </nav + 28   + 29  <!-- Main content area --> + 30      <main + 31          <article + 32              <h2>Welcome to the Test Page</h2 + 33              <p>This is a paragraph to test the HTML structure.</p + 34              <img src="test-image.jpg" alt="Test Image" width="300" + 35          </article + 36      </main + 37   + 38  <!-- Form --> + 39      <section + 40          <form action="/submit" method="post" + 41              <label for="name">Name:</label + 42              <input type="text" id="name" name="name" + 43              <input type="submit" value="Submit" + 44          </form + 45      </section + 46   + 47  <!-- Footer --> + 48      <footer + 49          <p>&copy; 2023 HTML Test Page</p + 50      </footer + 51   + 52  <!-- Script tag --> + 53      <script src="scripts.js"></script + 54  </body + 55   + 56  </html + 57   + + ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ @@ -35141,7 +38365,7 @@ # --- # name: test_text_area_language_rendering[json] ''' - + - - + + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + + + + + + + - TextAreaSnapshot - - - - - - - - - -  1  { -  2  "name""John Doe",                            -  3  "age"30,                                     -  4  "isStudent"false,                            -  5  "address": {                                   -  6  "street""123 Main St",                   -  7  "city""Anytown",                         -  8  "state""CA",                             -  9  "zip""12345" - 10      },                                             - 11  "phoneNumbers": [                              - 12          {                                          - 13  "type""home",                        - 14  "number""555-555-1234" - 15          },                                         - 16          {                                          - 17  "type""work",                        - 18  "number""555-555-5678" - 19          }                                          - 20      ],                                             - 21  "hobbies": ["reading""hiking""swimming"],  - 22  "pets": [                                      - 23          {                                          - 24  "type""dog",                         - 25  "name""Fido" - 26          },                                         - 27      ],                                             - 28  "graduationYear"null - 29  } - 30   - 31   - + TextAreaSnapshot + + + + + + + + + + ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ +  1  { +  2  "name""John Doe" +  3  "age"30 +  4  "isStudent"false +  5  "address": {  +  6  "street""123 Main St" +  7  "city""Anytown" +  8  "state""CA" +  9  "zip""12345" + 10      },  + 11  "phoneNumbers": [  + 12          {  + 13  "type""home" + 14  "number""555-555-1234" + 15          },  + 16          {  + 17  "type""work" + 18  "number""555-555-5678" + 19          }  + 20      ],  + 21  "hobbies": ["reading""hiking""swimming"],  + 22  "pets": [  + 23          {  + 24  "type""dog" + 25  "name""Fido" + 26          },  + 27      ],  + 28  "graduationYear"null + 29  } + 30   + 31   + + ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ @@ -35336,7 +38570,362 @@ # --- # name: test_text_area_language_rendering[markdown] ''' - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + TextAreaSnapshot + + + + + + + + + + ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ +  1  Heading +  2  =======  +  3   +  4  Sub-heading +  5  -----------  +  6   +  7  ### Heading +  8   +  9  #### H4 Heading + 10   + 11  ##### H5 Heading + 12   + 13  ###### H6 Heading + 14   + 15   + 16  Paragraphs are separated  + 17  by a blank line.  + 18   + 19  Two spaces at the end of a line    + 20  produces a line break.  + 21   + 22  Text attributes _italic_,   + 23  **bold**`monospace` + 24   + 25  Horizontal rule:  + 26   + 27  ---  + 28   + 29  Bullet list:  + 30   + 31  * apples  + 32  * oranges  + 33  * pears  + 34   + 35  Numbered list:  + 36   + 37  1. lather  + 38  2. rinse  + 39  3. repeat  + 40   + 41  An [example](http://example.com) + 42   + 43  > Markdown uses email-style > characters for blockquoting.  + 44   + 45  > Lorem ipsum  + 46   + 47  ![progress](https://github.com/textualize/rich/raw/master/imgs/progress. + 48   + 49   + 50  ```  + 51  a=1  + 52  ```  + 53   + 54  ```python  + 55  import this  + 56  ```  + 57   + 58  ```somelang  + 59  foobar  + 60  ```  + 61   + 62      import this  + 63   + 64   + 65  1. List item  + 66   + 67         Code block  + 68   + + ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ + + + + + ''' +# --- +# name: test_text_area_language_rendering[python] + ''' + - - + + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - TextAreaSnapshot - - - - - - - - - -  1  Heading -  2  =======                                                                      -  3   -  4  Sub-heading -  5  -----------                                                                  -  6   -  7  ### Heading -  8   -  9  #### H4 Heading - 10   - 11  ##### H5 Heading - 12   - 13  ###### H6 Heading - 14   - 15   - 16  Paragraphs are separated                                                     - 17  by a blank line.                                                             - 18   - 19  Two spaces at the end of a line                                              - 20  produces a line break.                                                       - 21   - 22  Text attributes _italic_,                                                    - 23  **bold**`monospace`.                                                       - 24   - 25  Horizontal rule:                                                             - 26   - 27  ---                                                                          - 28   - 29  Bullet list:                                                                 - 30   - 31  * apples                                                                   - 32  * oranges                                                                  - 33  * pears                                                                    - 34   - 35  Numbered list:                                                               - 36   - 37  1. lather                                                                  - 38  2. rinse                                                                   - 39  3. repeat                                                                  - 40   - 41  An [example](http://example.com).                                            - 42   - 43  > Markdown uses email-style > characters for blockquoting.                   - 44  >                                                                            - 45  > Lorem ipsum                                                                - 46   - 47  ![progress](https://github.com/textualize/rich/raw/master/imgs/progress.gif) - 48   - 49   - 50  ```                                                                          - 51  a=1                                                                          - 52  ```                                                                          - 53   - 54  ```python                                                                    - 55  import this                                                                  - 56  ```                                                                          - 57   - 58  ```somelang                                                                  - 59  foobar                                                                       - 60  ```                                                                          - 61   - 62      import this                                                              - 63   - 64   - 65  1. List item                                                                 - 66   - 67         Code block                                                            - 68   - + TextAreaSnapshot + + + + + + + + + + ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ +  1  import math                                                              +  2  from os import path  +  3   +  4  # I'm a comment :) +  5   +  6  string_var ="Hello, world!" +  7  int_var =42 +  8  float_var =3.14 +  9  complex_var =1+2j + 10   + 11  list_var = [12345 + 12  tuple_var = (12345 + 13  set_var = {12345 + 14  dict_var = {"a"1"b"2"c"3 + 15   + 16  deffunction_no_args():  + 17  return"No arguments" + 18   + 19  deffunction_with_args(a, b):  + 20  return a + b  + 21   + 22  deffunction_with_default_args(a=0, b=0):  + 23  return a * b  + 24   + 25  lambda_func =lambda x: x**2 + 26   + 27  if int_var ==42 + 28  print("It's the answer!" + 29  elif int_var <42 + 30  print("Less than the answer." + 31  else + 32  print("Greater than the answer." + 33   + 34  for index, value inenumerate(list_var):  + 35  print(f"Index: {index}, Value: {value}" + 36   + 37  counter =0 + 38  while counter <5 + 39  print(f"Counter value: {counter}" + 40      counter +=1 + 41   + 42  squared_numbers = [x**2for x inrange(10if x %2==0 + 43   + 44  try + 45      result =10/0 + 46  except ZeroDivisionError:  + 47  print("Cannot divide by zero!" + 48  finally + 49  print("End of try-except block." + 50   + 51  classAnimal + 52  def__init__(self, name):  + 53          self.name = name  + 54   + 55  defspeak(self):  + 56  raiseNotImplementedError("Subclasses must implement this method + 57   + 58  classDog(Animal):  + 59  defspeak(self):  + 60  returnf"{self.name} says Woof!" + 61   + 62  deffibonacci(n):  + 63      a, b =01 + 64  for _ inrange(n):  + 65  yield a  + 66          a, b = b, a + b  + 67   + 68  for num infibonacci(5):  + 69  print(num)  + 70   + 71  withopen('test.txt''w'as f:  + 72      f.write("Testing with statement." + 73   + 74  @my_decorator  + 75  defsay_hello():  + 76  print("Hello!" + 77   + 78  say_hello()  + 79   + + ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ ''' # --- -# name: test_text_area_language_rendering[python] +# name: test_text_area_language_rendering[regex] ''' - + - - + + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - TextAreaSnapshot - - - - - - - - - -  1  import math                                                                  -  2  from os import path                                                          -  3   -  4  # I'm a comment :) -  5   -  6  string_var ="Hello, world!" -  7  int_var =42 -  8  float_var =3.14 -  9  complex_var =1+2j - 10   - 11  list_var = [12345]                                                   - 12  tuple_var = (12345)                                                  - 13  set_var = {12345}                                                    - 14  dict_var = {"a"1"b"2"c"3}                                          - 15   - 16  deffunction_no_args():                                                      - 17  return"No arguments" - 18   - 19  deffunction_with_args(a, b):                                                - 20  return a + b                                                             - 21   - 22  deffunction_with_default_args(a=0, b=0):                                    - 23  return a * b                                                             - 24   - 25  lambda_func =lambda x: x**2 - 26   - 27  if int_var ==42:                                                            - 28  print("It's the answer!")                                                - 29  elif int_var <42:                                                           - 30  print("Less than the answer.")                                           - 31  else:                                                                        - 32  print("Greater than the answer.")                                        - 33   - 34  for index, value inenumerate(list_var):                                     - 35  print(f"Index: {index}, Value: {value}")                                 - 36   - 37  counter =0 - 38  while counter <5:                                                           - 39  print(f"Counter value: {counter}")                                       - 40      counter +=1 - 41   - 42  squared_numbers = [x**2for x inrange(10if x %2==0]                    - 43   - 44  try:                                                                         - 45      result =10/0 - 46  except ZeroDivisionError:                                                    - 47  print("Cannot divide by zero!")                                          - 48  finally:                                                                     - 49  print("End of try-except block.")                                        - 50   - 51  classAnimal:                                                                - 52  def__init__(self, name):                                                - 53          self.name = name                                                     - 54   - 55  defspeak(self):                                                         - 56  raiseNotImplementedError("Subclasses must implement this method." - 57   - 58  classDog(Animal):                                                           - 59  defspeak(self):                                                         - 60  returnf"{self.name} says Woof!" - 61   - 62  deffibonacci(n):                                                            - 63      a, b =01 - 64  for _ inrange(n):                                                       - 65  yield a                                                              - 66          a, b = b, a + b                                                      - 67   - 68  for num infibonacci(5):                                                     - 69  print(num)                                                               - 70   - 71  withopen('test.txt''w'as f:                                             - 72      f.write("Testing with statement.")                                       - 73   - 74  @my_decorator                                                                - 75  defsay_hello():                                                             - 76  print("Hello!")                                                          - 77   - 78  say_hello()                                                                  - 79   - + TextAreaSnapshot + + + + + + + + + + ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ +  1  ^abc            # Matches any string that starts with "abc"              +  2  abc$            # Matches any string that ends with "abc"  +  3  ^abc$           # Matches the string "abc" and nothing else  +  4  a.b             # Matches any string containing "a", any character, then +  5  a[.]b           # Matches the string "a.b"  +  6  a|b             # Matches either "a" or "b"  +  7  a{2}            # Matches "aa"  +  8  a{2,}           # Matches two or more consecutive "a" characters  +  9  a{2,5}          # Matches between 2 and 5 consecutive "a" characters  + 10  a?              # Matches "a" or nothing (0 or 1 occurrence of "a") + 11  a*              # Matches zero or more consecutive "a" characters  + 12  a+              # Matches one or more consecutive "a" characters  + 13  \d              # Matches any digit (equivalent to [0-9]) + 14  \D              # Matches any non-digit  + 15  \w              # Matches any word character (equivalent to [a-zA-Z0-9_] + 16  \W              # Matches any non-word character  + 17  \s              # Matches any whitespace character (spaces, tabs, line b + 18  \S              # Matches any non-whitespace character  + 19  (?i)abc         # Case-insensitive match for "abc"  + 20  (?:a|b)         # Non-capturing group for either "a" or "b"  + 21  (?<=a)b         # Positive lookbehind: matches "b" that is preceded by " + 22  (?<!a)b         # Negative lookbehind: matches "b" that is not preceded  + 23  a(?=b)          # Positive lookahead: matches "a" that is followed by "b + 24  a(?!b)          # Negative lookahead: matches "a" that is not followed b + 25   + + ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ ''' # --- -# name: test_text_area_language_rendering[regex] +# name: test_text_area_language_rendering[sql] ''' - + - - + + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - TextAreaSnapshot + TextAreaSnapshot - - - -  1  ^abc            # Matches any string that starts with "abc"                  -  2  abc$            # Matches any string that ends with "abc"                    -  3  ^abc$           # Matches the string "abc" and nothing else                  -  4  a.b             # Matches any string containing "a", any character, then "b" -  5  a[.]b           # Matches the string "a.b"                                   -  6  a|b             # Matches either "a" or "b"                                  -  7  a{2}            # Matches "aa"                                               -  8  a{2,}           # Matches two or more consecutive "a" characters             -  9  a{2,5}          # Matches between 2 and 5 consecutive "a" characters         - 10  a?              # Matches "a" or nothing (0 or 1 occurrence of "a") - 11  a*              # Matches zero or more consecutive "a" characters            - 12  a+              # Matches one or more consecutive "a" characters             - 13  \d              # Matches any digit (equivalent to [0-9]) - 14  \D              # Matches any non-digit                                      - 15  \w              # Matches any word character (equivalent to [a-zA-Z0-9_]) - 16  \W              # Matches any non-word character                             - 17  \s              # Matches any whitespace character (spaces, tabs, line break - 18  \S              # Matches any non-whitespace character                       - 19  (?i)abc         # Case-insensitive match for "abc"                           - 20  (?:a|b)         # Non-capturing group for either "a" or "b"                  - 21  (?<=a)b         # Positive lookbehind: matches "b" that is preceded by "a"   - 22  (?<!a)b         # Negative lookbehind: matches "b" that is not preceded by " - 23  a(?=b)          # Positive lookahead: matches "a" that is followed by "b"    - 24  a(?!b)          # Negative lookahead: matches "a" that is not followed by "b - 25   - + + + + ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ +  1  -- This is a comment in SQL +  2   +  3  -- Create tables +  4  CREATETABLE Authors (  +  5      AuthorID INT PRIMARY KEY +  6      Name VARCHAR(255NOT NULL +  7      Country VARCHAR(50 +  8  );  +  9   + 10  CREATETABLE Books (  + 11      BookID INT PRIMARY KEY + 12      Title VARCHAR(255NOT NULL + 13      AuthorID INT,  + 14      PublishedDate DATE,  + 15      FOREIGN KEY (AuthorID) REFERENCES Authors(AuthorID)  + 16  );  + 17   + 18  -- Insert data + 19  INSERTINTO Authors (AuthorID, Name, Country) VALUES (1'George Orwell' + 20   + 21  INSERTINTO Books (BookID, Title, AuthorID, PublishedDate) VALUES (1'1 + 22   + 23  -- Update data + 24  UPDATE Authors SET Country ='United Kingdom'WHERE Country ='UK' + 25   + 26  -- Select data with JOIN + 27  SELECT Books.Title, Authors.Name   + 28  FROM Books   + 29  JOIN Authors ON Books.AuthorID = Authors.AuthorID;  + 30   + 31  -- Delete data (commented to preserve data for other examples) + 32  -- DELETE FROM Books WHERE BookID = 1; + 33   + 34  -- Alter table structure + 35  ALTER TABLE Authors ADD COLUMN BirthDate DATE;  + 36   + 37  -- Create index + 38  CREATEINDEX idx_author_name ON Authors(Name);  + 39   + 40  -- Drop index (commented to avoid actually dropping it) + 41  -- DROP INDEX idx_author_name ON Authors; + 42   + 43  -- End of script + 44   + + ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ ''' # --- -# name: test_text_area_language_rendering[sql] +# name: test_text_area_language_rendering[toml] ''' - + - - + + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - TextAreaSnapshot - - - - - - - - - -  1  -- This is a comment in SQL -  2   -  3  -- Create tables -  4  CREATETABLE Authors (                                                       -  5      AuthorID INT PRIMARY KEY,                                                -  6      Name VARCHAR(255NOT NULL,                                              -  7      Country VARCHAR(50)                                                      -  8  );                                                                           -  9   - 10  CREATETABLE Books (                                                         - 11      BookID INT PRIMARY KEY,                                                  - 12      Title VARCHAR(255NOT NULL,                                             - 13      AuthorID INT,                                                            - 14      PublishedDate DATE,                                                      - 15      FOREIGN KEY (AuthorID) REFERENCES Authors(AuthorID)                      - 16  );                                                                           - 17   - 18  -- Insert data - 19  INSERTINTO Authors (AuthorID, Name, Country) VALUES (1'George Orwell''U - 20   - 21  INSERTINTO Books (BookID, Title, AuthorID, PublishedDate) VALUES (1'1984' - 22   - 23  -- Update data - 24  UPDATE Authors SET Country ='United Kingdom'WHERE Country ='UK';          - 25   - 26  -- Select data with JOIN - 27  SELECT Books.Title, Authors.Name                                             - 28  FROM Books                                                                   - 29  JOIN Authors ON Books.AuthorID = Authors.AuthorID;                           - 30   - 31  -- Delete data (commented to preserve data for other examples) - 32  -- DELETE FROM Books WHERE BookID = 1; - 33   - 34  -- Alter table structure - 35  ALTER TABLE Authors ADD COLUMN BirthDate DATE;                               - 36   - 37  -- Create index - 38  CREATEINDEX idx_author_name ON Authors(Name);                               - 39   - 40  -- Drop index (commented to avoid actually dropping it) - 41  -- DROP INDEX idx_author_name ON Authors; - 42   - 43  -- End of script - 44   - + TextAreaSnapshot + + + + + + + + + + ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ +  1  # This is a comment in TOML +  2   +  3  string = "Hello, world!" +  4  integer = 42 +  5  float = 3.14 +  6  boolean = true +  7  datetime = 1979-05-27T07:32:00Z +  8   +  9  fruits = ["apple""banana""cherry" + 10   + 11  [address + 12  street = "123 Main St" + 13  city = "Anytown" + 14  state = "CA" + 15  zip = "12345" + 16   + 17  [person.john + 18  name = "John Doe" + 19  age = 28 + 20  is_student = false + 21   + 22   + 23  [[animals]]  + 24  name = "Fido" + 25  type = "dog" + 26   + + ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ ''' # --- -# name: test_text_area_language_rendering[toml] +# name: test_text_area_language_rendering[yaml] ''' - + - - + + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - TextAreaSnapshot + TextAreaSnapshot - - - -  1  # This is a comment in TOML -  2   -  3  string = "Hello, world!" -  4  integer = 42 -  5  float = 3.14 -  6  boolean = true -  7  datetime = 1979-05-27T07:32:00Z -  8   -  9  fruits = ["apple""banana""cherry" - 10   - 11  [address]                               - 12  street = "123 Main St" - 13  city = "Anytown" - 14  state = "CA" - 15  zip = "12345" - 16   - 17  [person.john]                           - 18  name = "John Doe" - 19  age = 28 - 20  is_student = false - 21   - 22   - 23  [[animals]]                             - 24  name = "Fido" - 25  type = "dog" - 26   - + + + + ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ +  1  # This is a comment in YAML +  2   +  3  # Scalars +  4  string"Hello, world!" +  5  integer42 +  6  float3.14 +  7  booleantrue +  8   +  9  # Sequences (Arrays) + 10  fruits + 11    - Apple + 12    - Banana + 13    - Cherry + 14   + 15  # Nested sequences + 16  persons + 17    - nameJohn + 18  age28 + 19  is_studentfalse + 20    - nameJane + 21  age22 + 22  is_studenttrue + 23   + 24  # Mappings (Dictionaries) + 25  address + 26  street123 Main St + 27  cityAnytown + 28  stateCA + 29  zip'12345' + 30   + 31  # Multiline string + 32  description + 33    This is a multiline  + 34    string in YAML. + 35   + 36  # Inline and nested collections + 37  colors: { redFF0000green00FF00blue0000FF }  + 38   + + ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ ''' # --- -# name: test_text_area_language_rendering[yaml] +# name: test_text_area_read_only_cursor_rendering ''' - + - - + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + - - + + - - + + - - + + - TextAreaSnapshot - - - - - - - - - -  1  # This is a comment in YAML -  2   -  3  # Scalars -  4  string"Hello, world!" -  5  integer42 -  6  float3.14 -  7  booleantrue -  8   -  9  # Sequences (Arrays) - 10  fruits:                                               - 11    - Apple - 12    - Banana - 13    - Cherry - 14   - 15  # Nested sequences - 16  persons:                                              - 17    - nameJohn - 18  age28 - 19  is_studentfalse - 20    - nameJane - 21  age22 - 22  is_studenttrue - 23   - 24  # Mappings (Dictionaries) - 25  address:                                              - 26  street123 Main St - 27  cityAnytown - 28  stateCA - 29  zip'12345' - 30   - 31  # Multiline string - 32  description|                                        - 33    This is a multiline                                 - 34    string in YAML. - 35   - 36  # Inline and nested collections - 37  colors: { redFF0000green00FF00blue0000FF }  - 38   - + TextAreaSnapshot + + + + + + + + + + ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ + 1  Hello, world!           + + + ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ @@ -36880,7 +40259,7 @@ # --- # name: test_text_area_selection_rendering[selection0] ''' - + - - + + - + - + - + - + + + + + + + + + + - TextAreaSnapshot + TextAreaSnapshot - - - - I am a line. - ▌                     - I am another line.             - - I am the final line.  + + + + ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ + I am a line. + + I am another line.         + + I am the final line.  + + ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ @@ -36961,7 +40354,7 @@ # --- # name: test_text_area_selection_rendering[selection1] ''' - + - - + + - + - + - + - + + + + + + + + + + - TextAreaSnapshot + TextAreaSnapshot - - - - I am a line. - ▌                     - I am another line.    - - I am the final line.  + + + + ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ + I am a line. + + I am another line.  + + I am the final line.  + + ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ @@ -37042,7 +40449,7 @@ # --- # name: test_text_area_selection_rendering[selection2] ''' - + - - + + - + - + - + - + + + + + + + + + + - TextAreaSnapshot + TextAreaSnapshot - - - - I am a line. - ▌                     - I am another line. - ▌                     - I am the final line.  + + + + ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ + I am a line. + + I am another line. + + I am the final line.  + + ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ @@ -37123,7 +40544,7 @@ # --- # name: test_text_area_selection_rendering[selection3] ''' - + - - + + - + - + - + - + + + + + + + + + + - TextAreaSnapshot + TextAreaSnapshot - - - - I am a line. - ▌                     - I am another line. - ▌                     - I am the final line. + + + + ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ + I am a line. + + I am another line. + + I am the final line. + + ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ @@ -37204,7 +40639,7 @@ # --- # name: test_text_area_selection_rendering[selection4] ''' - + - - + + - + - + - + - + + + + + + + + + + - TextAreaSnapshot + TextAreaSnapshot - - - - I am a line.          - - I am another line.    - - I am the final line.  + + + + ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ + I am a line.  + + I am another line.  + + I am the final line.  + + ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ @@ -37284,7 +40733,7 @@ # --- # name: test_text_area_selection_rendering[selection5] ''' - + - - + + - + - + - + - + + + + + + + + + + - TextAreaSnapshot + TextAreaSnapshot - - - - I am a line.          - - I am another line.             - - I am the final line.  + + + + ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ + I am a line.  + + I am another line.         + + I am the final line.  + + ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ + + + + + ''' +# --- +# name: test_text_area_themes[css] + ''' + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + TextAreaSnapshot + + + + + + + + + + ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ + 1  defhello(name): + 2      x =123 + 3  whilenotFalse + 4  print("hello "+ name)  + 5  continue + 6   + + ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ @@ -37364,7 +40932,7 @@ # --- # name: test_text_area_themes[dracula] ''' - + - - + + - + - + - + - + - + - + + + + + + + - TextAreaSnapshot + TextAreaSnapshot - - - - 1  defhello(name): - 2      x =123 - 3  whilenotFalse:            - 4  print("hello "+ name)  - 5  continue - 6   - + + + + ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ + 1  defhello(name): + 2      x =123 + 3  whilenotFalse + 4  print("hello "+ name)  + 5  continue + 6   + + ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ @@ -37458,7 +41036,7 @@ # --- # name: test_text_area_themes[github_light] ''' - + - - + + - + - + - + - + - + - + + + + + + + - TextAreaSnapshot + TextAreaSnapshot - - - - 1  defhello(name): - 2  x=123 - 3  whilenotFalse:            - 4  print("hello "+name - 5  continue - 6   - + + + + ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ + 1  defhello(name): + 2  x=123 + 3  whilenotFalse + 4  print("hello "+name + 5  continue + 6   + + ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ @@ -37555,7 +41143,7 @@ # --- # name: test_text_area_themes[monokai] ''' - + - - + + - + - + - + - + - + - + + + + + + + - TextAreaSnapshot + TextAreaSnapshot - - - - 1  defhello(name): - 2      x =123 - 3  whilenotFalse:            - 4  print("hello "+ name)  - 5  continue - 6   - + + + + ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ + 1  defhello(name): + 2      x =123 + 3  whilenotFalse + 4  print("hello "+ name)  + 5  continue + 6   + + ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ @@ -37650,7 +41248,7 @@ # --- # name: test_text_area_themes[vscode_dark] ''' - + - - + + - + - + - + - + - + - + + + + + + + - TextAreaSnapshot + TextAreaSnapshot - - - - 1  defhello(name): - 2      x =123 - 3  whilenotFalse:            - 4  print("hello "+ name)  - 5  continue - 6   - + + + + ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ + 1  defhello(name): + 2      x =123 + 3  whilenotFalse + 4  print("hello "+ name)  + 5  continue + 6   + + ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ + + + + + ''' +# --- +# name: test_text_area_wrapping_and_folding + ''' + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + TextAreaWrapping + + + + + + + + + + ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ +  1  # The  + Wonders  + of Space  + Explorati + on +  2   +  3  Space  + explorati + on has  + *always* + captured  + the  + human  + imaginati + on.  +  4  ▃▃ +  5  ダレンバ + ーンズ  +  6   +  7   + Thisissom + elongtext + thatshoul + dfoldcorr + ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ @@ -38428,146 +42206,146 @@ font-weight: 700; } - .terminal-4085160594-matrix { + .terminal-2882699257-matrix { font-family: Fira Code, monospace; font-size: 20px; line-height: 24.4px; font-variant-east-asian: full-width; } - .terminal-4085160594-title { + .terminal-2882699257-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-2882699257-r1 { fill: #c5c8c6 } + .terminal-2882699257-r2 { fill: #e3e3e3 } + .terminal-2882699257-r3 { fill: #e1e1e1 } + .terminal-2882699257-r4 { fill: #e1e1e1;text-decoration: underline; } + .terminal-2882699257-r5 { fill: #e1e1e1;font-weight: bold } + .terminal-2882699257-r6 { fill: #e1e1e1;font-style: italic; } + .terminal-2882699257-r7 { fill: #f4005f;font-weight: bold } + .terminal-2882699257-r8 { fill: #fd971f } + .terminal-2882699257-r9 { fill: #98e024 } + .terminal-2882699257-r10 { fill: #98e024;font-style: italic; } + .terminal-2882699257-r11 { fill: #ffcf56 } + .terminal-2882699257-r12 { fill: #e76580 } + .terminal-2882699257-r13 { fill: #fea62b;font-weight: bold } + .terminal-2882699257-r14 { fill: #f5e5e9;font-weight: bold } + .terminal-2882699257-r15 { fill: #b86b00 } + .terminal-2882699257-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 + ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ @@ -38597,134 +42375,134 @@ font-weight: 700; } - .terminal-3455460968-matrix { + .terminal-3216424293-matrix { font-family: Fira Code, monospace; font-size: 20px; line-height: 24.4px; font-variant-east-asian: full-width; } - .terminal-3455460968-title { + .terminal-3216424293-title { font-size: 18px; font-weight: bold; font-family: arial; } - .terminal-3455460968-r1 { fill: #fea62b } - .terminal-3455460968-r2 { fill: #323232 } - .terminal-3455460968-r3 { fill: #c5c8c6 } - .terminal-3455460968-r4 { fill: #e1e1e1 } - .terminal-3455460968-r5 { fill: #e2e3e3 } + .terminal-3216424293-r1 { fill: #fea62b } + .terminal-3216424293-r2 { fill: #323232 } + .terminal-3216424293-r3 { fill: #c5c8c6 } + .terminal-3216424293-r4 { fill: #e1e1e1 } + .terminal-3216424293-r5 { fill: #e2e3e3 } - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - TooltipApp + TooltipApp - - - - ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━10%--:--:-- - - Hello, Tooltip! - - - - - - - - - - - - - - - - - - - - + + + + ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━10% + + Hello, Tooltip! + + + + + + + + + + + + + + + + + + + + diff --git a/tests/snapshot_tests/snapshot_apps/ansi_mapping.py b/tests/snapshot_tests/snapshot_apps/ansi_mapping.py new file mode 100644 index 0000000000..05e4669cd2 --- /dev/null +++ b/tests/snapshot_tests/snapshot_apps/ansi_mapping.py @@ -0,0 +1,25 @@ +from textual.app import App, ComposeResult +from textual.widgets import Label + + +class AnsiMappingApp(App[None]): + def compose(self) -> ComposeResult: + ansi_colors = [ + "red", + "green", + "yellow", + "blue", + "magenta", + "cyan", + "white", + "black", + ] + yield Label("[fg on bg]Foreground & background[/]") + for color in ansi_colors: + yield Label(f"[{color}]{color}[/]") + yield Label(f"[dim {color}]dim {color}[/]") + + +app = AnsiMappingApp() +if __name__ == "__main__": + app.run() diff --git a/tests/snapshot_tests/snapshot_apps/app_blur.py b/tests/snapshot_tests/snapshot_apps/app_blur.py new file mode 100644 index 0000000000..37079f0cc5 --- /dev/null +++ b/tests/snapshot_tests/snapshot_apps/app_blur.py @@ -0,0 +1,32 @@ +from textual.app import App, ComposeResult +from textual.events import AppBlur +from textual.widgets import Input + +class AppBlurApp(App[None]): + + CSS = """ + Screen { + align: center middle; + } + + Input { + width: 50%; + margin-bottom: 1; + + &:focus { + width: 75%; + border: thick green; + background: pink; + } + } + """ + + def compose(self) -> ComposeResult: + yield Input("This should be the blur style") + yield Input("This should also be the blur style") + + def on_mount(self) -> None: + self.post_message(AppBlur()) + +if __name__ == "__main__": + AppBlurApp().run() diff --git a/tests/snapshot_tests/snapshot_apps/button_widths.py b/tests/snapshot_tests/snapshot_apps/button_widths.py new file mode 100644 index 0000000000..e01c310187 --- /dev/null +++ b/tests/snapshot_tests/snapshot_apps/button_widths.py @@ -0,0 +1,25 @@ +from textual.app import App, ComposeResult +from textual.containers import Horizontal +from textual.widgets import Button + + +class HorizontalWidthAutoApp(App[None]): + CSS = """ + Horizontal { + border: solid red; + height: auto; + width: auto; + } + """ + + def compose(self) -> ComposeResult: + with Horizontal(classes="auto"): + yield Button("This is a very wide button") + + with Horizontal(classes="auto"): + yield Button("This is a very wide button") + yield Button("This is a very wide button") + + +if __name__ == "__main__": + HorizontalWidthAutoApp().run() 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/input_percentage_width.py b/tests/snapshot_tests/snapshot_apps/input_percentage_width.py new file mode 100644 index 0000000000..f20b2ddd29 --- /dev/null +++ b/tests/snapshot_tests/snapshot_apps/input_percentage_width.py @@ -0,0 +1,35 @@ +from textual.app import App, ComposeResult +from textual.widgets import Input, TextArea, Static, Button, Label + + +class InputVsTextArea(App[None]): + CSS = """ + Screen > *, Screen > *:focus { + width: 50%; + height: 1fr; + border: solid red; + } + App #ruler { + width: 1fr; + height: 1; + border: none; + } + """ + + def compose(self) -> ComposeResult: + yield Label("[reverse]0123456789[/]0123456789" * 4, id="ruler") + + input = Input() + input.cursor_blink = False + yield input + + text_area = TextArea() + text_area.cursor_blink = False + yield text_area + + yield Static() + yield Button() + + +if __name__ == "__main__": + InputVsTextArea().run() diff --git a/tests/snapshot_tests/snapshot_apps/listview_index.py b/tests/snapshot_tests/snapshot_apps/listview_index.py new file mode 100644 index 0000000000..1aa88aec3b --- /dev/null +++ b/tests/snapshot_tests/snapshot_apps/listview_index.py @@ -0,0 +1,33 @@ +from textual.app import App, ComposeResult +from textual.reactive import reactive +from textual.widgets import Label, ListItem, ListView + + +class ListViewIndexApp(App): + CSS = """ + ListView { + height: 10; + } + """ + + data = reactive(list(range(6))) + + def __init__(self) -> None: + super().__init__() + self._menu = ListView() + + def compose(self) -> ComposeResult: + yield self._menu + + async def watch_data(self, data: "list[int]") -> None: + await self._menu.remove_children() + await self._menu.extend((ListItem(Label(str(value))) for value in data)) + self._menu.index = len(self._menu) - 1 + + async def on_ready(self): + self.data = list(range(0, 30, 2)) + + +if __name__ == "__main__": + app = ListViewIndexApp() + app.run() diff --git a/tests/snapshot_tests/snapshot_apps/markdown_component_classes_reloading.py b/tests/snapshot_tests/snapshot_apps/markdown_component_classes_reloading.py new file mode 100644 index 0000000000..f58440a47f --- /dev/null +++ b/tests/snapshot_tests/snapshot_apps/markdown_component_classes_reloading.py @@ -0,0 +1,50 @@ +from pathlib import Path + +from textual.app import App, ComposeResult +from textual.widgets import Markdown + +CSS_PATH = (Path(__file__) / "../markdown_component_classes_reloading.tcss").resolve() + +CSS_PATH.write_text( + """\ +.code_inline, +.em, +.strong, +.s, +.markdown-table--header, +.markdown-table--lines, +{ + color: yellow; +} +""" +) + +MD = """ +# This is a **header** + +| col1 | col2 | +| :- | :- | +| value 1 | value 2 | + +Here's some code: `from itertools import product`. +**Bold text** +_Emphasized text_ +~~strikethrough~~ + +```py +print("Hello, world!") +``` + +**That** was _some_ code. +""" + + +class MyApp(App[None]): + CSS_PATH = CSS_PATH + + def compose(self) -> ComposeResult: + yield Markdown(MD) + + +if __name__ == "__main__": + MyApp().run() diff --git a/tests/snapshot_tests/snapshot_apps/markdown_component_classes_reloading.tcss b/tests/snapshot_tests/snapshot_apps/markdown_component_classes_reloading.tcss new file mode 100644 index 0000000000..5e9ee82eb7 --- /dev/null +++ b/tests/snapshot_tests/snapshot_apps/markdown_component_classes_reloading.tcss @@ -0,0 +1 @@ +/* This file is purposefully empty. */ 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/nested_specificity.py b/tests/snapshot_tests/snapshot_apps/nested_specificity.py index 0705abfabc..da67cc8dcd 100644 --- a/tests/snapshot_tests/snapshot_apps/nested_specificity.py +++ b/tests/snapshot_tests/snapshot_apps/nested_specificity.py @@ -31,14 +31,15 @@ class NestedCSS(BaseTester): DEFAULT_CSS = """ NestedCSS { width: 1fr; - height: 1fr; - background: green 10%; - border: blank; + height: 1fr; &:focus { background: green 20%; border: round green; } + + background: green 10%; + border: blank; } """ 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/pilot_resize_terminal.py b/tests/snapshot_tests/snapshot_apps/pilot_resize_terminal.py new file mode 100644 index 0000000000..99d7400691 --- /dev/null +++ b/tests/snapshot_tests/snapshot_apps/pilot_resize_terminal.py @@ -0,0 +1,13 @@ +from textual.app import App, ComposeResult +from textual.widgets import Label + + +class SingleLabelApp(App[None]): + """An app with a single label that's 20 x 10.""" + + def compose(self) -> ComposeResult: + yield Label(("12345678901234567890\n" * 10).strip()) + + +if __name__ == "__main__": + SingleLabelApp().run() diff --git a/tests/snapshot_tests/snapshot_apps/placeholder_disabled.py b/tests/snapshot_tests/snapshot_apps/placeholder_disabled.py new file mode 100644 index 0000000000..1719a963dc --- /dev/null +++ b/tests/snapshot_tests/snapshot_apps/placeholder_disabled.py @@ -0,0 +1,18 @@ +from textual.app import App, ComposeResult +from textual.widgets import Placeholder + +class DisabledPlaceholderApp(App[None]): + + CSS = """ + Placeholder { + height: 1fr; + } + """ + + def compose(self) -> ComposeResult: + yield Placeholder() + yield Placeholder(disabled=True) + +if __name__ == "__main__": + DisabledPlaceholderApp().run() + diff --git a/tests/snapshot_tests/snapshot_apps/pretty_grid_gutter_interaction.py b/tests/snapshot_tests/snapshot_apps/pretty_grid_gutter_interaction.py new file mode 100644 index 0000000000..59c768d57b --- /dev/null +++ b/tests/snapshot_tests/snapshot_apps/pretty_grid_gutter_interaction.py @@ -0,0 +1,41 @@ +"""App used as regression test for https://github.com/Textualize/textual/pull/4219.""" + +from __future__ import annotations + +from textual.app import App, ComposeResult +from textual.containers import Grid +from textual.widgets import Pretty, Label + + +class MyGrid(Grid): + """A simple grid with 2 columns and 2 rows.""" + + +class MyApp(App[None]): + DEFAULT_CSS = """ + MyGrid { + height: auto; + grid-rows: auto; + grid-size: 2; + grid-gutter: 1; + background: green; + } + + Pretty { + background: red; + } + + Label { + background: blue; + } +""" + + def compose(self) -> ComposeResult: + with MyGrid(): + yield Pretty(["This is a string that has some chars"]) + yield Label() + yield Label("This should be 1 cell away from ^") + + +if __name__ == "__main__": + MyApp().run() diff --git a/tests/snapshot_tests/snapshot_apps/recompose.py b/tests/snapshot_tests/snapshot_apps/recompose.py new file mode 100644 index 0000000000..f0e5909f95 --- /dev/null +++ b/tests/snapshot_tests/snapshot_apps/recompose.py @@ -0,0 +1,59 @@ +from __future__ import annotations + +from textual.app import App, ComposeResult +from textual.containers import Horizontal, Vertical +from textual.reactive import reactive +from textual.widgets import Digits, ProgressBar + + +class Numbers(Vertical): + + DEFAULT_CSS = """ + Digits { + border: red; + } + """ + + def __init__(self, numbers: list[int]) -> None: + self.numbers = numbers + super().__init__() + + def compose(self) -> ComposeResult: + with Horizontal(): + for number in self.numbers: + yield Digits(str(number)) + + +class Progress(Horizontal): + + progress = reactive(0, recompose=True) + + def compose(self) -> ComposeResult: + bar = ProgressBar(100, show_eta=False) + bar.progress = self.progress + yield bar + + +class RecomposeApp(App): + + start = reactive(0, recompose=True) + end = reactive(5, recompose=True) + progress: reactive[int] = reactive(0, recompose=True) + + def compose(self) -> ComposeResult: + yield Numbers(list(range(self.start, self.end))) + yield Progress().data_bind(RecomposeApp.progress) + + def on_mount(self) -> None: + self.start = 10 + self.end = 17 + # Call update_progress later so it is part of another recompose + self.set_timer(0.05, self.update_progress) + + def update_progress(self) -> None: + self.progress = 50 + + +if __name__ == "__main__": + app = RecomposeApp() + app.run() diff --git a/tests/snapshot_tests/snapshot_apps/richlog_width.py b/tests/snapshot_tests/snapshot_apps/richlog_width.py new file mode 100644 index 0000000000..97ff437d2f --- /dev/null +++ b/tests/snapshot_tests/snapshot_apps/richlog_width.py @@ -0,0 +1,12 @@ +from rich.text import Text +from textual.app import App, ComposeResult +from textual.widgets import RichLog + + +class RichLogWidth(App[None]): + def compose(self) -> ComposeResult: + yield RichLog(min_width=20) + +app = RichLogWidth() +if __name__ == "__main__": + app.run() diff --git a/tests/snapshot_tests/snapshot_apps/sort_children.py b/tests/snapshot_tests/snapshot_apps/sort_children.py new file mode 100644 index 0000000000..21c9178273 --- /dev/null +++ b/tests/snapshot_tests/snapshot_apps/sort_children.py @@ -0,0 +1,79 @@ +from operator import attrgetter + +from textual.app import App, ComposeResult +from textual.containers import Horizontal, Vertical +from textual.widgets import Label + + +class Number(Label): + DEFAULT_CSS = """ + Number { + width: 1fr; + } + """ + + def __init__(self, number: int) -> None: + self.number = number + super().__init__(classes=f"number{number}") + + def render(self) -> str: + return str(self.number) + + +class NumberList(Vertical): + + DEFAULT_CSS = """ + NumberList { + width: 1fr; + Number { + border: green; + box-sizing: border-box; + &.number1 { + height: 3; + } + &.number2 { + height: 4; + } + &.number3 { + height: 5; + } + &.number4 { + height: 6; + } + &.number5 { + height: 7; + } + } + } + + """ + + def compose(self) -> ComposeResult: + yield Number(5) + yield Number(1) + yield Number(3) + yield Number(2) + yield Number(4) + + +class SortApp(App): + + def compose(self) -> ComposeResult: + with Horizontal(): + yield NumberList(id="unsorted") + yield NumberList(id="ascending") + yield NumberList(id="descending") + + def on_mount(self) -> None: + self.query_one("#ascending").sort_children( + key=attrgetter("number"), + ) + self.query_one("#descending").sort_children( + key=attrgetter("number"), + reverse=True, + ) + + +if __name__ == "__main__": + app = SortApp() + app.run() diff --git a/tests/snapshot_tests/snapshot_apps/tabbed_content_style_leak_test.py b/tests/snapshot_tests/snapshot_apps/tabbed_content_style_leak_test.py new file mode 100644 index 0000000000..fb20cd6b31 --- /dev/null +++ b/tests/snapshot_tests/snapshot_apps/tabbed_content_style_leak_test.py @@ -0,0 +1,14 @@ +from textual.app import App, ComposeResult +from textual.widgets import Button, Label, TabbedContent, Tabs, TabPane + +class TabbedContentStyleLeakTestApp(App[None]): + + def compose(self) -> ComposeResult: + with TabbedContent(): + with TabPane("Leak Test"): + yield Label("This label should come first") + yield Button("This button should come second") + yield Tabs("These", "Tabs", "Should", "Come", "Last") + +if __name__ == "__main__": + TabbedContentStyleLeakTestApp().run() diff --git a/tests/snapshot_tests/snapshot_apps/text_area.py b/tests/snapshot_tests/snapshot_apps/text_area.py index da6bd06992..9822d2fafc 100644 --- a/tests/snapshot_tests/snapshot_apps/text_area.py +++ b/tests/snapshot_tests/snapshot_apps/text_area.py @@ -5,7 +5,7 @@ class TextAreaSnapshot(App): def compose(self) -> ComposeResult: - text_area = TextArea() + text_area = TextArea.code_editor() text_area.cursor_blink = False yield text_area diff --git a/tests/snapshot_tests/snapshot_apps/text_area_alternate_screen.py b/tests/snapshot_tests/snapshot_apps/text_area_alternate_screen.py new file mode 100644 index 0000000000..4c2578e2c6 --- /dev/null +++ b/tests/snapshot_tests/snapshot_apps/text_area_alternate_screen.py @@ -0,0 +1,24 @@ +from textual.app import App +from textual.screen import Screen +from textual.widgets import TextArea + +TEXT = """\ +foo +bar +baz +""" + + +class AltScreen(Screen[None]): + def compose(self): + yield TextArea(TEXT) + + +class TABug(App[None]): + def on_mount(self): + self.push_screen(AltScreen()) + + +if __name__ == "__main__": + app = TABug() + app.run() diff --git a/tests/snapshot_tests/snapshot_apps/text_area_unfocus.py b/tests/snapshot_tests/snapshot_apps/text_area_unfocus.py index e092f16721..cc373b593f 100644 --- a/tests/snapshot_tests/snapshot_apps/text_area_unfocus.py +++ b/tests/snapshot_tests/snapshot_apps/text_area_unfocus.py @@ -7,7 +7,7 @@ class TextAreaUnfocusSnapshot(App): AUTO_FOCUS = None def compose(self) -> ComposeResult: - text_area = TextArea() + text_area = TextArea.code_editor() text_area.cursor_blink = False yield text_area diff --git a/tests/snapshot_tests/snapshot_apps/text_area_wrapping.py b/tests/snapshot_tests/snapshot_apps/text_area_wrapping.py new file mode 100644 index 0000000000..c1fdcdff70 --- /dev/null +++ b/tests/snapshot_tests/snapshot_apps/text_area_wrapping.py @@ -0,0 +1,31 @@ +from textual.app import App, ComposeResult +from textual.widgets import TextArea + +TEXT = """\ +# The Wonders of Space Exploration + +Space exploration has *always* captured the human imagination. + +ダレンバーンズ + +\tThisissomelongtextthatshouldfoldcorrectly. + +\t\tダレン バーンズ + + + + + + + +""" + + +class TextAreaWrapping(App): + def compose(self) -> ComposeResult: + yield TextArea.code_editor(TEXT, language="markdown", theme="monokai", soft_wrap=True) + + +app = TextAreaWrapping() +if __name__ == "__main__": + app.run() diff --git a/tests/snapshot_tests/snapshot_apps/tooltips.py b/tests/snapshot_tests/snapshot_apps/tooltips.py index bc55542d71..6c9ca8aea5 100644 --- a/tests/snapshot_tests/snapshot_apps/tooltips.py +++ b/tests/snapshot_tests/snapshot_apps/tooltips.py @@ -4,7 +4,7 @@ class TooltipApp(App[None]): def compose(self) -> ComposeResult: - progress_bar = ProgressBar(100) + progress_bar = ProgressBar(100, show_eta=False) progress_bar.advance(10) progress_bar.tooltip = "Hello, Tooltip!" yield progress_bar diff --git a/tests/snapshot_tests/test_snapshots.py b/tests/snapshot_tests/test_snapshots.py index db9f95b25e..9e82692510 100644 --- a/tests/snapshot_tests/test_snapshots.py +++ b/tests/snapshot_tests/test_snapshots.py @@ -4,7 +4,7 @@ from tests.snapshot_tests.language_snippets import SNIPPETS from textual.widgets.text_area import Selection, BUILTIN_LANGUAGES -from textual.widgets import TextArea, Input, Button +from textual.widgets import RichLog, TextArea, Input, Button from textual.widgets.text_area import TextAreaTheme # These paths should be relative to THIS directory. @@ -230,6 +230,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") @@ -263,6 +279,10 @@ def test_tabbed_content_with_modified_tabs(snap_compare): assert snap_compare(SNAPSHOT_APPS_DIR / "modified_tabs.py") +def test_tabbed_content_styling_not_leaking(snap_compare): + assert snap_compare(SNAPSHOT_APPS_DIR / "tabbed_content_style_leak_test.py") + + def test_option_list_strings(snap_compare): assert snap_compare(WIDGET_EXAMPLES_DIR / "option_list_strings.py") @@ -297,6 +317,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"]) @@ -404,6 +428,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. @@ -479,15 +516,6 @@ def test_demo(snap_compare): ) -# def test_demo_with_keys(snap_compare): -# """Test the demo app (python -m textual)""" -# assert snap_compare( -# Path("../../src/textual/demo.py"), -# press=["down", "down", "down", "wait:500"], -# terminal_size=(100, 30), -# ) - - def test_label_widths(snap_compare): """Test renderable widths are calculate correctly.""" assert snap_compare(SNAPSHOT_APPS_DIR / "label_widths.py") @@ -538,6 +566,27 @@ def test_richlog_scroll(snap_compare): assert snap_compare(SNAPSHOT_APPS_DIR / "richlog_scroll.py") +def test_richlog_width(snap_compare): + """Check that min_width applies in RichLog and that we can write + to the RichLog when it's not visible, and it still renders as expected + when made visible again.""" + + async def setup(pilot): + from rich.text import Text + + rich_log: RichLog = pilot.app.query_one(RichLog) + rich_log.write(Text("hello1", style="on red", justify="right"), expand=True) + rich_log.visible = False + rich_log.write(Text("world2", style="on green", justify="right"), expand=True) + rich_log.visible = True + rich_log.write(Text("hello3", style="on blue", justify="right"), expand=True) + rich_log.display = False + rich_log.write(Text("world4", style="on yellow", justify="right"), expand=True) + rich_log.display = True + + assert snap_compare(SNAPSHOT_APPS_DIR / "richlog_width.py", run_before=setup) + + def test_tabs_invalidate(snap_compare): assert snap_compare( SNAPSHOT_APPS_DIR / "tabs_invalidate.py", @@ -551,6 +600,18 @@ def test_scrollbar_thumb_height(snap_compare): ) +def test_pilot_resize_terminal(snap_compare): + async def run_before(pilot): + await pilot.resize_terminal(35, 20) + await pilot.resize_terminal(20, 10) + + assert snap_compare( + SNAPSHOT_APPS_DIR / "pilot_resize_terminal.py", + run_before=run_before, + terminal_size=(80, 25), + ) + + def test_css_hot_reloading(snap_compare, monkeypatch): """Regression test for https://github.com/Textualize/textual/issues/2063.""" @@ -606,6 +667,27 @@ async def run_before(pilot): ) +def test_markdown_component_classes_reloading(snap_compare, monkeypatch): + """Tests all markdown component classes reload correctly. + + See https://github.com/Textualize/textual/issues/3464.""" + + monkeypatch.setenv( + "TEXTUAL", "debug" + ) # This will make sure we create a file monitor. + + async def run_before(pilot): + css_file = pilot.app.CSS_PATH + with open(css_file, "w") as f: + f.write("/* This file is purposefully empty. */\n") # Clear all the CSS. + await pilot.app._on_css_change() + + assert snap_compare( + SNAPSHOT_APPS_DIR / "markdown_component_classes_reloading.py", + run_before=run_before, + ) + + def test_layer_fix(snap_compare): # Check https://github.com/Textualize/textual/issues/1358 assert snap_compare(SNAPSHOT_APPS_DIR / "layer_fix.py", press=["d"]) @@ -725,6 +807,16 @@ 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 --- @@ -812,7 +904,7 @@ def setup_language(pilot) -> None: assert snap_compare( SNAPSHOT_APPS_DIR / "text_area.py", run_before=setup_language, - terminal_size=(80, snippet.count("\n") + 2), + terminal_size=(80, snippet.count("\n") + 4), ) @@ -843,7 +935,21 @@ def setup_selection(pilot): assert snap_compare( SNAPSHOT_APPS_DIR / "text_area.py", run_before=setup_selection, - terminal_size=(30, text.count("\n") + 1), + terminal_size=(30, text.count("\n") + 4), + ) + + +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), ) @@ -873,7 +979,20 @@ def setup_theme(pilot): assert snap_compare( SNAPSHOT_APPS_DIR / "text_area.py", run_before=setup_theme, - terminal_size=(48, text.count("\n") + 2), + terminal_size=(48, text.count("\n") + 4), + ) + + +def test_text_area_alternate_screen(snap_compare): + assert snap_compare( + SNAPSHOT_APPS_DIR / "text_area_alternate_screen.py", terminal_size=(48, 10) + ) + + +@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) ) @@ -987,6 +1106,65 @@ def test_tab_rename(snap_compare): assert snap_compare(SNAPSHOT_APPS_DIR / "tab_rename.py") +def test_input_percentage_width(snap_compare): + """Check percentage widths work correctly.""" + # https://github.com/Textualize/textual/issues/3721 + assert snap_compare(SNAPSHOT_APPS_DIR / "input_percentage_width.py") + + +def test_recompose(snap_compare): + """Check recompose works.""" + # https://github.com/Textualize/textual/pull/4206 + assert snap_compare(SNAPSHOT_APPS_DIR / "recompose.py") + + +@pytest.mark.parametrize("dark", [True, False]) +def test_ansi_color_mapping(snap_compare, dark): + """Test how ANSI colors in Rich renderables are mapped to hex colors.""" + + def setup(pilot): + pilot.app.dark = dark + + assert snap_compare(SNAPSHOT_APPS_DIR / "ansi_mapping.py", run_before=setup) + + +def test_pretty_grid_gutter_interaction(snap_compare): + """Regression test for https://github.com/Textualize/textual/pull/4219.""" + assert snap_compare( + SNAPSHOT_APPS_DIR / "pretty_grid_gutter_interaction.py", terminal_size=(81, 7) + ) + + +def test_sort_children(snap_compare): + """Test sort_children method.""" + assert snap_compare(SNAPSHOT_APPS_DIR / "sort_children.py", terminal_size=(80, 25)) + + +def test_app_blur(snap_compare): + """Test Styling after receiving an AppBlur message.""" + + async def run_before(pilot) -> None: + await pilot.pause() # Allow the AppBlur message to get processed. + + assert snap_compare(SNAPSHOT_APPS_DIR / "app_blur.py", run_before=run_before) + + +def test_placeholder_disabled(snap_compare): + """Test placeholder with diabled set to True.""" + assert snap_compare(SNAPSHOT_APPS_DIR / "placeholder_disabled.py") + + +def test_listview_index(snap_compare): + """Tests that ListView scrolls correctly after updating its index.""" + assert snap_compare(SNAPSHOT_APPS_DIR / "listview_index.py") + + +def test_button_widths(snap_compare): + """Test that button widths expand auto containers as expected.""" + # https://github.com/Textualize/textual/issues/4024 + assert snap_compare(SNAPSHOT_APPS_DIR / "button_widths.py") + + # --- Example apps --- # We skip the code browser because the length of the scrollbar in the tree depends on # the number of files and folders we have locally and that typically differs from the @@ -1048,4 +1226,4 @@ async def run_before(pilot): def test_example_pride(snap_compare): """Test the pride example.""" - assert snap_compare(EXAMPLES_DIR / "pride.py") + assert snap_compare(EXAMPLES_DIR / "pride.py") \ No newline at end of file diff --git a/tests/test_animator.py b/tests/test_animator.py index e5be30ab6e..a92e1ad6d8 100644 --- a/tests/test_animator.py +++ b/tests/test_animator.py @@ -203,11 +203,11 @@ async def test_animator(): assert animator._animations[(id(animate_test), "foo")] == expected assert not animator._on_animation_frame_called - await animator() + animator() assert animate_test.foo == 0 animator._time = 5 - await animator() + animator() assert animate_test.foo == 50 # New animation in the middle of an existing one @@ -215,7 +215,7 @@ async def test_animator(): assert animate_test.foo == 50 animator._time = 6 - await animator() + animator() assert animate_test.foo == 200 @@ -251,7 +251,7 @@ async def test_animator_on_complete_callback_not_fired_before_duration_ends(): animator.animate(animate_test, "foo", 200, duration=10, on_complete=callback) animator._time = 9 - await animator() + animator() assert not callback.called @@ -259,11 +259,34 @@ async def test_animator_on_complete_callback_not_fired_before_duration_ends(): async def test_animator_on_complete_callback_fired_at_duration(): callback = Mock() animate_test = AnimateTest() - animator = MockAnimator(Mock()) + mock_app = Mock() + animator = MockAnimator(mock_app) animator.animate(animate_test, "foo", 200, duration=10, on_complete=callback) animator._time = 10 - await animator() + animator() + + # Ensure that the callback is scheduled to run after the duration is up. + mock_app.call_later.assert_called_once_with(callback) + + +def test_force_stop_animation(): + callback = Mock() + animate_test = AnimateTest() + mock_app = Mock() + animator = MockAnimator(mock_app) + + animator.animate(animate_test, "foo", 200, duration=10, on_complete=callback) + + assert animator.is_being_animated(animate_test, "foo") + assert animate_test.foo != 200 + + animator.force_stop_animation(animate_test, "foo") + + # The animation of the attribute was force cancelled. + assert not animator.is_being_animated(animate_test, "foo") + assert animate_test.foo == 200 - callback.assert_called_once_with() + # The on_complete callback was scheduled. + mock_app.call_later.assert_called_once_with(callback) diff --git a/tests/test_app.py b/tests/test_app.py index e5afed1e2a..3e3927176f 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -1,5 +1,7 @@ import contextlib +from rich.terminal_theme import DIMMED_MONOKAI, MONOKAI, NIGHT_OWLISH + from textual.app import App, ComposeResult from textual.widgets import Button, Input @@ -117,3 +119,24 @@ async def test_no_return_code_while_running(): app = App() async with app.run_test(): assert app.return_code is None + + +async def test_ansi_theme(): + app = App() + async with app.run_test(): + app.ansi_theme_dark = NIGHT_OWLISH + assert app.ansi_theme == NIGHT_OWLISH + + app.dark = False + assert app.ansi_theme != NIGHT_OWLISH + + app.ansi_theme_light = MONOKAI + assert app.ansi_theme == MONOKAI + + # Ensure if we change the dark theme while on light mode, + # then change back to dark mode, the dark theme is updated. + app.ansi_theme_dark = DIMMED_MONOKAI + assert app.ansi_theme == MONOKAI + + app.dark = True + assert app.ansi_theme == DIMMED_MONOKAI diff --git a/tests/test_app_focus_blur.py b/tests/test_app_focus_blur.py new file mode 100644 index 0000000000..bbeea8ebce --- /dev/null +++ b/tests/test_app_focus_blur.py @@ -0,0 +1,79 @@ +"""Test the workings of reacting to AppFocus and AppBlur.""" + +from textual.app import App, ComposeResult +from textual.events import AppBlur, AppFocus +from textual.widgets import Input + + +class FocusBlurApp(App[None]): + + AUTO_FOCUS = "#input-4" + + def compose(self) -> ComposeResult: + for n in range(10): + yield Input(id=f"input-{n}") + + +async def test_app_blur() -> None: + """Test that AppBlur removes focus.""" + async with FocusBlurApp().run_test() as pilot: + assert pilot.app.focused is not None + assert pilot.app.focused.id == "input-4" + pilot.app.post_message(AppBlur()) + await pilot.pause() + assert pilot.app.focused is None + + +async def test_app_focus_restores_focus() -> None: + """Test that AppFocus restores the correct focus.""" + async with FocusBlurApp().run_test() as pilot: + assert pilot.app.focused is not None + assert pilot.app.focused.id == "input-4" + pilot.app.post_message(AppBlur()) + await pilot.pause() + assert pilot.app.focused is None + pilot.app.post_message(AppFocus()) + await pilot.pause() + assert pilot.app.focused is not None + assert pilot.app.focused.id == "input-4" + + +async def test_app_focus_restores_none_focus() -> None: + """Test that AppFocus doesn't set focus if nothing was focused.""" + async with FocusBlurApp().run_test() as pilot: + pilot.app.screen.focused = None + pilot.app.post_message(AppBlur()) + await pilot.pause() + assert pilot.app.focused is None + pilot.app.post_message(AppFocus()) + await pilot.pause() + assert pilot.app.focused is None + + +async def test_app_focus_handles_missing_widget() -> None: + """Test that AppFocus works even when the last-focused widget has gone away.""" + async with FocusBlurApp().run_test() as pilot: + assert pilot.app.focused is not None + assert pilot.app.focused.id == "input-4" + pilot.app.post_message(AppBlur()) + await pilot.pause() + assert pilot.app.focused is None + await pilot.app.query_one("#input-4").remove() + pilot.app.post_message(AppFocus()) + await pilot.pause() + assert pilot.app.focused is None + + +async def test_app_focus_defers_to_new_focus() -> None: + """Test that AppFocus doesn't undo a fresh focus done while the app is in AppBlur state.""" + async with FocusBlurApp().run_test() as pilot: + assert pilot.app.focused is not None + assert pilot.app.focused.id == "input-4" + pilot.app.post_message(AppBlur()) + await pilot.pause() + assert pilot.app.focused is None + pilot.app.query_one("#input-1").focus() + await pilot.pause() + pilot.app.post_message(AppFocus()) + await pilot.pause() + assert pilot.app.focused.id == "input-1" diff --git a/tests/test_binding_inheritance.py b/tests/test_binding_inheritance.py index 431920422c..07c483bc65 100644 --- a/tests/test_binding_inheritance.py +++ b/tests/test_binding_inheritance.py @@ -28,8 +28,8 @@ # An application with no bindings anywhere. # # The idea of this first little test is that an application that has no -# bindings set anywhere, and uses a default screen, should only have the one -# binding in place: ctrl+c; it's hard-coded in the app class for now. +# bindings set anywhere, and uses a default screen, should only its +# hard-coded bindings in place. class NoBindings(App[None]): @@ -37,9 +37,12 @@ class NoBindings(App[None]): async def test_just_app_no_bindings() -> None: - """An app with no bindings should have no bindings, other than ctrl+c.""" + """An app with no bindings should have no bindings, other than the app's hard-coded ones.""" async with NoBindings().run_test() as pilot: - assert list(pilot.app._bindings.keys.keys()) == ["ctrl+c", "ctrl+backslash"] + assert list(pilot.app._bindings.keys.keys()) == [ + "ctrl+c", + "ctrl+backslash", + ] assert pilot.app._bindings.get_key("ctrl+c").priority is True @@ -48,7 +51,8 @@ async def test_just_app_no_bindings() -> None: # # Sticking with just an app and the default screen: this configuration has a # BINDINGS on the app itself, and simply binds the letter a. The result -# should be that we see the letter a, ctrl+c, and nothing else. +# should be that we see the letter a, the app's default bindings, and +# nothing else. class AlphaBinding(App[None]): @@ -352,7 +356,9 @@ def on_mount(self) -> None: self.push_screen("main") -async def test_contained_focused_child_widget_with_movement_bindings_on_screen() -> None: +async def test_contained_focused_child_widget_with_movement_bindings_on_screen() -> ( + None +): """A contained focused child widget, with movement bindings in the screen, should trigger screen actions.""" async with AppWithScreenWithBindingsWrappedWidgetNoBindings().run_test() as pilot: await pilot.press(*AppKeyRecorder.ALL_KEYS) @@ -443,7 +449,9 @@ def on_mount(self) -> None: self.push_screen("main") -async def test_focused_child_widget_no_inherit_with_movement_bindings_on_screen() -> None: +async def test_focused_child_widget_no_inherit_with_movement_bindings_on_screen() -> ( + None +): """A focused child widget, that doesn't inherit bindings, with movement bindings in the screen, should trigger screen actions.""" async with AppWithScreenWithBindingsWidgetNoBindingsNoInherit().run_test() as pilot: await pilot.press(*AppKeyRecorder.ALL_KEYS) @@ -498,7 +506,9 @@ def on_mount(self) -> None: self.push_screen("main") -async def test_focused_child_widget_no_inherit_empty_bindings_with_movement_bindings_on_screen() -> None: +async def test_focused_child_widget_no_inherit_empty_bindings_with_movement_bindings_on_screen() -> ( + None +): """A focused child widget, that doesn't inherit bindings and sets BINDINGS empty, with movement bindings in the screen, should trigger screen actions.""" async with AppWithScreenWithBindingsWidgetEmptyBindingsNoInherit().run_test() as pilot: await pilot.press(*AppKeyRecorder.ALL_KEYS) diff --git a/tests/test_box_model.py b/tests/test_box_model.py index 8f59c8a82f..d775dc9be9 100644 --- a/tests/test_box_model.py +++ b/tests/test_box_model.py @@ -90,7 +90,7 @@ def get_content_height(self, container: Size, parent: Size, width: int) -> int: styles.max_width = "50%" box_model = widget._get_box_model(Size(60, 20), Size(80, 24), one, one) - assert box_model == BoxModel(Fraction(30), Fraction(16), Spacing(1, 2, 3, 4)) + assert box_model == BoxModel(Fraction(27), Fraction(16), Spacing(1, 2, 3, 4)) def test_height(): @@ -141,7 +141,7 @@ def get_content_height(self, container: Size, parent: Size, width: int) -> int: styles.max_height = "50%" box_model = widget._get_box_model(Size(60, 20), Size(80, 24), one, one) - assert box_model == BoxModel(Fraction(54), Fraction(10), Spacing(1, 2, 3, 4)) + assert box_model == BoxModel(Fraction(54), Fraction(8), Spacing(1, 2, 3, 4)) def test_max(): diff --git a/tests/test_collapsible.py b/tests/test_collapsible.py index 116ac74c9d..5cbb973f3f 100644 --- a/tests/test_collapsible.py +++ b/tests/test_collapsible.py @@ -57,8 +57,8 @@ def compose(self) -> ComposeResult: yield Collapsible(Label("Inner"), id="inner", collapsed=False) async with CollapsibleApp().run_test() as pilot: - outer: Collapsible = pilot.app.get_child_by_id("outer") - inner: Collapsible = get_contents(outer).get_child_by_id("inner") + outer: Collapsible = pilot.app.get_child_by_id("outer", Collapsible) + inner: Collapsible = get_contents(outer).get_child_by_id("inner", Collapsible) outer.collapsed = True assert not inner.collapsed @@ -148,7 +148,7 @@ def catch_collapsible_events(self) -> None: async def test_expand_message(): - """Toggling should post a message.""" + """Clicking to expand should post a message.""" hits = [] @@ -169,8 +169,30 @@ def on_collapsible_expanded(self) -> None: assert len(hits) == 1 +async def test_expand_via_watcher_message(): + """Setting `collapsed` to `False` should post a message.""" + + hits = [] + + class CollapsibleApp(App[None]): + def compose(self) -> ComposeResult: + yield Collapsible(collapsed=True) + + def on_collapsible_expanded(self) -> None: + hits.append("expanded") + + async with CollapsibleApp().run_test() as pilot: + assert pilot.app.query_one(Collapsible).collapsed + + pilot.app.query_one(Collapsible).collapsed = False + await pilot.pause() + + assert not pilot.app.query_one(Collapsible).collapsed + assert len(hits) == 1 + + async def test_collapse_message(): - """Toggling should post a message.""" + """Clicking on collapsed should post a message.""" hits = [] @@ -191,6 +213,28 @@ def on_collapsible_collapsed(self) -> None: assert len(hits) == 1 +async def test_collapse_via_watcher_message(): + """Setting `collapsed` to `True` should post a message.""" + + hits = [] + + class CollapsibleApp(App[None]): + def compose(self) -> ComposeResult: + yield Collapsible(collapsed=False) + + def on_collapsible_collapsed(self) -> None: + hits.append("collapsed") + + async with CollapsibleApp().run_test() as pilot: + assert not pilot.app.query_one(Collapsible).collapsed + + pilot.app.query_one(Collapsible).collapsed = True + await pilot.pause() + + assert pilot.app.query_one(Collapsible).collapsed + assert len(hits) == 1 + + async def test_collapsible_title_reactive_change(): class CollapsibleApp(App[None]): def compose(self) -> ComposeResult: diff --git a/tests/test_data_bind.py b/tests/test_data_bind.py new file mode 100644 index 0000000000..a6d86dce25 --- /dev/null +++ b/tests/test_data_bind.py @@ -0,0 +1,86 @@ +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() as pilot: + + # 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) + await pilot.pause() + # 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_dom.py b/tests/test_dom.py index 6f06fb8091..ea14a25bfe 100644 --- a/tests/test_dom.py +++ b/tests/test_dom.py @@ -259,3 +259,24 @@ def test_walk_children_with_self_breadth(search): ] assert children == ["f", "e", "d", "c", "b", "a"] + + +@pytest.mark.parametrize( + "identifier", + [ + " bad", + " terrible ", + "worse! ", + "&ersand", + "amper&sand", + "ampersand&", + "2_leading_digits", + "água", # water + "cão", # dog + "@'/.23", + ], +) +def test_id_validation(identifier: str): + """Regression tests for https://github.com/Textualize/textual/issues/3954.""" + with pytest.raises(BadIdentifier): + DOMNode(id=identifier) diff --git a/tests/test_eta.py b/tests/test_eta.py new file mode 100644 index 0000000000..c91903293b --- /dev/null +++ b/tests/test_eta.py @@ -0,0 +1,56 @@ +from textual.eta import ETA + + +def test_basics() -> None: + eta = ETA() + eta.add_sample(1.0, 1.0) + assert eta.first_sample == (0, 0) + assert eta.last_sample == (1.0, 1.0) + assert len(eta._samples) == 2 + repr(eta) + + +def test_speed() -> None: + eta = ETA() + # One sample is not enough to determine speed + assert eta.speed is None + eta.add_sample(1.0, 0.5) + assert eta.speed == 0.5 + + # Check reset + eta.reset() + assert eta.speed is None + eta.add_sample(0.0, 0.0) + assert eta.speed is None + eta.add_sample(1.0, 0.5) + assert eta.speed == 0.5 + + # Check backwards + eta.add_sample(2.0, 0.0) + assert eta.speed is None + eta.add_sample(3.0, 1.0) + assert eta.speed == 1.0 + + +def test_get_progress_at() -> None: + eta = ETA() + eta.add_sample(1, 2) + # Check interpolation works + assert eta._get_progress_at(0) == (0, 0) + assert eta._get_progress_at(0.1) == (0.1, 0.2) + assert eta._get_progress_at(0.5) == (0.5, 1.0) + + +def test_eta(): + eta = ETA(estimation_period=2, extrapolate_period=5) + eta.add_sample(1, 0.1) + assert eta.speed == 0.1 + assert eta.get_eta(1) == 9 + assert eta.get_eta(2) == 8 + assert eta.get_eta(3) == 7 + assert eta.get_eta(4) == 6 + assert eta.get_eta(5) == 5 + # After 5 seconds (extrapolate_period), eta won't update + assert eta.get_eta(6) == 4 + assert eta.get_eta(7) == 4 + assert eta.get_eta(8) == 4 diff --git a/tests/test_expand_tabs.py b/tests/test_expand_tabs.py index d1d31fb110..b120978da5 100644 --- a/tests/test_expand_tabs.py +++ b/tests/test_expand_tabs.py @@ -1,6 +1,6 @@ import pytest -from textual.expand_tabs import expand_tabs_inline +from textual.expand_tabs import expand_tabs_inline, get_tab_widths @pytest.mark.parametrize( @@ -23,3 +23,10 @@ ) def test_expand_tabs_inline(line, expanded_line): assert expand_tabs_inline(line) == expanded_line + + +def test_get_tab_widths(): + assert get_tab_widths("\tbar") == [("", 4), ("bar", 0)] + assert get_tab_widths("\tbar\t") == [("", 4), ("bar", 1)] + assert get_tab_widths("\tfoo\t\t") == [("", 4), ("foo", 1), ("", 4)] + assert get_tab_widths("\t木foo\t木\t\t") == [("", 4), ("木foo", 3), ("木", 2), ("", 4)] diff --git a/tests/test_file_monitor.py b/tests/test_file_monitor.py index f6c3849682..fc93c7d292 100644 --- a/tests/test_file_monitor.py +++ b/tests/test_file_monitor.py @@ -1,3 +1,4 @@ +import os from pathlib import Path from textual.file_monitor import FileMonitor @@ -6,3 +7,52 @@ def test_repr() -> None: file_monitor = FileMonitor([Path(".")], lambda: None) assert "FileMonitor" in repr(file_monitor) + + +def test_file_never_found(): + path = "doesnt_exist.tcss" + file_monitor = FileMonitor([Path(path)], lambda: None) + file_monitor.check() # Ensuring no exceptions are raised. + + +async def test_file_deletion(tmp_path): + """In some environments, a file can become temporarily unavailable during saving. + + This test ensures the FileMonitor is robust enough to handle a file temporarily being + unavailable, and that it recovers when the file becomes available again. + + Regression test for: https://github.com/Textualize/textual/issues/3996 + """ + + def make_file(): + path = tmp_path / "will_be_deleted.tcss" + path.write_text("#foo { color: dodgerblue; }") + return path + + callback_counter = 0 + + def callback(): + nonlocal callback_counter + callback_counter += 1 + + path = make_file() + file_monitor = FileMonitor([path], callback) + + # Ensure the file monitor survives after the file is gone + await file_monitor() + os.remove(path) + + # The file is now unavailable, but we don't crash here + await file_monitor() + await file_monitor() + + # Despite multiple checks, the file was only available for the first check, + # and we only fire the callback while the file is available. + assert callback_counter == 1 + + # The file is made available again. + make_file() + + # Since the file is available, the callback should fire when the FileMonitor is called + await file_monitor() + assert callback_counter == 2 diff --git a/tests/test_focus.py b/tests/test_focus.py index 67b35d0a93..ee955e23e5 100644 --- a/tests/test_focus.py +++ b/tests/test_focus.py @@ -1,10 +1,10 @@ import pytest from textual.app import App, ComposeResult -from textual.containers import Container +from textual.containers import Container, ScrollableContainer from textual.screen import Screen from textual.widget import Widget -from textual.widgets import Button +from textual.widgets import Button, Label class Focusable(Widget, can_focus=True): @@ -149,12 +149,12 @@ def test_no_focus_empty_selector(screen: Screen): screen.set_focus(screen.query_one("#foo")) assert screen.focused is not None - assert screen.focus_next("bananas") is None + assert screen.focus_next("#bananas") is None assert screen.focused is None screen.set_focus(screen.query_one("#foo")) assert screen.focused is not None - assert screen.focus_previous("bananas") is None + assert screen.focus_previous("#bananas") is None assert screen.focused is None @@ -409,3 +409,42 @@ def compose(self) -> ComposeResult: classes = list(button.get_pseudo_classes()) assert "blur" not in classes assert "focus" in classes + + +async def test_get_focusable_widget_at() -> None: + """Check that clicking a non-focusable widget will focus any (focusable) ancestors.""" + + class FocusApp(App): + AUTO_FOCUS = None + + def compose(self) -> ComposeResult: + with ScrollableContainer(id="focusable"): + with Container(): + yield Label("Foo", id="foo") + yield Label("Bar", id="bar") + yield Label("Egg", id="egg") + + app = FocusApp() + async with app.run_test() as pilot: + # Nothing focused + assert app.screen.focused is None + # Click foo + await pilot.click("#foo") + # Confirm container is focused + assert app.screen.focused is not None + assert app.screen.focused.id == "focusable" + # Reset focus + app.screen.set_focus(None) + assert app.screen.focused is None + # Click bar + await pilot.click("#bar") + # Confirm container is focused + assert app.screen.focused is not None + assert app.screen.focused.id == "focusable" + # Reset focus + app.screen.set_focus(None) + assert app.screen.focused is None + # Click egg (outside of focusable widget) + await pilot.click("#egg") + # Confirm nothing focused + assert app.screen.focused is None diff --git a/tests/test_keys.py b/tests/test_keys.py index 9f13e17d17..3aac179fc6 100644 --- a/tests/test_keys.py +++ b/tests/test_keys.py @@ -52,3 +52,20 @@ def action_increment(self) -> None: def test_get_key_display(): assert _get_key_display("minus") == "-" + + +def test_get_key_display_when_used_in_conjunction(): + """Test a key display is the same if used in conjunction with another key. + For example, "ctrl+right_square_bracket" should display the bracket as "]", + the same as it would without the ctrl modifier. + + Regression test for #3035 https://github.com/Textualize/textual/issues/3035 + """ + + right_square_bracket = _get_key_display("right_square_bracket") + ctrl_right_square_bracket = _get_key_display("ctrl+right_square_bracket") + assert ctrl_right_square_bracket == f"CTRL+{right_square_bracket}" + + left = _get_key_display("left") + ctrl_left = _get_key_display("ctrl+left") + assert ctrl_left == f"CTRL+{left}" diff --git a/tests/test_markdown.py b/tests/test_markdown.py index da4c016aab..358d64baba 100644 --- a/tests/test_markdown.py +++ b/tests/test_markdown.py @@ -11,6 +11,7 @@ from rich.text import Span import textual.widgets._markdown as MD +from textual import on from textual.app import App, ComposeResult from textual.widget import Widget from textual.widgets import Markdown @@ -132,7 +133,7 @@ async def test_load_non_existing_file() -> None: [ ("hello-world", False), ("hello-there", True), - ] + ], ) async def test_goto_anchor(anchor: str, found: bool) -> None: """Going to anchors should return a boolean: whether the anchor was found.""" @@ -140,3 +141,27 @@ async def test_goto_anchor(anchor: str, found: bool) -> None: async with MarkdownApp(document).run_test() as pilot: markdown = pilot.app.query_one(Markdown) assert markdown.goto_anchor(anchor) is found + + +async def test_update_of_document_posts_table_of_content_update_message() -> None: + """Updating the document should post a TableOfContentsUpdated message.""" + + messages: list[str] = [] + + class TableOfContentApp(App[None]): + + def compose(self) -> ComposeResult: + yield Markdown("# One\n\n#Two\n") + + @on(Markdown.TableOfContentsUpdated) + def log_table_of_content_update( + self, event: Markdown.TableOfContentsUpdated + ) -> None: + nonlocal messages + messages.append(event.__class__.__name__) + + async with TableOfContentApp().run_test() as pilot: + assert messages == ["TableOfContentsUpdated"] + pilot.app.query_one(Markdown).update("") + await pilot.pause() + assert messages == ["TableOfContentsUpdated", "TableOfContentsUpdated"] diff --git a/tests/test_message_pump.py b/tests/test_message_pump.py index c6f9d921ca..a25bd53a79 100644 --- a/tests/test_message_pump.py +++ b/tests/test_message_pump.py @@ -3,6 +3,7 @@ from textual.app import App, ComposeResult from textual.errors import DuplicateKeyHandlers from textual.events import Key +from textual.message import Message from textual.widget import Widget from textual.widgets import Input @@ -70,6 +71,25 @@ def on_input_changed(self, event: Input.Changed) -> None: self.input_changed_events.append(event) +async def test_message_queue_size(): + """Test message queue size property.""" + app = App() + assert app.message_queue_size == 0 + + class TestMessage(Message): + pass + + async with app.run_test() as pilot: + assert app.message_queue_size == 0 + app.post_message(TestMessage()) + assert app.message_queue_size == 1 + app.post_message(TestMessage()) + assert app.message_queue_size == 2 + # A pause will process all the messages + await pilot.pause() + assert app.message_queue_size == 0 + + async def test_prevent() -> None: app = PreventTestApp() diff --git a/tests/test_pilot.py b/tests/test_pilot.py index 0650c258a9..d436f8b5fc 100644 --- a/tests/test_pilot.py +++ b/tests/test_pilot.py @@ -6,7 +6,6 @@ from textual.app import App, ComposeResult from textual.binding import Binding from textual.containers import Center, Middle -from textual.events import MouseDown, MouseUp from textual.pilot import OutOfBounds from textual.widgets import Button, Label @@ -352,3 +351,15 @@ async def test_pilot_target_screen_always_true(method, offset): async with app.run_test(size=(80, 24)) as pilot: pilot_method = getattr(pilot, method) assert (await pilot_method(offset=offset)) is True + + +async def test_pilot_resize_terminal(): + app = App() + async with app.run_test(size=(80, 24)) as pilot: + # Sanity checks. + assert app.size == (80, 24) + assert app.screen.size == (80, 24) + await pilot.resize_terminal(27, 15) + await pilot.pause() + assert app.size == (27, 15) + assert app.screen.size == (27, 15) diff --git a/tests/test_progress_bar.py b/tests/test_progress_bar.py index bc7f799196..0e9663bf30 100644 --- a/tests/test_progress_bar.py +++ b/tests/test_progress_bar.py @@ -48,11 +48,9 @@ def test_progress_overflow(): pb = ProgressBar(total=100) pb.advance(999_999) - assert pb.progress == 100 assert pb.percentage == 1 pb.update(total=50) - assert pb.progress == 50 assert pb.percentage == 1 @@ -60,7 +58,6 @@ def test_progress_underflow(): pb = ProgressBar(total=100) pb.advance(-999_999) - assert pb.progress == 0 assert pb.percentage == 0 diff --git a/tests/test_query.py b/tests/test_query.py index d09599cdf2..19aac12dbe 100644 --- a/tests/test_query.py +++ b/tests/test_query.py @@ -11,7 +11,7 @@ WrongType, ) from textual.widget import Widget -from textual.widgets import Label +from textual.widgets import Input, Label def test_query(): @@ -244,7 +244,7 @@ async def test_query_set_styles_invalid_css_raises_error(): app = App() async with app.run_test(): with pytest.raises(DeclarationError): - app.query(Widget).set_styles(css="random_rule: 1fr;") + app.query(Widget).set_styles(css="random-rule: 1fr;") async def test_query_set_styles_kwds(): @@ -301,7 +301,7 @@ async def test_query_refresh(args): refreshes = [] class MyWidget(Widget): - def refresh(self, *, repaint=None, layout=None): + def refresh(self, *, repaint=None, layout=None, recompose=None): super().refresh(repaint=repaint, layout=layout) refreshes.append((repaint, layout)) @@ -313,3 +313,33 @@ def compose(self): async with app.run_test() as pilot: app.query(MyWidget).refresh(repaint=args[0], layout=args[1]) assert refreshes[-1] == args + + +async def test_query_focus_blur(): + class FocusApp(App): + AUTO_FOCUS = None + + def compose(self) -> ComposeResult: + yield Input(id="foo") + yield Input(id="bar") + yield Input(id="baz") + + app = FocusApp() + async with app.run_test() as pilot: + # Nothing focused + assert app.focused is None + # Focus first input + app.query(Input).focus() + await pilot.pause() + assert app.focused.id == "foo" + # Blur inputs + app.query(Input).blur() + await pilot.pause() + assert app.focused is None + # Focus another + app.query("#bar").focus() + await pilot.pause() + assert app.focused.id == "bar" + # Focus non existing + app.query("#egg").focus() + assert app.focused.id == "bar" diff --git a/tests/test_reactive.py b/tests/test_reactive.py index 8ee7861a2a..8a22d70a13 100644 --- a/tests/test_reactive.py +++ b/tests/test_reactive.py @@ -5,6 +5,8 @@ import pytest from textual.app import App, ComposeResult +from textual.message import Message +from textual.message_pump import MessagePump from textual.reactive import Reactive, TooManyComputesError, reactive, var from textual.widget import Widget @@ -575,3 +577,170 @@ 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"] + + +async def test_message_sender_from_reactive() -> None: + """Test that the sender of a message comes from the reacting widget.""" + + message_senders: list[MessagePump | None] = [] + + class TestWidget(Widget): + test_var: var[int] = var(0, init=False) + + class TestMessage(Message): + pass + + def watch_test_var(self) -> None: + self.post_message(self.TestMessage()) + + def make_reaction(self) -> None: + self.test_var += 1 + + class TestContainer(Widget): + def compose(self) -> ComposeResult: + yield TestWidget() + + def on_test_widget_test_message(self, event: TestWidget.TestMessage) -> None: + nonlocal message_senders + message_senders.append(event._sender) + + class TestApp(App[None]): + + def compose(self) -> ComposeResult: + yield TestContainer() + + async with TestApp().run_test() as pilot: + assert message_senders == [] + pilot.app.query_one(TestWidget).make_reaction() + await pilot.pause() + assert message_senders == [pilot.app.query_one(TestWidget)] diff --git a/tests/test_signal.py b/tests/test_signal.py new file mode 100644 index 0000000000..7833ae70f4 --- /dev/null +++ b/tests/test_signal.py @@ -0,0 +1,75 @@ +import pytest + +from textual.app import App, ComposeResult +from textual.signal import Signal, SignalError +from textual.widgets import Label + + +async def test_signal(): + """Test signal subscribe""" + called = 0 + + class TestLabel(Label): + def on_mount(self) -> None: + def signal_result(): + nonlocal called + called += 1 + + assert isinstance(self.app, TestApp) + self.app.test_signal.subscribe(self, signal_result) + + class TestApp(App): + BINDINGS = [("space", "signal")] + + def __init__(self) -> None: + self.test_signal = Signal(self, "coffee ready") + super().__init__() + + def compose(self) -> ComposeResult: + yield TestLabel() + + def action_signal(self) -> None: + self.test_signal.publish() + + app = TestApp() + async with app.run_test() as pilot: + # Check default called is 0 + assert called == 0 + # Action should publish signal + await pilot.press("space") + assert called == 1 + # Check a second time + await pilot.press("space") + assert called == 2 + # Removed the owner object + await app.query_one(TestLabel).remove() + # Check nothing is called + await pilot.press("space") + assert called == 2 + # Add a new test label + await app.mount(TestLabel()) + # Check callback again + await pilot.press("space") + assert called == 3 + # Unsubscribe + app.test_signal.unsubscribe(app.query_one(TestLabel)) + # Check nothing to update + await pilot.press("space") + assert called == 3 + + +def test_signal_errors(): + """Check exceptions raised by Signal class.""" + app = App() + test_signal = Signal(app, "test") + label = Label() + # Check subscribing a non-running widget is an error + with pytest.raises(SignalError): + test_signal.subscribe(label, lambda: None) + + +def test_repr(): + """Check the repr doesn't break.""" + app = App() + test_signal = Signal(app, "test") + assert isinstance(repr(test_signal), str) diff --git a/tests/test_suspend.py b/tests/test_suspend.py new file mode 100644 index 0000000000..8f4534dce1 --- /dev/null +++ b/tests/test_suspend.py @@ -0,0 +1,63 @@ +import sys + +import pytest + +from textual.app import App, SuspendNotSupported +from textual.drivers.headless_driver import HeadlessDriver + + +async def test_suspend_not_supported() -> None: + """Suspending when not supported should raise an error.""" + async with App().run_test() as pilot: + # Pilot uses the headless driver, the headless driver doesn't + # support suspend, and so... + with pytest.raises(SuspendNotSupported): + with pilot.app.suspend(): + pass + + +async def test_suspend_supported(capfd: pytest.CaptureFixture[str]) -> None: + """Suspending when supported should call the relevant driver methods.""" + + calls: set[str] = set() + + class HeadlessSuspendDriver(HeadlessDriver): + @property + def is_headless(self) -> bool: + return False + + @property + def can_suspend(self) -> bool: + return True + + def suspend_application_mode(self) -> None: + nonlocal calls + calls.add("suspend") + + def resume_application_mode(self) -> None: + nonlocal calls + calls.add("resume") + + class SuspendApp(App[None]): + def on_suspend(self) -> None: + nonlocal calls + calls.add("suspend signal") + + def on_resume(self) -> None: + nonlocal calls + calls.add("resume signal") + + def on_mount(self) -> None: + self.app_suspend_signal.subscribe(self, self.on_suspend) + self.app_resume_signal.subscribe(self, self.on_resume) + + async with SuspendApp(driver_class=HeadlessSuspendDriver).run_test( + headless=False + ) as pilot: + calls = set() + with pilot.app.suspend(): + _ = capfd.readouterr() # Clear the existing buffer. + print("USE THEM TOGETHER.", end="", flush=True) + print("USE THEM IN PEACE.", file=sys.stderr, end="", flush=True) + assert ("USE THEM TOGETHER.", "USE THEM IN PEACE.") == capfd.readouterr() + assert calls == {"suspend", "resume", "suspend signal", "resume signal"} diff --git a/tests/test_tabbed_content.py b/tests/test_tabbed_content.py index 5b67cc508a..57a8874ebf 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 @@ -24,6 +26,7 @@ def compose(self) -> ComposeResult: tabbed_content = app.query_one(TabbedContent) # Check first tab assert tabbed_content.active == "foo" + assert tabbed_content.active_pane.id == "foo" await pilot.pause() assert app.query_one("#foo-label").region assert not app.query_one("#bar-label").region @@ -32,6 +35,7 @@ def compose(self) -> ComposeResult: # Click second tab await pilot.click(f"Tab#{ContentTab.add_prefix('bar')}") assert tabbed_content.active == "bar" + assert tabbed_content.active_pane.id == "bar" await pilot.pause() assert not app.query_one("#foo-label").region assert app.query_one("#bar-label").region @@ -40,6 +44,7 @@ def compose(self) -> ComposeResult: # Click third tab await pilot.click(f"Tab#{ContentTab.add_prefix('baz')}") assert tabbed_content.active == "baz" + assert tabbed_content.active_pane.id == "baz" await pilot.pause() assert not app.query_one("#foo-label").region assert not app.query_one("#bar-label").region @@ -103,9 +108,33 @@ def compose(self) -> ComposeResult: with pytest.raises(ValueError): tabbed_content.active = "X" - # Check fail with empty tab - with pytest.raises(ValueError): - tabbed_content.active = "" + +async def test_unsetting_tabbed_content_active(): + """Check that setting `TabbedContent.active = ""` unsets active tab.""" + + messages = [] + + class TabbedApp(App[None]): + def compose(self) -> ComposeResult: + with TabbedContent(initial="bar"): + 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"): + yield Label("Baz", id="baz-label") + + def on_tabbed_content_cleared(self, event: TabbedContent.Cleared) -> None: + messages.append(event) + + app = TabbedApp() + async with app.run_test() as pilot: + tabbed_content = app.query_one(TabbedContent) + assert bool(tabbed_content.active) + tabbed_content.active = "" + await pilot.pause() + assert len(messages) == 1 + assert isinstance(messages[0], TabbedContent.Cleared) async def test_tabbed_content_initial(): @@ -134,13 +163,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"): @@ -149,15 +177,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(): @@ -859,3 +891,26 @@ def compose(self) -> ComposeResult: assert inner_tabs.active_tab is None assert tabbed_content.active == "outer1" + + +async def test_disabling_tab_within_tabbed_content_stays_isolated(): + """Disabling a tab within a tab pane should not affect the TabbedContent.""" + + class TabsNestedInTabbedContent(App): + def compose(self) -> ComposeResult: + with TabbedContent(): + with TabPane("TabbedContent", id="duplicate"): + yield Tabs( + Tab("Tab1", id="duplicate"), + Tab("Tab2", id="stay-enabled"), + id="test-tabs", + ) + + app = TabsNestedInTabbedContent() + async with app.run_test() as pilot: + assert app.query_one("Tab#duplicate").disabled is False + assert app.query_one("TabPane#duplicate").disabled is False + app.query_one("#test-tabs", Tabs).disable("duplicate") + await pilot.pause() + assert app.query_one("Tab#duplicate").disabled is True + assert app.query_one("TabPane#duplicate").disabled is False diff --git a/tests/test_tabs.py b/tests/test_tabs.py index 32e626cefb..8d865552d2 100644 --- a/tests/test_tabs.py +++ b/tests/test_tabs.py @@ -316,15 +316,8 @@ def compose(self) -> ComposeResult: assert tabs.active_tab.id == "tab-2" assert tabs.active == tabs.active_tab.id - # TODO: This one is questionable. It seems Tabs has been designed so - # that you can set the active tab to an empty string, and it remains - # so, and just removes the underline; no other changes. So active - # will be an empty string while active_tab will be a tab. This feels - # like an oversight. Need to investigate and possibly modify this - # behaviour unless there's a good reason for this. tabs.active = "" - assert tabs.active_tab is not None - assert tabs.active_tab.id == "tab-2" + assert tabs.active_tab is None async def test_navigate_tabs_with_keyboard(): diff --git a/tests/test_tooltips.py b/tests/test_tooltips.py new file mode 100644 index 0000000000..3648615148 --- /dev/null +++ b/tests/test_tooltips.py @@ -0,0 +1,118 @@ +"""Tests for the tooltips.""" + +from typing_extensions import Final + +from textual.app import App, ComposeResult +from textual.widgets import Static + + +class TooltipApp(App[None]): + + CSS = """ + Static { + width: 1fr; + height: 1fr; + } + """ + + @staticmethod + def tip(static: Static) -> Static: + static.tooltip = "This is a test tooltip" + return static + + def compose(self) -> ComposeResult: + yield Static(id="mr-pink") + yield self.tip(Static(id="mr-blue")) + + +TOOLTIP_TIMEOUT: Final[float] = 0.3 + 0.1 +"""How long to wait for a tooltip to appear. + +The 0.3 is the currently-hard-coded value in Screen, and the 0.1 is a bit of +wiggle room. +""" + + +async def test_no_tip_gets_no_tooltip() -> None: + """If there is no tooltip, none should show.""" + async with TooltipApp().run_test(tooltips=True) as pilot: + assert pilot.app.query_one("#textual-tooltip").display is False + await pilot.hover("#mr-pink") + assert pilot.app.query_one("#textual-tooltip").display is False + await pilot.pause(TOOLTIP_TIMEOUT) + assert pilot.app.query_one("#textual-tooltip").display is False + + +async def test_tip_gets_a_tooltip() -> None: + """If there is a tooltip, it should show.""" + async with TooltipApp().run_test(tooltips=True) as pilot: + assert pilot.app.query_one("#textual-tooltip").display is False + await pilot.hover("#mr-blue") + assert pilot.app.query_one("#textual-tooltip").display is False + await pilot.pause(TOOLTIP_TIMEOUT) + assert pilot.app.query_one("#textual-tooltip").display is True + + +async def test_mouse_move_removes_a_tooltip() -> None: + """If there is a mouse move when there is a tooltip, it should disappear.""" + async with TooltipApp().run_test(tooltips=True) as pilot: + assert pilot.app.query_one("#textual-tooltip").display is False + await pilot.hover("#mr-blue") + assert pilot.app.query_one("#textual-tooltip").display is False + await pilot.pause(TOOLTIP_TIMEOUT) + assert pilot.app.query_one("#textual-tooltip").display is True + await pilot.hover("#mr-pink") + await pilot.pause(TOOLTIP_TIMEOUT) + assert pilot.app.query_one("#textual-tooltip").display is False + + +async def test_removing_tipper_should_remove_tooltip() -> None: + """If the tipping widget is removed, it should remove the tooltip.""" + async with TooltipApp().run_test(tooltips=True) as pilot: + assert pilot.app.query_one("#textual-tooltip").display is False + await pilot.hover("#mr-blue") + assert pilot.app.query_one("#textual-tooltip").display is False + await pilot.pause(TOOLTIP_TIMEOUT) + assert pilot.app.query_one("#textual-tooltip").display is True + await pilot.app.query_one("#mr-blue").remove() + await pilot.pause() + assert pilot.app.query_one("#textual-tooltip").display is False + + +async def test_making_tipper_invisible_should_remove_tooltip() -> None: + """If the tipping widget is made invisible, it should remove the tooltip.""" + async with TooltipApp().run_test(tooltips=True) as pilot: + assert pilot.app.query_one("#textual-tooltip").display is False + await pilot.hover("#mr-blue") + assert pilot.app.query_one("#textual-tooltip").display is False + await pilot.pause(TOOLTIP_TIMEOUT) + assert pilot.app.query_one("#textual-tooltip").display is True + pilot.app.query_one("#mr-blue").visible = False + await pilot.pause() + assert pilot.app.query_one("#textual-tooltip").display is False + + +async def test_making_tipper_not_displayed_should_remove_tooltip() -> None: + """If the tipping widget is made display none, it should remove the tooltip.""" + async with TooltipApp().run_test(tooltips=True) as pilot: + assert pilot.app.query_one("#textual-tooltip").display is False + await pilot.hover("#mr-blue") + assert pilot.app.query_one("#textual-tooltip").display is False + await pilot.pause(TOOLTIP_TIMEOUT) + assert pilot.app.query_one("#textual-tooltip").display is True + pilot.app.query_one("#mr-blue").display = False + await pilot.pause() + assert pilot.app.query_one("#textual-tooltip").display is False + + +async def test_making_tipper_shuffle_away_should_remove_tooltip() -> None: + """If the tipping widget moves from under the cursor, it should remove the tooltip.""" + async with TooltipApp().run_test(tooltips=True) as pilot: + assert pilot.app.query_one("#textual-tooltip").display is False + await pilot.hover("#mr-blue") + assert pilot.app.query_one("#textual-tooltip").display is False + await pilot.pause(TOOLTIP_TIMEOUT) + assert pilot.app.query_one("#textual-tooltip").display is True + await pilot.app.mount(Static(id="mr-brown"), before="#mr-blue") + await pilot.pause() + assert pilot.app.query_one("#textual-tooltip").display is False diff --git a/tests/test_widget.py b/tests/test_widget.py index 82524ac11f..182de27b7a 100644 --- a/tests/test_widget.py +++ b/tests/test_widget.py @@ -1,3 +1,5 @@ +from operator import attrgetter + import pytest from rich.text import Text @@ -9,7 +11,7 @@ from textual.css.query import NoMatches from textual.geometry import Offset, Size from textual.message import Message -from textual.widget import MountError, PseudoClasses, Widget +from textual.widget import BadWidgetName, MountError, PseudoClasses, Widget from textual.widgets import Label, LoadingIndicator @@ -436,3 +438,87 @@ def render(self) -> str: render_result = widget._render() assert isinstance(render_result, Text) assert render_result.plain == "Hello World!" + + +async def test_sort_children() -> None: + """Test the sort_children method.""" + + class SortApp(App): + + def compose(self) -> ComposeResult: + with Container(id="container"): + yield Label("three", id="l3") + yield Label("one", id="l1") + yield Label("four", id="l4") + yield Label("two", id="l2") + + app = SortApp() + async with app.run_test(): + container = app.query_one("#container", Container) + assert [label.id for label in container.query(Label)] == [ + "l3", + "l1", + "l4", + "l2", + ] + container.sort_children(key=attrgetter("id")) + assert [label.id for label in container.query(Label)] == [ + "l1", + "l2", + "l3", + "l4", + ] + container.sort_children(key=attrgetter("id"), reverse=True) + assert [label.id for label in container.query(Label)] == [ + "l4", + "l3", + "l2", + "l1", + ] + + +async def test_sort_children_no_key() -> None: + """Test sorting with no key.""" + + class SortApp(App): + + def compose(self) -> ComposeResult: + with Container(id="container"): + yield Label("three", id="l3") + yield Label("one", id="l1") + yield Label("four", id="l4") + yield Label("two", id="l2") + + app = SortApp() + async with app.run_test(): + container = app.query_one("#container", Container) + assert [label.id for label in container.query(Label)] == [ + "l3", + "l1", + "l4", + "l2", + ] + # Without a key, the sort order is the order children were instantiated + container.sort_children() + assert [label.id for label in container.query(Label)] == [ + "l3", + "l1", + "l4", + "l2", + ] + container.sort_children(reverse=True) + assert [label.id for label in container.query(Label)] == [ + "l2", + "l4", + "l1", + "l3", + ] + + +def test_bad_widget_name_raised() -> None: + """Ensure error is raised when bad class names are used for widgets.""" + + with pytest.raises(BadWidgetName): + + class lowercaseWidget(Widget): + pass 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/test_widget_removing.py b/tests/test_widget_removing.py index ddb9dae16a..7ffb624d77 100644 --- a/tests/test_widget_removing.py +++ b/tests/test_widget_removing.py @@ -141,6 +141,63 @@ async def test_widget_remove_children_container(): assert len(container.children) == 0 +async def test_widget_remove_children_with_star_selector(): + app = ExampleApp() + async with app.run_test(): + container = app.query_one(Vertical) + + # 6 labels in total, with 5 of them inside the container. + assert len(app.query(Label)) == 6 + assert len(container.children) == 5 + + await container.remove_children("*") + + # The labels inside the container are gone, and the 1 outside remains. + assert len(app.query(Label)) == 1 + assert len(container.children) == 0 + + +async def test_widget_remove_children_with_string_selector(): + app = ExampleApp() + async with app.run_test(): + container = app.query_one(Vertical) + + # 6 labels in total, with 5 of them inside the container. + assert len(app.query(Label)) == 6 + assert len(container.children) == 5 + + await app.screen.remove_children("Label") + + # Only the Screen > Label widget is gone, everything else remains. + assert len(app.query(Button)) == 1 + assert len(app.query(Vertical)) == 1 + assert len(app.query(Label)) == 5 + + +async def test_widget_remove_children_with_type_selector(): + app = ExampleApp() + async with app.run_test(): + assert len(app.query(Button)) == 1 # Sanity check. + await app.screen.remove_children(Button) + assert len(app.query(Button)) == 0 + + +async def test_widget_remove_children_with_selector_does_not_leak(): + app = ExampleApp() + async with app.run_test(): + container = app.query_one(Vertical) + + # 6 labels in total, with 5 of them inside the container. + assert len(app.query(Label)) == 6 + assert len(container.children) == 5 + + await container.remove_children("Label") + + # The labels inside the container are gone, and the 1 outside remains. + assert len(app.query(Label)) == 1 + assert len(container.children) == 0 + + async def test_widget_remove_children_no_children(): app = ExampleApp() async with app.run_test(): @@ -154,3 +211,17 @@ async def test_widget_remove_children_no_children(): assert ( count_before == count_after ) # No widgets have been removed, since Button has no children. + + +async def test_widget_remove_children_no_children_match_selector(): + app = ExampleApp() + async with app.run_test(): + container = app.query_one(Vertical) + assert len(container.query("Button")) == 0 # Sanity check. + + count_before = len(app.query("*")) + container_children_before = list(container.children) + await container.remove_children("Button") + + assert count_before == len(app.query("*")) + assert container_children_before == list(container.children) diff --git a/tests/test_wrap.py b/tests/test_wrap.py new file mode 100644 index 0000000000..67de871511 --- /dev/null +++ b/tests/test_wrap.py @@ -0,0 +1,43 @@ +import pytest + +from textual._wrap import chunks, compute_wrap_offsets + + +@pytest.mark.parametrize( + "input_text, expected_output", + [ + ("", []), + (" ", [(0, 4, " ")]), + ("\t", [(0, 1, "\t")]), + ("foo", [(0, 3, "foo")]), + (" foo ", [(0, 2, " "), (2, 7, "foo ")]), + ("foo bar", [(0, 4, "foo "), (4, 7, "bar")]), + ("\tfoo bar", [(0, 1, "\t"), (1, 5, "foo "), (5, 8, "bar")]), + (" foo bar", [(0, 1, " "), (1, 5, "foo "), (5, 8, "bar")]), + ("foo bar ", [(0, 4, "foo "), (4, 10, "bar ")]), + ("foo\t bar ", [(0, 6, "foo\t "), (6, 12, "bar ")]), + ("木\t 川 ", [(0, 4, "木\t "), (4, 8, "川 ")]), + ], +) +def test_chunks(input_text, expected_output): + assert list(chunks(input_text)) == expected_output + + +@pytest.mark.parametrize( + "text, width, tab_size, expected_output", + [ + ("", 6, 4, []), + ("\t", 6, 4, []), + (" ", 6, 4, []), + ("foo bar baz", 6, 4, [4, 8]), + ("\tfoo bar baz", 6, 4, [1, 5, 9]), + ("\tfo bar baz", 6, 4, [1, 4, 8]), + ("\tfo bar baz", 6, 8, [1, 4, 8]), + ("\tfo bar baz\t", 6, 8, [1, 4, 8]), + ("\t\t\tfo bar baz\t", 20, 4, [10]), + ("\t\t\t\t\t\t\t\tfo bar bar", 19, 4, [4, 11]), + ("\t\t\t\t\t", 19, 4, [4]), + ], +) +def test_compute_wrap_offsets(text, width, tab_size, expected_output): + assert compute_wrap_offsets(text, width, tab_size) == expected_output diff --git a/tests/text_area/test_code_editor.py b/tests/text_area/test_code_editor.py new file mode 100644 index 0000000000..7e72c758b0 --- /dev/null +++ b/tests/text_area/test_code_editor.py @@ -0,0 +1,18 @@ +import inspect + +from textual.widgets import TextArea + + +def test_code_editor_parameters_kept_up_to_date(): + """Meta test to ensure the `TextArea.code_editor` convenience constructor + is kept up to date with changes to the `TextArea.__init__` parameters. + """ + text_area_params = inspect.signature(TextArea.__init__).parameters + code_editor_params = inspect.signature(TextArea.code_editor).parameters + expected_diffs = ["theme", "soft_wrap", "tab_behavior", "show_line_numbers"] + for param in text_area_params: + if param == "self": + continue + assert param in code_editor_params + if param not in expected_diffs: + assert code_editor_params[param] == text_area_params[param] 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 aa99a63ad9..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 @@ -29,7 +31,7 @@ class TextAreaApp(App): def compose(self) -> ComposeResult: - text_area = TextArea() + text_area = TextArea.code_editor() text_area.load_text(TEXT) yield text_area @@ -219,8 +221,8 @@ async def test_delete_line_multiline_document(selection, expected_result): await pilot.press("ctrl+x") - cursor_row, _ = text_area.cursor_location - assert text_area.selection == Selection.cursor((cursor_row, 0)) + cursor_row, cursor_column = text_area.cursor_location + assert text_area.selection == Selection.cursor((cursor_row, cursor_column)) assert text_area.text == expected_result @@ -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_issue_4301.py b/tests/text_area/test_issue_4301.py new file mode 100644 index 0000000000..b149a5f360 --- /dev/null +++ b/tests/text_area/test_issue_4301.py @@ -0,0 +1,38 @@ +from itertools import product + +import pytest + +from textual.app import App, ComposeResult +from textual.widgets import TextArea +from textual.widgets.text_area import Selection + +TEST_TEXT = "\n".join(f"01234567890 - {n}" for n in range(10)) +TEST_SELECTED_TEXT = "567890 - 0\n01234567890 - 1\n01234" + + +class TextAreaApp(App[None]): + + def compose(self) -> ComposeResult: + yield TextArea(TEST_TEXT) + + +@pytest.mark.parametrize( + "selection, edit", + product( + [Selection((0, 5), (2, 5)), Selection((2, 5), (0, 5))], + ["A", "delete", "backspace"], + ), +) +async def test_issue_4301_reproduction(selection: Selection, edit: str) -> None: + """Test https://github.com/Textualize/textual/issues/4301""" + + async with (app := TextAreaApp()).run_test() as pilot: + assert app.query_one(TextArea).text == TEST_TEXT + app.query_one(TextArea).selection = selection + assert app.query_one(TextArea).selected_text == TEST_SELECTED_TEXT + await pilot.press(edit) + await pilot.press("ctrl+z") + assert app.query_one(TextArea).text == TEST_TEXT + # Note that the real test here is that the following code doesn't + # result in a crash; everything above should really be a given. + await pilot.press(*(["down"] * 10)) diff --git a/tests/text_area/test_languages.py b/tests/text_area/test_languages.py index 7e33fcf728..ad3d4d0799 100644 --- a/tests/text_area/test_languages.py +++ b/tests/text_area/test_languages.py @@ -2,7 +2,11 @@ from textual.app import App, ComposeResult from textual.widgets import TextArea -from textual.widgets.text_area import LanguageDoesNotExist +from textual.widgets.text_area import ( + Document, + LanguageDoesNotExist, + SyntaxAwareDocument, +) class TextAreaApp(App): @@ -96,3 +100,28 @@ async def test_register_language_existing_language(): # We've overridden the highlight query with a blank one, so there are no highlights. assert text_area._highlights == {} + + +@pytest.mark.syntax +async def test_language_binary_missing(monkeypatch: pytest.MonkeyPatch): + # mock a failed installation of tree-sitter-language binaries by + # raising an OSError from get_language + def raise_oserror(_): + raise OSError( + "/path/to/tree_sitter_languages/languages.so: " + "cannot open shared object file: No such file or directory" + ) + + monkeypatch.setattr( + "textual.document._syntax_aware_document.get_language", raise_oserror + ) + + app = TextAreaApp() + async with app.run_test(): + text_area = app.query_one(TextArea) # does not crash + assert text_area.language == "python" + # resulting document is not a SyntaxAwareDocument and does not + # support highlights + assert isinstance(text_area.document, Document) + assert not isinstance(text_area.document, SyntaxAwareDocument) + assert text_area._highlights == {} diff --git a/tests/text_area/test_messages.py b/tests/text_area/test_messages.py index c6ddbe5a4d..6f91fb0db2 100644 --- a/tests/text_area/test_messages.py +++ b/tests/text_area/test_messages.py @@ -64,6 +64,19 @@ async def test_changed_message_via_typing(): ] +async def test_changed_message_edit_via_assignment(): + app = TextAreaApp() + async with app.run_test() as pilot: + text_area = app.query_one(TextArea) + assert get_changed_messages(app.messages) == [] + + text_area.text = "" + await pilot.pause() + + assert get_changed_messages(app.messages) == [TextArea.Changed(text_area)] + assert get_selection_changed_messages(app.messages) == [] + + async def test_selection_changed_via_api(): app = TextAreaApp() async with app.run_test() as pilot: diff --git a/tests/text_area/test_selection.py b/tests/text_area/test_selection.py index bbc70e476e..8b5424d2fb 100644 --- a/tests/text_area/test_selection.py +++ b/tests/text_area/test_selection.py @@ -301,36 +301,36 @@ async def test_select_line(index, content, expected_selection): async def test_cursor_screen_offset_and_terminal_cursor_position_update(): class TextAreaCursorScreenOffset(App): def compose(self) -> ComposeResult: - yield TextArea("abc\ndef") + yield TextArea.code_editor("abc\ndef") app = TextAreaCursorScreenOffset() async with app.run_test(): text_area = app.query_one(TextArea) - assert app.cursor_position == (3, 0) + assert app.cursor_position == (5, 1) text_area.cursor_location = (1, 1) - assert text_area.cursor_screen_offset == (4, 1) + assert text_area.cursor_screen_offset == (6, 2) # Also ensure that this update has been reported back to the app # for the benefit of IME/emoji popups. - assert app.cursor_position == (4, 1) + assert app.cursor_position == (6, 2) async def test_cursor_screen_offset_and_terminal_cursor_position_scrolling(): class TextAreaCursorScreenOffset(App): def compose(self) -> ComposeResult: - yield TextArea("AB\nAB\nAB\nAB\nAB\nAB\n") + yield TextArea.code_editor("AB\nAB\nAB\nAB\nAB\nAB\n") app = TextAreaCursorScreenOffset() async with app.run_test(size=(80, 2)) as pilot: text_area = app.query_one(TextArea) - assert app.cursor_position == (3, 0) + assert app.cursor_position == (5, 1) text_area.cursor_location = (5, 0) await pilot.pause() - assert text_area.cursor_screen_offset == (3, 1) - assert app.cursor_position == (3, 1) + assert text_area.cursor_screen_offset == (5, 1) + assert app.cursor_position == (5, 1) diff --git a/tests/text_area/test_selection_bindings.py b/tests/text_area/test_selection_bindings.py index 76d4586df4..06b180485e 100644 --- a/tests/text_area/test_selection_bindings.py +++ b/tests/text_area/test_selection_bindings.py @@ -3,7 +3,7 @@ from textual.app import App, ComposeResult from textual.geometry import Offset from textual.widgets import TextArea -from textual.widgets.text_area import Document, Selection +from textual.widgets.text_area import Selection TEXT = """I must not fear. Fear is the mind-killer. @@ -13,38 +13,45 @@ class TextAreaApp(App): + def __init__(self, read_only: bool = False): + super().__init__() + self.read_only = read_only + def compose(self) -> ComposeResult: - text_area = TextArea() - 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((2, 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)) - assert text_area.selection == Selection.cursor((3, 0)) + assert text_area.selection == Selection.cursor((2, 0)) async def test_cursor_movement_basic(): @@ -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,37 +246,38 @@ 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) text_area.selection = Selection.cursor((0, 1)) await pilot.press("pagedown") - assert text_area.selection == Selection.cursor((app.console.height - 1, 1)) + margin = 2 + assert text_area.selection == Selection.cursor( + (app.console.height - 1 - margin, 1) + ) -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) text_area.selection = Selection.cursor((100, 1)) await pilot.press("pageup") + margin = 2 assert text_area.selection == Selection.cursor( - (100 - app.console.height + 1, 1) + (100 - app.console.height + 1 + margin, 1) ) -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.load_document(Document("こんにちは\n012345")) + text_area.text = "こんにちは\n012345" text_area.move_cursor((1, 3), record_width=True) # The '3' is aligned with ん at (0, 1) @@ -297,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)) @@ -308,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 diff --git a/tests/tree/test_tree_messages.py b/tests/tree/test_tree_messages.py index 31de55e543..1262721100 100644 --- a/tests/tree/test_tree_messages.py +++ b/tests/tree/test_tree_messages.py @@ -3,7 +3,8 @@ from typing import Any from textual.app import App, ComposeResult -from textual.widgets import Tree +from textual.containers import Vertical +from textual.widgets import Button, Tree class MyTree(Tree[None]): @@ -27,10 +28,12 @@ def on_mount(self) -> None: def record( self, - event: Tree.NodeSelected[None] - | Tree.NodeExpanded[None] - | Tree.NodeCollapsed[None] - | Tree.NodeHighlighted[None], + event: ( + Tree.NodeSelected[None] + | Tree.NodeExpanded[None] + | Tree.NodeCollapsed[None] + | Tree.NodeHighlighted[None] + ), ) -> None: self.messages.append( (event.__class__.__name__, event.node.tree.id or "Unknown") @@ -152,3 +155,75 @@ async def test_tree_node_highlighted_message() -> None: ("NodeSelected", "test-tree"), ("NodeHighlighted", "test-tree"), ] + + +class TreeWrapper(Vertical): + """Testing widget related to https://github.com/Textualize/textual/issues/3869""" + + def __init__(self, auto_expand: bool) -> None: + super().__init__() + self._auto_expand = auto_expand + + def compose(self) -> ComposeResult: + """Compose the child widgets.""" + yield Button(id="expander") + yield Button(id="collapser") + yield MyTree("Root", id="test-tree") + + def on_mount(self) -> None: + self.query_one(MyTree).auto_expand = self._auto_expand + self.query_one(MyTree).root.add("Child") + + def on_button_pressed(self, event: Button.Pressed) -> None: + if event.button.id == "expander": + self.query_one(Tree).root.expand() + elif event.button.id == "collapser": + self.query_one(Tree).root.collapse() + + +class TreeViaCodeApp(App[None]): + """Testing app related to https://github.com/Textualize/textual/issues/3869""" + + def __init__(self, auto_expand: bool, *args: Any, **kwargs: Any) -> None: + super().__init__(*args, **kwargs) + self.messages: list[tuple[str, str]] = [] + self._auto_expand = auto_expand + + def compose(self) -> ComposeResult: + """Compose the child widgets.""" + yield TreeWrapper(self._auto_expand) + + def record( + self, + event: ( + Tree.NodeExpanded[None] + | Tree.NodeCollapsed[None] + | Tree.NodeHighlighted[None] + ), + ) -> None: + self.messages.append( + (event.__class__.__name__, event.node.tree.id or "Unknown") + ) + + def on_tree_node_expanded(self, event: Tree.NodeExpanded[None]) -> None: + self.record(event) + + def on_tree_node_collapsed(self, event: Tree.NodeCollapsed[None]) -> None: + self.record(event) + + def on_tree_node_highlighted(self, event: Tree.NodeHighlighted[None]) -> None: + self.record(event) + + +async def test_expand_node_from_code() -> None: + """Expanding a node from code should result in the appropriate message.""" + async with TreeViaCodeApp(False).run_test() as pilot: + await pilot.click("#expander") + assert pilot.app.messages == [("NodeExpanded", "test-tree")] + + +async def test_collapse_node_from_code() -> None: + """Collapsing a node from code should result in the appropriate message.""" + async with TreeViaCodeApp(True).run_test() as pilot: + await pilot.click("#collapser") + assert pilot.app.messages == [("NodeCollapsed", "test-tree")]