diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index ebf5a048a2..7bbae74e0b 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -6,6 +6,13 @@ on: pull_request: # The branches below must be a subset of the branches above branches: [ 'main' ] + paths: + - '.github/workflows/codeql.yml' + - '**.py' + - '**.pyi' + - '**.lock' + - '**.js' + - '**.ts' schedule: - cron: '10 20 * * 4' diff --git a/.github/workflows/pythonpackage.yml b/.github/workflows/pythonpackage.yml index 01a5639af0..02e367aa44 100644 --- a/.github/workflows/pythonpackage.yml +++ b/.github/workflows/pythonpackage.yml @@ -3,56 +3,50 @@ name: Test Textual module on: pull_request: paths: - - '.github/workflows/pythonpackage.yml' - - '**.py' - - '**.pyi' - - '**.css' - - '**.ambr' - - '**.lock' - - 'Makefile' + - ".github/workflows/pythonpackage.yml" + - "**.py" + - "**.pyi" + - "**.css" + - "**.ambr" + - "**.lock" + - "Makefile" jobs: build: runs-on: ${{ matrix.os }} strategy: matrix: - os: [ubuntu-latest, macos-latest, windows-latest] - python-version: ["3.7", "3.8", "3.9", "3.10", "3.11", "3.12"] + os: [ubuntu-latest, windows-latest, macos-latest] + python-version: ["3.8", "3.9", "3.10", "3.11", "3.12"] defaults: run: shell: bash steps: - - uses: actions/checkout@v3.5.2 - - name: Install and configure Poetry # This could be cached, too... - uses: snok/install-poetry@v1.3.3 - with: - version: 1.4.2 - virtualenvs-in-project: true + - uses: actions/checkout@v4.1.1 + - name: Install Poetry + run: pipx install poetry==1.7.1 - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v4.6.0 + uses: actions/setup-python@v4.7.1 with: python-version: ${{ matrix.python-version }} - architecture: x64 - allow-prereleases: true - - name: Load cached venv - id: cached-poetry-dependencies - uses: actions/cache@v3 - with: - path: .venv - key: venv-${{ runner.os }}-${{ matrix.python-version }}-${{ hashFiles('**/poetry.lock') }} + cache: 'poetry' - name: Install dependencies - run: poetry install - if: steps.cached-poetry-dependencies.outputs.cache-hit != 'true' -# - name: Typecheck with mypy -# run: | -# make typecheck + run: poetry install --no-interaction --extras syntax + if: ${{ matrix.python-version != '3.12' }} + - name: Install dependencies for 3.12 # https://github.com/Textualize/textual/issues/3491#issuecomment-1854156476 + run: poetry install --no-interaction + if: ${{ matrix.python-version == '3.12' }} - name: Test with pytest run: | - source $VENV - pytest tests -v --cov=./src/textual --cov-report=xml:./coverage.xml --cov-report term-missing + poetry run pytest tests -v --cov=./src/textual --cov-report=xml:./coverage.xml --cov-report term-missing + if: ${{ matrix.python-version != '3.12' }} + - name: Test with pytest for 3.12 # https://github.com/Textualize/textual/issues/3491#issuecomment-1854156476 + run: | + poetry run pytest tests -v --cov=./src/textual --cov-report=xml:./coverage.xml --cov-report term-missing -m 'not syntax' + if: ${{ matrix.python-version == '3.12' }} - name: Upload snapshot report if: always() uses: actions/upload-artifact@v3 with: name: snapshot-report-textual - path: tests/snapshot_tests/output/snapshot_report.html + path: snapshot_report.html diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index b68fd51211..39994d188c 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -4,10 +4,18 @@ repos: - repo: https://github.com/pre-commit/pre-commit-hooks rev: v4.3.0 hooks: - - id: trailing-whitespace - - id: end-of-file-fixer - - id: check-yaml - args: [ '--unsafe' ] + - id: check-ast # simply checks whether the files parse as valid python + - id: check-builtin-literals # requires literal syntax when initializing empty or zero python builtin types + - id: check-case-conflict # checks for files that would conflict in case-insensitive filesystems + - id: check-merge-conflict # checks for files that contain merge conflict strings + - id: check-json # checks json files for parseable syntax + - id: check-toml # checks toml files for parseable syntax + - id: check-yaml # checks yaml files for parseable syntax + args: [ '--unsafe' ] # Instead of loading the files, simply parse them for syntax. + - id: check-shebang-scripts-are-executable # ensures that (non-binary) files with a shebang are executable + - id: check-vcs-permalinks # ensures that links to vcs websites are permalinks + - 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 hooks: @@ -19,4 +27,10 @@ repos: rev: 23.1.0 hooks: - id: black + - repo: https://github.com/hadialqattan/pycln # removes unused imports + rev: v2.3.0 + hooks: + - id: pycln + language_version: "3.11" + args: [--all] exclude: ^tests/snapshot_tests diff --git a/CHANGELOG.md b/CHANGELOG.md index 6f56879e54..36b22172bd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,290 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](http://keepachangelog.com/) and this project adheres to [Semantic Versioning](http://semver.org/). +## [0.47.0] - 2024-01-04 + +### Fixed + +- `Widget.move_child` would break if `before`/`after` is set to the index of the widget in `child` https://github.com/Textualize/textual/issues/1743 +- Fixed auto width text not processing markup https://github.com/Textualize/textual/issues/3918 +- Fixed `Tree.clear` not retaining the root's expanded state https://github.com/Textualize/textual/issues/3557 + +### Changed + +- Breaking change: `Widget.move_child` parameters `before` and `after` are now keyword-only https://github.com/Textualize/textual/pull/3896 +- Style tweak to toasts https://github.com/Textualize/textual/pull/3955 + +### Added + +- Added textual.lazy https://github.com/Textualize/textual/pull/3936 +- Added App.push_screen_wait https://github.com/Textualize/textual/pull/3955 +- Added nesting of CSS https://github.com/Textualize/textual/pull/3946 + +## [0.46.0] - 2023-12-17 + +### Fixed + +- Disabled radio buttons could be selected with the keyboard https://github.com/Textualize/textual/issues/3839 +- Fixed zero width scrollbars causing content to disappear https://github.com/Textualize/textual/issues/3886 + +### Changed + +- The tabs within a `TabbedContent` now prefix their IDs to stop any clash with their associated `TabPane` https://github.com/Textualize/textual/pull/3815 +- Breaking change: `tab` is no longer a `@on` decorator selector for `TabbedContent.TabActivated` -- use `pane` instead https://github.com/Textualize/textual/pull/3815 + +### Added + +- Added `Collapsible.title` reactive attribute https://github.com/Textualize/textual/pull/3830 +- Added a `pane` attribute to `TabbedContent.TabActivated` https://github.com/Textualize/textual/pull/3815 +- Added caching of rules attributes and `cache` parameter to Stylesheet.apply https://github.com/Textualize/textual/pull/3880 + +## [0.45.1] - 2023-12-12 + +### Fixed + +- Fixed issues where styles wouldn't update if changed in mount. https://github.com/Textualize/textual/pull/3860 + +## [0.45.0] - 2023-12-12 + +### Fixed + +- Fixed `DataTable.update_cell` not raising an error with an invalid column key https://github.com/Textualize/textual/issues/3335 +- Fixed `Input` showing suggestions when not focused https://github.com/Textualize/textual/pull/3808 +- Fixed loading indicator not covering scrollbars https://github.com/Textualize/textual/pull/3816 + +### Removed + +- Removed renderables/align.py which was no longer used. + +### Changed + +- Dropped ALLOW_CHILDREN flag introduced in 0.43.0 https://github.com/Textualize/textual/pull/3814 +- Widgets with an auto height in an auto height container will now expand if they have no siblings https://github.com/Textualize/textual/pull/3814 +- Breaking change: Removed `limit_rules` from Stylesheet.apply https://github.com/Textualize/textual/pull/3844 + +### Added + +- Added `get_loading_widget` to Widget and App customize the loading widget. https://github.com/Textualize/textual/pull/3816 +- Added messages `Collapsible.Expanded` and `Collapsible.Collapsed` that inherit from `Collapsible.Toggled`. https://github.com/Textualize/textual/issues/3824 + +## [0.44.1] - 2023-12-4 + +### Fixed + +- Fixed slow scrolling when there are many widgets https://github.com/Textualize/textual/pull/3801 + +## [0.44.0] - 2023-12-1 + +### Changed + +- Breaking change: Dropped 3.7 support https://github.com/Textualize/textual/pull/3766 +- Breaking changes https://github.com/Textualize/textual/issues/1530 + - `link-hover-background` renamed to `link-background-hover` + - `link-hover-color` renamed to `link-color-hover` + - `link-hover-style` renamed to `link-style-hover` +- `Tree` now forces a scroll when `scroll_to_node` is called https://github.com/Textualize/textual/pull/3786 +- Brought rxvt's use of shift-numpad keys in line with most other terminals https://github.com/Textualize/textual/pull/3769 + +### Added + +- Added support for Ctrl+Fn and Ctrl+Shift+Fn keys in urxvt https://github.com/Textualize/textual/pull/3737 +- Friendly error messages when trying to mount non-widgets https://github.com/Textualize/textual/pull/3780 +- Added `Select.from_values` class method that can be used to initialize a Select control with an iterator of values https://github.com/Textualize/textual/pull/3743 + +### Fixed + +- Fixed NoWidget when mouse goes outside window https://github.com/Textualize/textual/pull/3790 +- Removed spurious print statements from press_keys https://github.com/Textualize/textual/issues/3785 + +## [0.43.2] - 2023-11-29 + +### Fixed + +- Fixed NoWidget error https://github.com/Textualize/textual/pull/3779 + +## [0.43.1] - 2023-11-29 + +### Fixed + +- Fixed clicking on scrollbar moves TextArea cursor https://github.com/Textualize/textual/issues/3763 + +## [0.43.0] - 2023-11-28 + +### Fixed + +- Fixed mouse targeting issue in `TextArea` when tabs were not fully expanded https://github.com/Textualize/textual/pull/3725 +- Fixed `Select` not updating after changing the `prompt` reactive https://github.com/Textualize/textual/issues/2983 +- Fixed flicker when updating Markdown https://github.com/Textualize/textual/pull/3757 + +### Added + +- Added experimental Canvas class https://github.com/Textualize/textual/pull/3669/ +- Added `keyline` rule https://github.com/Textualize/textual/pull/3669/ +- Widgets can now have an ALLOW_CHILDREN (bool) classvar to disallow adding children to a widget https://github.com/Textualize/textual/pull/3758 +- Added the ability to set the `label` property of a `Checkbox` https://github.com/Textualize/textual/pull/3765 +- Added the ability to set the `label` property of a `RadioButton` https://github.com/Textualize/textual/pull/3765 +- Added support for various modified edit and navigation keys in urxvt https://github.com/Textualize/textual/pull/3739 +- Added app focus/blur for textual-web https://github.com/Textualize/textual/pull/3767 + +### Changed + +- Method `MarkdownTableOfContents.set_table_of_contents` renamed to `MarkdownTableOfContents.rebuild_table_of_contents` https://github.com/Textualize/textual/pull/3730 +- Exception `Tree.UnknownNodeID` moved out of `Tree`, import from `textual.widgets.tree` https://github.com/Textualize/textual/pull/3730 +- Exception `TreeNode.RemoveRootError` moved out of `TreeNode`, import from `textual.widgets.tree` https://github.com/Textualize/textual/pull/3730 +- Optimized startup time https://github.com/Textualize/textual/pull/3753 +- App.COMMANDS or Screen.COMMANDS can now accept a callable which returns a command palette provider https://github.com/Textualize/textual/pull/3756 + +## [0.42.0] - 2023-11-22 + +### Fixed + +- Duplicate CSS errors when parsing CSS from a screen https://github.com/Textualize/textual/issues/3581 +- Added missing `blur` pseudo class https://github.com/Textualize/textual/issues/3439 +- Fixed visual glitched characters on Windows due to Python limitation https://github.com/Textualize/textual/issues/2548 +- Fixed `ScrollableContainer` to receive focus https://github.com/Textualize/textual/pull/3632 +- Fixed app-level queries causing a crash when the command palette is active https://github.com/Textualize/textual/issues/3633 +- Fixed outline not rendering correctly in some scenarios (e.g. on Button widgets) https://github.com/Textualize/textual/issues/3628 +- Fixed live-reloading of screen CSS https://github.com/Textualize/textual/issues/3454 +- `Select.value` could be in an invalid state https://github.com/Textualize/textual/issues/3612 +- Off-by-one in CSS error reporting https://github.com/Textualize/textual/issues/3625 +- Loading indicators and app notifications overlapped in the wrong order https://github.com/Textualize/textual/issues/3677 +- Widgets being loaded are disabled and have their scrolling explicitly disabled too https://github.com/Textualize/textual/issues/3677 +- Method render on a widget could be called before mounting said widget https://github.com/Textualize/textual/issues/2914 + +### Added + +- Exceptions to `textual.widgets.select` https://github.com/Textualize/textual/pull/3614 + - `InvalidSelectValueError` for when setting a `Select` to an invalid value + - `EmptySelectError` when creating/setting a `Select` to have no options when `allow_blank` is `False` +- `Select` methods https://github.com/Textualize/textual/pull/3614 + - `clear` + - `is_blank` +- Constant `Select.BLANK` to flag an empty selection https://github.com/Textualize/textual/pull/3614 +- Added `restrict`, `type`, `max_length`, and `valid_empty` to Input https://github.com/Textualize/textual/pull/3657 +- Added `Pilot.mouse_down` to simulate `MouseDown` events https://github.com/Textualize/textual/pull/3495 +- Added `Pilot.mouse_up` to simulate `MouseUp` events https://github.com/Textualize/textual/pull/3495 +- Added `Widget.is_mounted` property https://github.com/Textualize/textual/pull/3709 +- Added `TreeNode.refresh` https://github.com/Textualize/textual/pull/3639 + +### Changed + +- CSS error reporting will no longer provide links to the files in question https://github.com/Textualize/textual/pull/3582 +- inline CSS error reporting will report widget/class variable where the CSS was read from https://github.com/Textualize/textual/pull/3582 +- Breaking change: `Tree.refresh_line` has now become an internal https://github.com/Textualize/textual/pull/3639 +- Breaking change: Setting `Select.value` to `None` no longer clears the selection (See `Select.BLANK` and `Select.clear`) https://github.com/Textualize/textual/pull/3614 +- Breaking change: `Button` no longer inherits from `Static`, now it inherits directly from `Widget` https://github.com/Textualize/textual/issues/3603 +- Rich markup in markdown headings is now escaped when building the TOC https://github.com/Textualize/textual/issues/3689 +- Mechanics behind mouse clicks. See [this](https://github.com/Textualize/textual/pull/3495#issue-1934915047) for more details. https://github.com/Textualize/textual/pull/3495 +- Breaking change: max/min-width/height now includes padding and border. https://github.com/Textualize/textual/pull/3712 + +## [0.41.0] - 2023-10-31 + +### Fixed + +- Fixed `Input.cursor_blink` reactive not changing blink state after `Input` was mounted https://github.com/Textualize/textual/pull/3498 +- Fixed `Tabs.active` attribute value not being re-assigned after removing a tab or clearing https://github.com/Textualize/textual/pull/3498 +- Fixed `DirectoryTree` race-condition crash when changing path https://github.com/Textualize/textual/pull/3498 +- Fixed issue with `LRUCache.discard` https://github.com/Textualize/textual/issues/3537 +- Fixed `DataTable` not scrolling to rows that were just added https://github.com/Textualize/textual/pull/3552 +- Fixed cache bug with `DataTable.update_cell` https://github.com/Textualize/textual/pull/3551 +- Fixed CSS errors being repeated https://github.com/Textualize/textual/pull/3566 +- Fix issue with chunky highlights on buttons https://github.com/Textualize/textual/pull/3571 +- Fixed `OptionList` event leakage from `CommandPalette` to `App`. +- Fixed crash in `LoadingIndicator` https://github.com/Textualize/textual/pull/3498 +- Fixed crash when `Tabs` appeared as a descendant of `TabbedContent` in the DOM https://github.com/Textualize/textual/pull/3602 +- Fixed the command palette cancelling other workers https://github.com/Textualize/textual/issues/3615 + +### Added + +- Add Document `get_index_from_location` / `get_location_from_index` https://github.com/Textualize/textual/pull/3410 +- Add setter for `TextArea.text` https://github.com/Textualize/textual/discussions/3525 +- Added `key` argument to the `DataTable.sort()` method, allowing the table to be sorted using a custom function (or other callable) https://github.com/Textualize/textual/pull/3090 +- Added `initial` to all css rules, which restores default (i.e. value from DEFAULT_CSS) https://github.com/Textualize/textual/pull/3566 +- Added HorizontalPad to pad.py https://github.com/Textualize/textual/pull/3571 +- Added `AwaitComplete` class, to be used for optionally awaitable return values https://github.com/Textualize/textual/pull/3498 + +### Changed + +- Breaking change: `Button.ACTIVE_EFFECT_DURATION` classvar converted to `Button.active_effect_duration` attribute https://github.com/Textualize/textual/pull/3498 +- Breaking change: `Input.blink_timer` made private (renamed to `Input._blink_timer`) https://github.com/Textualize/textual/pull/3498 +- Breaking change: `Input.cursor_blink` reactive updated to not run on mount (now `init=False`) https://github.com/Textualize/textual/pull/3498 +- Breaking change: `AwaitTabbedContent` class removed https://github.com/Textualize/textual/pull/3498 +- Breaking change: `Tabs.remove_tab` now returns an `AwaitComplete` instead of an `AwaitRemove` https://github.com/Textualize/textual/pull/3498 +- Breaking change: `Tabs.clear` now returns an `AwaitComplete` instead of an `AwaitRemove` https://github.com/Textualize/textual/pull/3498 +- `TabbedContent.add_pane` now returns an `AwaitComplete` instead of an `AwaitTabbedContent` https://github.com/Textualize/textual/pull/3498 +- `TabbedContent.remove_pane` now returns an `AwaitComplete` instead of an `AwaitTabbedContent` https://github.com/Textualize/textual/pull/3498 +- `TabbedContent.clear_pane` now returns an `AwaitComplete` instead of an `AwaitTabbedContent` https://github.com/Textualize/textual/pull/3498 +- `Tabs.add_tab` now returns an `AwaitComplete` instead of an `AwaitMount` https://github.com/Textualize/textual/pull/3498 +- `DirectoryTree.reload` now returns an `AwaitComplete`, which may be awaited to ensure the node has finished being processed by the internal queue https://github.com/Textualize/textual/pull/3498 +- `Tabs.remove_tab` now returns an `AwaitComplete`, which may be awaited to ensure the tab is unmounted and internal state is updated https://github.com/Textualize/textual/pull/3498 +- `App.switch_mode` now returns an `AwaitMount`, which may be awaited to ensure the screen is mounted https://github.com/Textualize/textual/pull/3498 +- Buttons will now display multiple lines, and have auto height https://github.com/Textualize/textual/pull/3539 +- DataTable now has a max-height of 100vh rather than 100%, which doesn't work with auto +- Breaking change: empty rules now result in an error https://github.com/Textualize/textual/pull/3566 +- Improved startup time by caching CSS parsing https://github.com/Textualize/textual/pull/3575 +- Workers are now created/run in a thread-safe way https://github.com/Textualize/textual/pull/3586 + +## [0.40.0] - 2023-10-11 + +### Added + +- Added `loading` reactive property to widgets https://github.com/Textualize/textual/pull/3509 + +## [0.39.0] - 2023-10-10 + +### Fixed + +- `Pilot.click`/`Pilot.hover` can't use `Screen` as a selector https://github.com/Textualize/textual/issues/3395 +- App exception when a `Tree` is initialized/mounted with `disabled=True` https://github.com/Textualize/textual/issues/3407 +- Fixed `print` locations not being correctly reported in `textual console` https://github.com/Textualize/textual/issues/3237 +- Fix location of IME and emoji popups https://github.com/Textualize/textual/pull/3408 +- Fixed application freeze when pasting an emoji into an application on Windows https://github.com/Textualize/textual/issues/3178 +- Fixed duplicate option ID handling in the `OptionList` https://github.com/Textualize/textual/issues/3455 +- Fix crash when removing and updating DataTable cell at same time https://github.com/Textualize/textual/pull/3487 +- Fixed fractional styles to allow integer values https://github.com/Textualize/textual/issues/3414 +- Stop eating stdout/stderr in headless mode - print works again in tests https://github.com/Textualize/textual/pull/3486 + +### Added + +- `OutOfBounds` exception to be raised by `Pilot` https://github.com/Textualize/textual/pull/3360 +- `TextArea.cursor_screen_offset` property for getting the screen-relative position of the cursor https://github.com/Textualize/textual/pull/3408 +- `Input.cursor_screen_offset` property for getting the screen-relative position of the cursor https://github.com/Textualize/textual/pull/3408 +- Reactive `cell_padding` (and respective parameter) to define horizontal cell padding in data table columns https://github.com/Textualize/textual/issues/3435 +- Added `Input.clear` method https://github.com/Textualize/textual/pull/3430 +- Added `TextArea.SelectionChanged` and `TextArea.Changed` messages https://github.com/Textualize/textual/pull/3442 +- Added `wait_for_dismiss` parameter to `App.push_screen` https://github.com/Textualize/textual/pull/3477 +- Allow scrollbar-size to be set to 0 to achieve scrollable containers with no visible scrollbars https://github.com/Textualize/textual/pull/3488 + +### Changed + +- Breaking change: tree-sitter and tree-sitter-languages dependencies moved to `syntax` extra https://github.com/Textualize/textual/pull/3398 +- `Pilot.click`/`Pilot.hover` now raises `OutOfBounds` when clicking outside visible screen https://github.com/Textualize/textual/pull/3360 +- `Pilot.click`/`Pilot.hover` now return a Boolean indicating whether the click/hover landed on the widget that matches the selector https://github.com/Textualize/textual/pull/3360 +- Added a delay to when the `No Matches` message appears in the command palette, thus removing a flicker https://github.com/Textualize/textual/pull/3399 +- Timer callbacks are now typed more loosely https://github.com/Textualize/textual/issues/3434 + +## [0.38.1] - 2023-09-21 + +### Fixed + +- Hotfix - added missing highlight files in build distribution https://github.com/Textualize/textual/pull/3370 + +## [0.38.0] - 2023-09-21 + +### Added + +- Added a TextArea https://github.com/Textualize/textual/pull/2931 +- Added :dark and :light pseudo classes + +### Fixed + +- Fixed `DataTable` not updating component styles on hot-reloading https://github.com/Textualize/textual/issues/3312 + +### Changed + +- Breaking change: CSS in DEFAULT_CSS is now automatically scoped to the widget (set SCOPED_CSS=False) to disable +- Breaking change: Changed `Markdown.goto_anchor` to return a boolean (if the anchor was found) instead of `None` https://github.com/Textualize/textual/pull/3334 + ## [0.37.1] - 2023-09-16 ### Fixed @@ -12,7 +296,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/). - Fixed the command palette crashing with a `TimeoutError` in any Python before 3.11 https://github.com/Textualize/textual/issues/3320 - Fixed `Input` event leakage from `CommandPalette` to `App`. -## [0.36.0] - 2023-09-15 +## [0.37.0] - 2023-09-15 ### Added @@ -40,6 +324,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/). - Breaking change: Widget.notify and App.notify now return None https://github.com/Textualize/textual/pull/3275 - App.unnotify is now private (renamed to App._unnotify) https://github.com/Textualize/textual/pull/3275 - `Markdown.load` will now attempt to scroll to a related heading if an anchor is provided https://github.com/Textualize/textual/pull/3244 +- `ProgressBar` explicitly supports being set back to its indeterminate state https://github.com/Textualize/textual/pull/3286 ## [0.36.0] - 2023-09-05 @@ -57,6 +342,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/). - Callbacks scheduled with `call_next` will now have the same prevented messages as when the callback was scheduled https://github.com/Textualize/textual/pull/3065 - Added `cursor_type` to the `DataTable` constructor. - Fixed `push_screen` not updating Screen.CSS styles https://github.com/Textualize/textual/issues/3217 +- `DataTable.add_row` accepts `height=None` to automatically compute optimal height for a row https://github.com/Textualize/textual/pull/3213 ### Fixed @@ -135,7 +421,6 @@ and this project adheres to [Semantic Versioning](http://semver.org/). - DescendantBlur and DescendantFocus can now be used with @on decorator - ## [0.32.0] - 2023-08-03 ### Added @@ -1283,6 +1568,21 @@ 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.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 +[0.45.1]: https://github.com/Textualize/textual/compare/v0.45.0...v0.45.1 +[0.45.0]: https://github.com/Textualize/textual/compare/v0.44.1...v0.45.0 +[0.44.1]: https://github.com/Textualize/textual/compare/v0.44.0...v0.44.1 +[0.44.0]: https://github.com/Textualize/textual/compare/v0.43.2...v0.44.0 +[0.43.2]: https://github.com/Textualize/textual/compare/v0.43.1...v0.43.2 +[0.43.1]: https://github.com/Textualize/textual/compare/v0.43.0...v0.43.1 +[0.43.0]: https://github.com/Textualize/textual/compare/v0.42.0...v0.43.0 +[0.42.0]: https://github.com/Textualize/textual/compare/v0.41.0...v0.42.0 +[0.41.0]: https://github.com/Textualize/textual/compare/v0.40.0...v0.41.0 +[0.40.0]: https://github.com/Textualize/textual/compare/v0.39.0...v0.40.0 +[0.39.0]: https://github.com/Textualize/textual/compare/v0.38.1...v0.39.0 +[0.38.1]: https://github.com/Textualize/textual/compare/v0.38.0...v0.38.1 +[0.38.0]: https://github.com/Textualize/textual/compare/v0.37.1...v0.38.0 [0.37.1]: https://github.com/Textualize/textual/compare/v0.37.0...v0.37.1 [0.37.0]: https://github.com/Textualize/textual/compare/v0.36.0...v0.37.0 [0.36.0]: https://github.com/Textualize/textual/compare/v0.35.1...v0.36.0 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 41b440244b..a28f2155ee 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,120 +1,112 @@ -# Contributing Guidelines +# Contributing to Textual -🎉 **First of all, thanks for taking the time to contribute!** 🎉 +First of all, thanks for taking the time to contribute to Textual! -## 🤔 How can I contribute? +## How can I contribute? -**1.** Fix issue +You can contribute to Textual in many ways: -**2.** Report bug + 1. [Report a bug](https://github.com/textualize/textual/issues/new?title=%5BBUG%5D%20short%20bug%20description&template=bug_report.md) + 2. Add a new feature + 3. Fix a bug + 4. Improve the documentation -**3.** Improve Documentation +## Setup -## Setup 🚀 -You need to set up Textualize to make your contribution. Textual requires Python 3.7 or later (if you have a choice, pick the most recent Python). Textual runs on Linux, macOS, Windows, and probably any OS where Python also runs. +To make a code or documentation contribution you will need to set up Textual locally. +You can follow these steps: -### Installation + 1. Make sure you have Poetry installed ([see instructions here](https://python-poetry.org)) + 2. Clone the Textual repository + 3. Run `poetry shell` to create a virtual environment for the dependencies + 4. Run `make setup` to install all dependencies + 5. Make sure the latest version of Textual was installed by running the command `textual --version` + 6. Install the pre-commit hooks with the command `pre-commit install` -**Install Texualize via pip:** -```bash -pip install textual -``` -**Install [Poetry](https://python-poetry.org/)** -```bash -curl -sSL https://install.python-poetry.org | python3 - -``` -**To install all dependencies, run:** -```bash -poetry install --all -``` -**Make sure everything works fine:** -```bash -textual --version -``` -### Demo +([Read this](#makefile-commands) if the command `make` doesn't work for you.) -Once you have Textual installed, run the following to get an impression of what it can do: +## Demo + +Once you have Textual installed, run the Textual demo to get an impression of what Textual can do and to double check that everything was installed correctly: ```bash python -m textual ``` -If Texualize is installed, you should see this: -demo - -## Make contribution -**1.** Fork [this](repo) repository. -**2.** Clone the forked repository. +## Guidelines -```bash -git clone https://github.com//textual.git -``` +- Read any issue instructions carefully. Feel free to ask for clarification if any details are missing. -**3.** Navigate to the project directory. +- Add docstrings to all of your code (functions, methods, classes, ...). The codebase should have enough examples for you to copy from. -```bash -cd textual -``` +- Write tests for your code. + - If you are fixing a bug, make sure to add regression tests that link to the original issue. + - If you are implementing a visual element, make sure to add _snapshot tests_. [See below](#snapshot-testing) for more details. -**4.** Create a new [pull request](https://help.github.com/en/github/collaborating-with-issues-and-pull-requests/creating-a-pull-request) +## Before opening a PR +Before you open your PR, please go through this checklist and make sure you've checked all the items that apply: -### 📣 Pull Requests(PRs) + - [ ] Update the `CHANGELOG.md` + - [ ] Format your code with black (`make format`) + - [ ] All your code has docstrings in the style of the rest of the codebase + - [ ] Your code passes all tests (`make test`) -The process described here should check off these goals: +([Read this](#makefile-commands) if the command `make` doesn't work for you.) -- [x] Maintain the project's quality. -- [x] Fix problems that are important to users. -- [x] The CHANGELOG.md was updated; -- [x] Your code was formatted with black (make format); -- [x] All of your code has docstrings in the style of the rest of the codebase; -- [x] your code passes all tests (make test); and -- [x] You added documentation when needed. +## Updating and building the documentation -### After the PR 🥳 -When you open a PR, your code will be reviewed by one of the Textual maintainers. -In that review process, +If you change the documentation, you will want to build the documentation to make sure everything looks like it should. +The command `make docs-serve-offline` should start a server that will let you preview the documentation locally and that should reload whenever you save changes to the documentation or the code files. -- We will take a look at all of the changes you are making; -- We might ask for clarifications (why did you do X or Y?); -- We might ask for more tests/more documentation; and -- We might ask for some code changes. +([Read this](#makefile-commands) if the command `make` doesn't work for you.) -The sole purpose of those interactions is to make sure that, in the long run, everyone has the best experience possible with Textual and with the feature you are implementing/fixing. +We strive to write our documentation in a clear and accessible way so, if you find any issues with the documentation, we encourage you to open an issue where you can enumerate the things you think should be changed or added. -Don't be discouraged if a reviewer asks for code changes. -If you go through our history of pull requests, you will see that every single one of the maintainers has had to make changes following a review. +Opening an issue or a discussion is typically better than opening a PR directly. +That's because there are many subjective considerations that go into writing documentation and we cannot expect you, a well-intentioned external contributor, to be aware of those subjective considerations that we take into account when writing our documentation. +Of course, this does not apply to objective/technical issues with the documentation like bugs or broken links. +## After opening a PR -## 🛑 Important +When you open a PR, your code will be reviewed by one of the Textual maintainers. +In that review process, -- Make sure to read the issue instructions carefully. If you are a newbie you should look out for some good first issues because they should be clear enough and sometimes even provide some hints. If something isn't clear, ask for clarification! +- We will take a look at all of the changes you are making +- We might ask for clarifications (why did you do X or Y?) +- We might ask for more tests/more documentation +- We might ask for some code changes -- Add docstrings to all of your code (functions, methods, classes, ...). The codebase should have enough examples for you to copy from. +The sole purpose of those interactions is to make sure that, in the long run, everyone has the best experience possible with Textual and with the feature you are implementing/fixing. -- Write tests for your code. +Don't be discouraged if a reviewer asks for code changes. +If you go through our history of pull requests, you will see that every single one of the maintainers has had to make changes following a review. -- If you are fixing a bug, make sure to add regression tests that link to the original issue. - -- If you are implementing a visual element, make sure to add snapshot tests. See below for more details. +## Snapshot testing - -### Snapshot Testing -Snapshot tests ensure that things like widgets look like they are supposed to. -PR [#1969](https://github.com/Textualize/textual/pull/1969) is a good example of what adding snapshot tests means: it amounts to a change in the file ```tests/snapshot_tests/test_snapshots.py```, that should run an app that you write and compare it against a historic snapshot of what that app should look like. +Snapshot tests ensure that visual things (like widgets) look like they are supposed to. +PR [#1969](https://github.com/Textualize/textual/pull/1969) is a good example of what adding snapshot tests looks like: it amounts to a change in the file `tests/snapshot_tests/test_snapshots.py` that should run an app that you write and compare it against a historic snapshot of what that app should look like. -When you create a new snapshot test, run it with ```pytest -vv tests/snapshot_tests/test_snapshots.py.``` -Because you just created this snapshot test, there is no history to compare against and the test will fail automatically. +When you create a new snapshot test, run it with `pytest -vv tests/snapshot_tests/test_snapshots.py`. +Because you just created this snapshot test, there is no history to compare against and the test will fail. After running the snapshot tests, you should see a link that opens an interface in your browser. -This interface should show all failing snapshot tests and a side-by-side diff between what the app looked like when it ran VS the historic snapshot. +This interface should show all failing snapshot tests and a side-by-side diff between what the app looked like when the test ran versus the historic snapshot. Make sure your snapshot app looks like it is supposed to and that you didn't break any other snapshot tests. -If that's the case, you can run ```make test-snapshot-update``` to update the snapshot history with your new snapshot. -This will write to the file ```tests/snapshot_tests/__snapshots__/test_snapshots.ambr```, that you should NOT modify by hand +If everything looks fine, you can run `make test-snapshot-update` to update the snapshot history with your new snapshot. +This will write to the file `tests/snapshot_tests/__snapshots__/test_snapshots.ambr`, which you should NOT modify by hand. + +([Read this](#makefile-commands) if the command `make` doesn't work for you.) + +## Join the community +Seems a little overwhelming? +Join our community on [Discord](https://discord.gg/Enf6Z3qhVr) to get help! -### 📈Join the community +## Makefile commands -- 😕 Seems a little overwhelming? Join our community on [Discord](https://discord.gg/uNRPEGCV) to get help. +Textual has a `Makefile` file that contains the most common commands used when developing Textual. +([Read about Make and makefiles on Wikipedia.](https://en.wikipedia.org/wiki/Make_(software))) +If you don't have Make and you're on Windows, you may want to [install Make](https://stackoverflow.com/q/32127524/2828287). diff --git a/Makefile b/Makefile index 9fe2b53427..8b5f8d2191 100644 --- a/Makefile +++ b/Makefile @@ -85,6 +85,7 @@ clean: clean-screenshot-cache clean-offline-docs .PHONY: setup setup: poetry install + poetry install --extras syntax .PHONY: update update: @@ -97,3 +98,7 @@ install-pre-commit: .PHONY: demo demo: $(run) python -m textual + +.PHONY: repl +repl: + $(run) python diff --git a/README.md b/README.md index b2b518b0a0..e000b74c6c 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ Textual is a *Rapid Application Development* framework for Python. -Build sophisticated user interfaces with a simple Python API. Run your apps in the terminal and (coming soon) a web browser! +Build sophisticated user interfaces with a simple Python API. Run your apps in the terminal and a [web browser](https://github.com/Textualize/textual-web)!
@@ -36,7 +36,7 @@ On modern terminal software (installed by default on most systems), Textual apps ## Compatibility -Textual runs on Linux, macOS, and Windows. Textual requires Python 3.7 or above. +Textual runs on Linux, macOS, and Windows. Textual requires Python 3.8 or above. ## Installing diff --git a/docs/_templates/python/material/_base/docstring/parameters.html b/docs/_templates/python/material/_base/docstring/parameters.html index afff067059..10176a23ae 100644 --- a/docs/_templates/python/material/_base/docstring/parameters.html +++ b/docs/_templates/python/material/_base/docstring/parameters.html @@ -5,33 +5,33 @@
{{ section.title or "Parameters" }}
- - - + + {% for parameter in section.value %} - - + {% endfor %} diff --git a/docs/api/app.md b/docs/api/app.md index 3a797ce06f..3a83ec5f02 100644 --- a/docs/api/app.md +++ b/docs/api/app.md @@ -1 +1,5 @@ ::: textual.app + options: + filters: + - "!^_" + - "^__init__$" diff --git a/docs/api/await_complete.md b/docs/api/await_complete.md new file mode 100644 index 0000000000..523cb8a289 --- /dev/null +++ b/docs/api/await_complete.md @@ -0,0 +1 @@ +::: textual.await_complete diff --git a/docs/api/containers.md b/docs/api/containers.md index f65b508681..d44d570d01 100644 --- a/docs/api/containers.md +++ b/docs/api/containers.md @@ -1 +1,3 @@ ::: textual.containers + +::: textual.widgets.ContentSwitcher diff --git a/docs/api/lazy.md b/docs/api/lazy.md new file mode 100644 index 0000000000..1b5039f136 --- /dev/null +++ b/docs/api/lazy.md @@ -0,0 +1 @@ +::: textual.lazy diff --git a/docs/api/logger.md b/docs/api/logger.md index bd76afceca..096ca3011c 100644 --- a/docs/api/logger.md +++ b/docs/api/logger.md @@ -1 +1,5 @@ +# Logger + +A [logger class](/guide/devtools/#logging-handler) that logs to the Textual [console](/guide/devtools#console). + ::: textual.Logger diff --git a/docs/api/renderables.md b/docs/api/renderables.md new file mode 100644 index 0000000000..08f3861e71 --- /dev/null +++ b/docs/api/renderables.md @@ -0,0 +1,7 @@ +A collection of Rich renderables which may be returned from a widget's `render()` method. + +::: textual.renderables.bar +::: textual.renderables.blank +::: textual.renderables.digits +::: textual.renderables.gradient +::: textual.renderables.sparkline diff --git a/docs/blog/images/text-area-learnings/cursor_position_updating_via_api.png b/docs/blog/images/text-area-learnings/cursor_position_updating_via_api.png new file mode 100644 index 0000000000..c10f78dc84 Binary files /dev/null and b/docs/blog/images/text-area-learnings/cursor_position_updating_via_api.png differ diff --git a/docs/blog/images/text-area-learnings/maintain_offset.gif b/docs/blog/images/text-area-learnings/maintain_offset.gif new file mode 100644 index 0000000000..d39bca5e0d Binary files /dev/null and b/docs/blog/images/text-area-learnings/maintain_offset.gif differ diff --git a/docs/blog/images/text-area-learnings/text-area-api-insert.gif b/docs/blog/images/text-area-learnings/text-area-api-insert.gif new file mode 100644 index 0000000000..529eb01e3d Binary files /dev/null and b/docs/blog/images/text-area-learnings/text-area-api-insert.gif differ diff --git a/docs/blog/images/text-area-learnings/text-area-pyinstrument.png b/docs/blog/images/text-area-learnings/text-area-pyinstrument.png new file mode 100644 index 0000000000..2a8cc3609c Binary files /dev/null and b/docs/blog/images/text-area-learnings/text-area-pyinstrument.png differ diff --git a/docs/blog/images/text-area-learnings/text-area-syntax-error.gif b/docs/blog/images/text-area-learnings/text-area-syntax-error.gif new file mode 100644 index 0000000000..0a74cb649e Binary files /dev/null and b/docs/blog/images/text-area-learnings/text-area-syntax-error.gif differ diff --git a/docs/blog/images/text-area-learnings/text-area-theme-cycle.gif b/docs/blog/images/text-area-learnings/text-area-theme-cycle.gif new file mode 100644 index 0000000000..c73e9dd9eb Binary files /dev/null and b/docs/blog/images/text-area-learnings/text-area-theme-cycle.gif differ diff --git a/docs/blog/images/text-area-learnings/text-area-welcome.gif b/docs/blog/images/text-area-learnings/text-area-welcome.gif new file mode 100644 index 0000000000..baaf821edc Binary files /dev/null and b/docs/blog/images/text-area-learnings/text-area-welcome.gif differ diff --git a/docs/blog/images/textual-plotext/demo1.png b/docs/blog/images/textual-plotext/demo1.png new file mode 100644 index 0000000000..359ace1e92 Binary files /dev/null and b/docs/blog/images/textual-plotext/demo1.png differ diff --git a/docs/blog/images/textual-plotext/demo2.png b/docs/blog/images/textual-plotext/demo2.png new file mode 100644 index 0000000000..50d47c090a Binary files /dev/null and b/docs/blog/images/textual-plotext/demo2.png differ diff --git a/docs/blog/images/textual-plotext/demo3.png b/docs/blog/images/textual-plotext/demo3.png new file mode 100644 index 0000000000..25866ded17 Binary files /dev/null and b/docs/blog/images/textual-plotext/demo3.png differ diff --git a/docs/blog/images/textual-plotext/demo4.png b/docs/blog/images/textual-plotext/demo4.png new file mode 100644 index 0000000000..84050cd849 Binary files /dev/null and b/docs/blog/images/textual-plotext/demo4.png differ diff --git a/docs/blog/images/textual-plotext/scatter.png b/docs/blog/images/textual-plotext/scatter.png new file mode 100644 index 0000000000..fdfba71393 Binary files /dev/null and b/docs/blog/images/textual-plotext/scatter.png differ diff --git a/docs/blog/images/textual-plotext/weather.png b/docs/blog/images/textual-plotext/weather.png new file mode 100644 index 0000000000..9a7063acc4 Binary files /dev/null and b/docs/blog/images/textual-plotext/weather.png differ diff --git a/docs/blog/posts/release0-38-0.md b/docs/blog/posts/release0-38-0.md new file mode 100644 index 0000000000..f08756b13e --- /dev/null +++ b/docs/blog/posts/release0-38-0.md @@ -0,0 +1,107 @@ +--- +draft: false +date: 2023-09-21 +categories: + - Release +title: "Textual 0.38.0 adds a syntax aware TextArea" +authors: + - willmcgugan +--- + +# Textual 0.38.0 adds a syntax aware TextArea + +This is the second big feature release this month after last week's [command palette](./release0.37.0.md). + + + +The [TextArea](../../widgets/text_area.md) has finally landed. +I know a lot of folk have been waiting for this one. +Textual's TextArea is a fully-featured widget for editing code, with syntax highlighting and line numbers. +It is highly configurable, and looks great. + +Darren Burns (the author of this widget) has penned a terrific write-up on the TextArea. +See [Things I learned while building Textual's TextArea](./text-area-learnings.md) for some of the challenges he faced. + + +## Scoped CSS + +Another notable feature added in 0.38.0 is *scoped* CSS. +A common gotcha in building Textual widgets is that you could write CSS that impacted styles outside of that widget. + +Consider the following widget: + +```python +class MyWidget(Widget): + DEFAULT_CSS = """ + MyWidget { + height: auto; + border: magenta; + } + Label { + border: solid green; + } + """ + + def compose(self) -> ComposeResult: + yield Label("foo") + yield Label("bar") +``` + +The author has intended to style the labels in that widget by adding a green border. +This does work for the widget in question, but (prior to 0.38.0) the `Label` rule would style *all* Labels (including any outside of the widget) — which was probably not intended. + +With version 0.38.0, the CSS is scoped so that only the widget's labels will be styled. +This is almost always what you want, which is why it is enabled by default. +If you do want to style something outside of the widget you can set `SCOPED_CSS=False` (as a classvar). + + +## Light and Dark pseudo selectors + +We've also made a slight quality of life improvement to the CSS, by adding `:light` and `:dark` pseudo selectors. +This allows you to change styles depending on whether you have dark mode enabled or not. + +This was possible before, just a little verbose. +Here's how you would do it in 0.37.0: + +```css +App.-dark-mode MyWidget Label { + ... +} +``` + +In 0.38.0 it's a little more concise and readable: + +```css +MyWidget:dark Label { + ... +} +``` + +## Testing guide + +Not strictly part of the release, but we've added a [guide on testing](/guide/testing) Textual apps. + +As you may know, we are on a mission to make TUIs a serious proposition for critical apps, which makes testing essential. +We've extracted and documented our internal testing tools, including our snapshot tests pytest plugin [pytest-textual-snapshot](https://pypi.org/project/pytest-textual-snapshot/). + +This gives devs powerful tools to ensure the quality of their apps. +Let us know your thoughts on that! + +## Release notes + +See the [release](https://github.com/Textualize/textual/releases/tag/v0.38.0) page for the full details on this release. + + +## What's next? + +There's lots of features planned over the next few months. +One feature I am particularly excited by is a widget to generate plots by wrapping the awesome [Plotext](https://pypi.org/project/plotext/) library. +Check out some early work on this feature: + +
+ +
+ +## Join us + +Join our [Discord server](https://discord.gg/Enf6Z3qhVr) if you want to discuss Textual with the Textualize devs, or the community. diff --git a/docs/blog/posts/text-area-learnings.md b/docs/blog/posts/text-area-learnings.md new file mode 100644 index 0000000000..552ee7997e --- /dev/null +++ b/docs/blog/posts/text-area-learnings.md @@ -0,0 +1,211 @@ +--- +draft: false +date: 2023-09-18 +categories: + - DevLog +authors: + - darrenburns +title: "Things I learned while building Textual's TextArea" +--- + +# Things I learned building a text editor for the terminal + +`TextArea` is the latest widget to be added to Textual's [growing collection](https://textual.textualize.io/widget_gallery/). +It provides a multi-line space to edit text, and features optional syntax highlighting for a selection of languages. + +![text-area-welcome.gif](../images/text-area-learnings/text-area-welcome.gif) + +Adding a `TextArea` to your Textual app is as simple as adding this to your `compose` method: + +```python +yield TextArea() +``` + +Enabling syntax highlighting for a language is as simple as: + +```python +yield TextArea(language="python") +``` + +Working on the `TextArea` widget for Textual taught me a lot about Python and my general +approach to software engineering. It gave me an appreciation for the subtle functionality behind +the editors we use on a daily basis — features we may not even notice, despite +some engineer spending hours perfecting it to provide a small boost to our development experience. + +This post is a tour of some of these learnings. + + + +## Vertical cursor movement is more than just `cursor_row++` + +When you move the cursor vertically, you can't simply keep the same column index and clamp it within the line. +Editors should maintain the visual column offset where possible, +meaning they must account for double-width emoji (sigh 😔) and East-Asian characters. + +![maintain_offset.gif](../images/text-area-learnings/maintain_offset.gif){ loading=lazy } + +Notice that although the cursor is on column 11 while on line 1, it lands on column 6 when it +arrives at line 3. +This is because the 6th character of line 3 _visually_ aligns with the 11th character of line 1. + + +## Edits from other sources may move my cursor + +There are two ways to interact with the `TextArea`: + +1. You can type into it. +2. You can make API calls to edit the content in it. + +In the example below, `Hello, world!\n` is repeatedly inserted at the start of the document via the +API. +Notice that this updates the location of my cursor, ensuring that I don't lose my place. + +![text-area-api-insert.gif](../images/text-area-learnings/text-area-api-insert.gif){ loading=lazy } + +This subtle feature should aid those implementing collaborative and multi-cursor editing. + +This turned out to be one of the more complex features of the whole project, and went through several iterations before I was happy with the result. + +Thankfully it resulted in some wonderful Tetris-esque whiteboards along the way! + +
+ ![cursor_position_updating_via_api.png](../images/text-area-learnings/cursor_position_updating_via_api.png){ loading=lazy } +
A TetrisArea white-boarding session.
+
+ +Sometimes stepping away from the screen and scribbling on a whiteboard with your colleagues (thanks [Dave](https://fosstodon.org/@davep)!) is what's needed to finally crack a tough problem. + +Many thanks to [David Brochart](https://mastodon.top/@davidbrochart) for sending me down this rabbit hole! + +## Spending a few minutes running a profiler can be really beneficial + +While building the `TextArea` widget I avoided heavy optimisation work that may have affected +readability or maintainability. + +However, I did run a profiler in an attempt to detect flawed assumptions or mistakes which were +affecting the performance of my code. + +I spent around 30 minutes profiling `TextArea` +using [pyinstrument](https://pyinstrument.readthedocs.io/en/latest/home.html), and the result was a +**~97%** reduction in the time taken to handle a key press. +What an amazing return on investment for such a minimal time commitment! + + +
+ ![text-area-pyinstrument.png](../images/text-area-learnings/text-area-pyinstrument.png){ loading=lazy } +
"pyinstrument -r html" produces this beautiful output.
+
+ +pyinstrument unveiled two issues that were massively impacting performance. + +### 1. Reparsing highlighting queries on each key press + +I was constructing a tree-sitter `Query` object on each key press, incorrectly assuming it was a +low-overhead call. +This query was completely static, so I moved it into the constructor ensuring the object was created +only once. +This reduced key processing time by around 94% - a substantial and very much noticeable improvement. + +This seems obvious in hindsight, but the code in question was written earlier in the project and had +been relegated in my mind to "code that works correctly and will receive less attention from here on +out". +pyinstrument quickly brought this code back to my attention and highlighted it as a glaring +performance bug. + +### 2. NamedTuples are slower than I expected + +In Python, `NamedTuple`s are slow to create relative to `tuple`s, and this cost was adding up inside +an extremely hot loop which was instantiating a large number of them. +pyinstrument revealed that a large portion of the time during syntax highlighting was spent inside `NamedTuple.__new__`. + +Here's a quick benchmark which constructs 10,000 `NamedTuple`s: + +```toml +❯ hyperfine -w 2 'python sandbox/darren/make_namedtuples.py' +Benchmark 1: python sandbox/darren/make_namedtuples.py + Time (mean ± σ): 15.9 ms ± 0.5 ms [User: 12.8 ms, System: 2.5 ms] + Range (min … max): 15.2 ms … 18.4 ms 165 runs +``` + +Here's the same benchmark using `tuple` instead: + +```toml +❯ hyperfine -w 2 'python sandbox/darren/make_tuples.py' +Benchmark 1: python sandbox/darren/make_tuples.py + Time (mean ± σ): 9.3 ms ± 0.5 ms [User: 6.8 ms, System: 2.0 ms] + Range (min … max): 8.7 ms … 12.3 ms 256 runs +``` + +Switching to `tuple` resulted in another noticeable increase in responsiveness. +Key-press handling time dropped by almost 50%! +Unfortunately, this change _does_ impact readability. +However, the scope in which these tuples were used was very small, and so I felt it was a worthy trade-off. + + +## Syntax highlighting is very different from what I expected + +In order to support syntax highlighting, we make use of +the [tree-sitter](https://tree-sitter.github.io/tree-sitter/) library, which maintains a syntax tree +representing the structure of our document. + +To perform highlighting, we follow these steps: + +1. The user edits the document. +2. We inform tree-sitter of the location of this edit. +3. tree-sitter intelligently parses only the subset of the document impacted by the change, updating the tree. +4. We run a query against the tree to retrieve ranges of text we wish to highlight. +5. These ranges are mapped to styles (defined by the chosen "theme"). +6. These styles to the appropriate text ranges when rendering the widget. + +
+ ![text-area-theme-cycle.gif](../images/text-area-learnings/text-area-theme-cycle.gif){ loading=lazy } +
Cycling through a few of the builtin themes.
+
+ +Another benefit that I didn't consider before working on this project is that tree-sitter +parsers can also be used to highlight syntax errors in a document. +This can be useful in some situations - for example, highlighting mismatched HTML closing tags: + +
+ ![text-area-syntax-error.gif](../images/text-area-learnings/text-area-syntax-error.gif){ loading=lazy } +
Highlighting mismatched closing HTML tags in red.
+
+ +Before building this widget, I was oblivious as to how we might approach syntax highlighting. +Without tree-sitter's incremental parsing approach, I'm not sure reasonable performance would have +been feasible. + +## Edits are replacements + +All single-cursor edits can be distilled into a single behaviour: `replace_range`. +This replaces a range of characters with some text. +We can use this one method to easily implement deletion, insertion, and replacement of text. + +- Inserting text is replacing a zero-width range with the text to insert. +- Pressing backspace (delete left) is just replacing the character behind the cursor with an empty + string. +- Selecting text and pressing delete is just replacing the selected text with an empty string. +- Selecting text and pasting is replacing the selected text with some other text. + +This greatly simplified my initial approach, which involved unique implementations for inserting and +deleting. + + +## The line between "text area" and "VSCode in the terminal" + +A project like this has no clear finish line. +There are always new features, optimisations, and refactors waiting to be made. + +So where do we draw the line? + +We want to provide a widget which can act as both a basic multiline text area that +anyone can drop into their app, yet powerful and extensible enough to act as the foundation +for a Textual-powered text editor. + +Yet, the more features we add, the more opinionated the widget becomes, and the less that users +will feel like they can build it into their _own_ thing. +Finding the sweet spot between feature-rich and flexible is no easy task. + +I don't think the answer is clear, and I don't believe it's possible to please everyone. + +Regardless, I'm happy with where we've landed, and I'm really excited to see what people build using `TextArea` in the future! diff --git a/docs/blog/posts/textual-plotext.md b/docs/blog/posts/textual-plotext.md new file mode 100644 index 0000000000..491e0d7621 --- /dev/null +++ b/docs/blog/posts/textual-plotext.md @@ -0,0 +1,118 @@ +--- +draft: false +date: 2023-10-04 +categories: + - DevLog +title: "Announcing textual-plotext" +authors: + - davep +--- + +# Announcing textual-plotext + +It's no surprise that a common question on the [Textual Discord +server](https://discord.gg/Enf6Z3qhVr) is how to go about producing plots in +the terminal. A popular solution that has been suggested is +[Plotext](https://github.com/piccolomo/plotext). While Plotext doesn't +directly support Textual, it is [easy to use with +Rich](https://github.com/piccolomo/plotext/blob/master/readme/environments.md#rich) +and, because of this, we wanted to make it just as easy to use in your +Textual applications. + + + +With this in mind we've created +[`textual-plotext`](https://github.com/Textualize/textual-plotext): a library +that provides a widget for using Plotext plots in your app. In doing this +we've tried our best to make it as similar as possible to using Plotext in a +conventional Python script. + +Take this code from the [Plotext README](https://github.com/piccolomo/plotext#readme): + +```python +import plotext as plt +y = plt.sin() # sinusoidal test signal +plt.scatter(y) +plt.title("Scatter Plot") # to apply a title +plt.show() # to finally plot +``` + +The Textual equivalent of this (including everything needed to make this a +fully-working Textual application) is: + +```python +from textual.app import App, ComposeResult + +from textual_plotext import PlotextPlot + +class ScatterApp(App[None]): + + def compose(self) -> ComposeResult: + yield PlotextPlot() + + def on_mount(self) -> None: + plt = self.query_one(PlotextPlot).plt + y = plt.sin() # sinusoidal test signal + plt.scatter(y) + plt.title("Scatter Plot") # to apply a title + +if __name__ == "__main__": + ScatterApp().run() +``` + +When run the result will look like this: + +![Scatter plot in a Textual application](/blog/images/textual-plotext/scatter.png) + +Aside from a couple of the more far-out plot types[^1] you should find that +everything you can do with Plotext in a conventional script can also be done +in a Textual application. + +Here's a small selection of screenshots from a demo built into the library, +each of the plots taken from the Plotext README: + +![Sample from the library demo application](/blog/images/textual-plotext/demo1.png) + +![Sample from the library demo application](/blog/images/textual-plotext/demo2.png) + +![Sample from the library demo application](/blog/images/textual-plotext/demo3.png) + +![Sample from the library demo application](/blog/images/textual-plotext/demo4.png) + +A key design goal of this widget is that you can develop your plots so that +the resulting code looks very similar to that in the Plotext documentation. +The core difference is that, where you'd normally import the `plotext` +module `as plt` and then call functions via `plt`, you instead use the `plt` +property made available by the widget. + +You don't even need to call the `build` or `show` functions as +`textual-plotext` takes care of this for you. You can see this in action in +the scatter code shown earlier. + +Of course, moving any existing plotting code into your Textual app means you +will need to think about how you get the data and when and where you build +your plot. This might be where the [Textual worker +API](https://textual.textualize.io/guide/workers/) becomes useful. + +We've included a longer-form example application that shows off the glorious +Scottish weather we enjoy here at Textual Towers, with [an application that +uses workers to pull down weather data from a year ago and plot +it](https://github.com/Textualize/textual-plotext/blob/main/examples/textual_towers_weather.py). + +![The Textual Towers weather history app](/blog/images/textual-plotext/weather.png) + +If you are an existing Plotext user who wants to turn your plots into full +terminal applications, we think this will be very familiar and accessible. +If you're a Textual user who wants to add plots to your application, we +think Plotext is a great library for this. + +If you have any questions about this, or anything else to do with Textual, +feel free to come and join us on our [Discord +server](https://discord.gg/Enf6Z3qhVr) or in our [GitHub +discussions](https://github.com/Textualize/textual/discussions). + +[^1]: Right now there's no [animated + gif](https://github.com/piccolomo/plotext/blob/master/readme/image.md#gif-plot) + or + [video](https://github.com/piccolomo/plotext/blob/master/readme/video.md) + support. diff --git a/docs/blog/snippets/2022-12-07-responsive-app-background-task/nonblocking01.py b/docs/blog/snippets/2022-12-07-responsive-app-background-task/nonblocking01.py index 20f2daba87..21e1760aaf 100644 --- a/docs/blog/snippets/2022-12-07-responsive-app-background-task/nonblocking01.py +++ b/docs/blog/snippets/2022-12-07-responsive-app-background-task/nonblocking01.py @@ -1,5 +1,4 @@ import asyncio -import time from random import randint from textual.app import App, ComposeResult diff --git a/docs/css_types/_template.md b/docs/css_types/_template.md index 079f1b7ef0..1e8be15123 100644 --- a/docs/css_types/_template.md +++ b/docs/css_types/_template.md @@ -51,7 +51,7 @@ If the type has many different syntaxes, cover all of them. Add comments when needed/if helpful. --> -```sass +```css .some-class { rule: type-value-1; rule: type-value-2; diff --git a/docs/css_types/border.md b/docs/css_types/border.md index 012bfb598c..00be2f8a98 100644 --- a/docs/css_types/border.md +++ b/docs/css_types/border.md @@ -37,7 +37,7 @@ textual borders ### CSS -```sass +```css #container { border: heavy red; } diff --git a/docs/css_types/color.md b/docs/css_types/color.md index 63b8e31eac..2394603ddb 100644 --- a/docs/css_types/color.md +++ b/docs/css_types/color.md @@ -106,7 +106,7 @@ For example, `hsla(128, 100%, 50%, 0.5)` is the color `hsl(128, 100%, 50%)` with ### CSS -```sass +```css Header { background: red; /* Color name */ } diff --git a/docs/css_types/horizontal.md b/docs/css_types/horizontal.md index ec6d02c5d8..b4783e6e15 100644 --- a/docs/css_types/horizontal.md +++ b/docs/css_types/horizontal.md @@ -16,7 +16,7 @@ The [``](./horizontal.md) type can take any of the following values: ### CSS -```sass +```css .container { align-horizontal: right; } diff --git a/docs/css_types/integer.md b/docs/css_types/integer.md index 29c8ba2b3d..a4fdb009ec 100644 --- a/docs/css_types/integer.md +++ b/docs/css_types/integer.md @@ -14,7 +14,7 @@ An [``](./integer.md) is any valid integer number like `-10` or `42`. ### CSS -```sass +```css .classname { offset: 10 -20 } diff --git a/docs/css_types/keyline.md b/docs/css_types/keyline.md new file mode 100644 index 0000000000..9b84f32ef9 --- /dev/null +++ b/docs/css_types/keyline.md @@ -0,0 +1,30 @@ +# <keyline> + +The `` CSS type represents a line style used in the [keyline](../styles/keyline.md) rule. + + +## Syntax + +| Value | Description | +| -------- | -------------------------- | +| `none` | No line (disable keyline). | +| `thin` | A thin line. | +| `heavy` | A heavy (thicker) line. | +| `double` | A double line. | + +## Examples + +### CSS + +```css +Vertical { + keyline: thin green; +} +``` + +### Python + +```py +# A tuple of and color +widget.styles.keyline = ("thin", "green") +``` diff --git a/docs/css_types/name.md b/docs/css_types/name.md index 8a6f064b97..03b2d7a9fc 100644 --- a/docs/css_types/name.md +++ b/docs/css_types/name.md @@ -13,7 +13,7 @@ A [``](./name.md) is any non-empty sequence of characters: ### CSS -```sass +```css Screen { layers: onlyLetters Letters-and-hiphens _lead-under letters-1-digit; } diff --git a/docs/css_types/number.md b/docs/css_types/number.md index ae3400fcfa..7159a74396 100644 --- a/docs/css_types/number.md +++ b/docs/css_types/number.md @@ -10,7 +10,7 @@ A [``](./number.md) is an [``](./integer.md), optionally follow ### CSS -```sass +```css Grid { grid-size: 3 6 /* Integers are numbers */ } diff --git a/docs/css_types/overflow.md b/docs/css_types/overflow.md index cb2cc76362..b5eec3a0d4 100644 --- a/docs/css_types/overflow.md +++ b/docs/css_types/overflow.md @@ -16,7 +16,7 @@ The [``](./overflow.md) type can take any of the following values: ### CSS -```sass +```css #container { overflow-y: hidden; /* Don't overflow */ } diff --git a/docs/css_types/percentage.md b/docs/css_types/percentage.md index aa2586d7dd..b886e880b5 100644 --- a/docs/css_types/percentage.md +++ b/docs/css_types/percentage.md @@ -16,7 +16,7 @@ Some rules may clamp the values between `0%` and `100%`. ### CSS -```sass +```css #footer { /* Integer followed by % */ color: red 70%; diff --git a/docs/css_types/scalar.md b/docs/css_types/scalar.md index 89c7dbf5d3..75ba4b4287 100644 --- a/docs/css_types/scalar.md +++ b/docs/css_types/scalar.md @@ -98,7 +98,7 @@ For example, if its container is big enough, a label with `width: auto` will be ### CSS -```sass +```css Horizontal { width: 60; /* 60 cells */ height: 1fr; /* proportional size of 1 */ diff --git a/docs/css_types/text_align.md b/docs/css_types/text_align.md index 95119e676e..4289f695cb 100644 --- a/docs/css_types/text_align.md +++ b/docs/css_types/text_align.md @@ -27,9 +27,9 @@ A [``](./text_align.md) can be any of the following values: ### CSS -```sass +```css Label { - rule: justify; + text-align: justify; } ``` diff --git a/docs/css_types/text_style.md b/docs/css_types/text_style.md index 8309502f95..c03e92c067 100644 --- a/docs/css_types/text_style.md +++ b/docs/css_types/text_style.md @@ -23,7 +23,7 @@ or any _space-separated_ combination of the following values: ### CSS -```sass +```css #label1 { /* You can specify any value by itself. */ rule: strike; diff --git a/docs/css_types/vertical.md b/docs/css_types/vertical.md index b8ff482037..d1b0ffd6ca 100644 --- a/docs/css_types/vertical.md +++ b/docs/css_types/vertical.md @@ -16,7 +16,7 @@ The [``](./vertical.md) type can take any of the following values: ### CSS -```sass +```css .container { align-vertical: top; } diff --git a/docs/custom_theme/main.html b/docs/custom_theme/main.html index fbbfd659ab..3219e100dd 100644 --- a/docs/custom_theme/main.html +++ b/docs/custom_theme/main.html @@ -7,7 +7,7 @@ - + @@ -30,4 +30,23 @@ + + {% endblock %} diff --git a/docs/examples/events/on_decorator01.py b/docs/examples/events/on_decorator01.py index ac8e2ccd28..6612d6ad6c 100644 --- a/docs/examples/events/on_decorator01.py +++ b/docs/examples/events/on_decorator01.py @@ -1,4 +1,3 @@ -from textual import on from textual.app import App, ComposeResult from textual.widgets import Button diff --git a/docs/examples/events/prevent.py b/docs/examples/events/prevent.py index 39fe437c2d..61e48780d5 100644 --- a/docs/examples/events/prevent.py +++ b/docs/examples/events/prevent.py @@ -1,5 +1,4 @@ from textual.app import App, ComposeResult -from textual.containers import Horizontal from textual.widgets import Button, Input diff --git a/docs/examples/guide/command_palette/command01.py b/docs/examples/guide/command_palette/command01.py index f808f73224..2e0a9f82d6 100644 --- a/docs/examples/guide/command_palette/command01.py +++ b/docs/examples/guide/command_palette/command01.py @@ -1,6 +1,7 @@ -from pathlib import Path +from __future__ import annotations -from rich.syntax import Syntax +from functools import partial +from pathlib import Path from textual.app import App, ComposeResult from textual.command import Hit, Hits, Provider @@ -34,7 +35,7 @@ async def search(self, query: str) -> Hits: # (2)! yield Hit( score, matcher.highlight(command), # (5)! - lambda: app.open_file(path), + partial(app.open_file, path), help="Open this file in the viewer", ) @@ -50,6 +51,8 @@ def compose(self) -> ComposeResult: def open_file(self, path: Path) -> None: """Open and display a file with syntax highlighting.""" + from rich.syntax import Syntax + syntax = Syntax.from_path( str(path), line_numbers=True, diff --git a/docs/examples/guide/css/nesting01.py b/docs/examples/guide/css/nesting01.py new file mode 100644 index 0000000000..56d6a85ffa --- /dev/null +++ b/docs/examples/guide/css/nesting01.py @@ -0,0 +1,19 @@ +from textual.app import App, ComposeResult +from textual.containers import Horizontal +from textual.widgets import Static + + +class NestingDemo(App): + """App that doesn't have nested CSS.""" + + CSS_PATH = "nesting01.tcss" + + def compose(self) -> ComposeResult: + with Horizontal(id="questions"): + yield Static("Yes", classes="button affirmative") + yield Static("No", classes="button negative") + + +if __name__ == "__main__": + app = NestingDemo() + app.run() diff --git a/docs/examples/guide/css/nesting01.tcss b/docs/examples/guide/css/nesting01.tcss new file mode 100644 index 0000000000..53cc9b2375 --- /dev/null +++ b/docs/examples/guide/css/nesting01.tcss @@ -0,0 +1,24 @@ +/* Style the container */ +#questions { + border: heavy $primary; + align: center middle; +} + +/* Style all buttons */ +#questions .button { + width: 1fr; + padding: 1 2; + margin: 1 2; + text-align: center; + border: heavy $panel; +} + +/* Style the Yes button */ +#questions .button.affirmative { + border: heavy $success; +} + +/* Style the No button */ +#questions .button.negative { + border: heavy $error; +} diff --git a/docs/examples/guide/css/nesting02.py b/docs/examples/guide/css/nesting02.py new file mode 100644 index 0000000000..25cbc96669 --- /dev/null +++ b/docs/examples/guide/css/nesting02.py @@ -0,0 +1,19 @@ +from textual.app import App, ComposeResult +from textual.containers import Horizontal +from textual.widgets import Static + + +class NestingDemo(App): + """App with nested CSS.""" + + CSS_PATH = "nesting02.tcss" + + def compose(self) -> ComposeResult: + with Horizontal(id="questions"): + yield Static("Yes", classes="button affirmative") + yield Static("No", classes="button negative") + + +if __name__ == "__main__": + app = NestingDemo() + app.run() diff --git a/docs/examples/guide/css/nesting02.tcss b/docs/examples/guide/css/nesting02.tcss new file mode 100644 index 0000000000..febcc77b61 --- /dev/null +++ b/docs/examples/guide/css/nesting02.tcss @@ -0,0 +1,24 @@ +/* Style the container */ +#questions { + border: heavy $primary; + align: center middle; + + /* Style all buttons */ + .button { + width: 1fr; + padding: 1 2; + margin: 1 2; + text-align: center; + border: heavy $panel; + + /* Style the Yes button */ + &.affirmative { + border: heavy $success; + } + + /* Style the No button */ + &.negative { + border: heavy $error; + } + } +} diff --git a/docs/examples/guide/input/mouse01.py b/docs/examples/guide/input/mouse01.py index 88ce4a94f9..48036ec4b3 100644 --- a/docs/examples/guide/input/mouse01.py +++ b/docs/examples/guide/input/mouse01.py @@ -1,18 +1,8 @@ from textual import events from textual.app import App, ComposeResult -from textual.containers import Container from textual.widgets import RichLog, Static -class PlayArea(Container): - def on_mount(self) -> None: - self.capture_mouse() - - def on_mouse_move(self, event: events.MouseMove) -> None: - self.screen.query_one(RichLog).write(event) - self.query_one(Ball).offset = event.offset - (8, 2) - - class Ball(Static): pass @@ -22,7 +12,11 @@ class MouseApp(App): def compose(self) -> ComposeResult: yield RichLog() - yield PlayArea(Ball("Textual")) + yield Ball("Textual") + + def on_mouse_move(self, event: events.MouseMove) -> None: + self.screen.query_one(RichLog).write(event) + self.query_one(Ball).offset = event.screen_offset - (8, 2) if __name__ == "__main__": diff --git a/docs/examples/guide/input/mouse01.tcss b/docs/examples/guide/input/mouse01.tcss index a37245093e..40a38ac1a7 100644 --- a/docs/examples/guide/input/mouse01.tcss +++ b/docs/examples/guide/input/mouse01.tcss @@ -2,15 +2,10 @@ Screen { layers: log ball; } -TextLog { +RichLog { layer: log; } -PlayArea { - opacity: 0%; - layer: ball; - -} Ball { layer: ball; width: auto; diff --git a/docs/examples/guide/reactivity/dynamic_watch.py b/docs/examples/guide/reactivity/dynamic_watch.py new file mode 100644 index 0000000000..bb231fa6e5 --- /dev/null +++ b/docs/examples/guide/reactivity/dynamic_watch.py @@ -0,0 +1,35 @@ +from textual.app import App, ComposeResult +from textual.reactive import reactive +from textual.widget import Widget +from textual.widgets import Button, Label, ProgressBar + + +class Counter(Widget): + DEFAULT_CSS = "Counter { height: auto; }" + counter = reactive(0) # (1)! + + def compose(self) -> ComposeResult: + yield Label() + yield Button("+10") + + def on_button_pressed(self) -> None: + self.counter += 10 + + def watch_counter(self, counter_value: int): + self.query_one(Label).update(str(counter_value)) + + +class WatchApp(App[None]): + def compose(self) -> ComposeResult: + yield Counter() + yield ProgressBar(total=100, show_eta=False) + + def on_mount(self): + def update_progress(counter_value: int): # (2)! + self.query_one(ProgressBar).update(progress=counter_value) + + self.watch(self.query_one(Counter), "counter", update_progress) # (3)! + + +if __name__ == "__main__": + WatchApp().run() diff --git a/docs/examples/guide/reactivity/validate01.py b/docs/examples/guide/reactivity/validate01.py index 65d8113c07..e6ac1a75f2 100644 --- a/docs/examples/guide/reactivity/validate01.py +++ b/docs/examples/guide/reactivity/validate01.py @@ -30,7 +30,7 @@ def on_button_pressed(self, event: Button.Pressed) -> None: self.count += 1 else: self.count -= 1 - self.query_one(RichLog).write(f"{self.count=}") + self.query_one(RichLog).write(f"count = {self.count}") if __name__ == "__main__": diff --git a/docs/examples/guide/screens/questions01.py b/docs/examples/guide/screens/questions01.py new file mode 100644 index 0000000000..cc699200c6 --- /dev/null +++ b/docs/examples/guide/screens/questions01.py @@ -0,0 +1,45 @@ +from textual import on, work +from textual.app import App, ComposeResult +from textual.screen import Screen +from textual.widgets import Button, Label + + +class QuestionScreen(Screen[bool]): + """Screen with a parameter.""" + + def __init__(self, question: str) -> None: + self.question = question + super().__init__() + + def compose(self) -> ComposeResult: + yield Label(self.question) + yield Button("Yes", id="yes", variant="success") + yield Button("No", id="no") + + @on(Button.Pressed, "#yes") + def handle_yes(self) -> None: + self.dismiss(True) # (1)! + + @on(Button.Pressed, "#no") + def handle_no(self) -> None: + self.dismiss(False) # (2)! + + +class QuestionsApp(App): + """Demonstrates wait_for_dismiss""" + + CSS_PATH = "questions01.tcss" + + @work # (3)! + async def on_mount(self) -> None: + if await self.push_screen_wait( # (4)! + QuestionScreen("Do you like Textual?"), + ): + self.notify("Good answer!") + else: + self.notify(":-(", severity="error") + + +if __name__ == "__main__": + app = QuestionsApp() + app.run() diff --git a/docs/examples/guide/screens/questions01.tcss b/docs/examples/guide/screens/questions01.tcss new file mode 100644 index 0000000000..e56b2a949c --- /dev/null +++ b/docs/examples/guide/screens/questions01.tcss @@ -0,0 +1,17 @@ +QuestionScreen { + layout: grid; + grid-size: 2 2; + align: center bottom; +} + +QuestionScreen > Label { + margin: 1; + text-align: center; + column-span: 2; + width: 1fr; +} + +QuestionScreen Button { + margin: 2; + width: 1fr; +} diff --git a/docs/examples/guide/testing/rgb.py b/docs/examples/guide/testing/rgb.py new file mode 100644 index 0000000000..d8b49cd1c3 --- /dev/null +++ b/docs/examples/guide/testing/rgb.py @@ -0,0 +1,42 @@ +from textual import on +from textual.app import App, ComposeResult +from textual.containers import Horizontal +from textual.widgets import Button, Footer + + +class RGBApp(App): + CSS = """ + Screen { + align: center middle; + } + Horizontal { + width: auto; + height: auto; + } + """ + + BINDINGS = [ + ("r", "switch_color('red')", "Go Red"), + ("g", "switch_color('green')", "Go Green"), + ("b", "switch_color('blue')", "Go Blue"), + ] + + def compose(self) -> ComposeResult: + with Horizontal(): + yield Button("Red", id="red") + yield Button("Green", id="green") + yield Button("Blue", id="blue") + yield Footer() + + @on(Button.Pressed) + def pressed_button(self, event: Button.Pressed) -> None: + assert event.button.id is not None + self.action_switch_color(event.button.id) + + def action_switch_color(self, color: str) -> None: + self.screen.styles.background = color + + +if __name__ == "__main__": + app = RGBApp() + app.run() diff --git a/docs/examples/guide/testing/test_rgb.py b/docs/examples/guide/testing/test_rgb.py new file mode 100644 index 0000000000..030f62b505 --- /dev/null +++ b/docs/examples/guide/testing/test_rgb.py @@ -0,0 +1,42 @@ +from rgb import RGBApp + +from textual.color import Color + + +async def test_keys(): # (1)! + """Test pressing keys has the desired result.""" + app = RGBApp() + async with app.run_test() as pilot: # (2)! + # Test pressing the R key + await pilot.press("r") # (3)! + assert app.screen.styles.background == Color.parse("red") # (4)! + + # Test pressing the G key + await pilot.press("g") + assert app.screen.styles.background == Color.parse("green") + + # Test pressing the B key + await pilot.press("b") + assert app.screen.styles.background == Color.parse("blue") + + # Test pressing the X key + await pilot.press("x") + # No binding (so no change to the color) + assert app.screen.styles.background == Color.parse("blue") + + +async def test_buttons(): + """Test pressing keys has the desired result.""" + app = RGBApp() + async with app.run_test() as pilot: + # Test clicking the "red" button + await pilot.click("#red") # (5)! + assert app.screen.styles.background == Color.parse("red") + + # Test clicking the "green" button + await pilot.click("#green") + assert app.screen.styles.background == Color.parse("green") + + # Test clicking the "blue" button + await pilot.click("#blue") + assert app.screen.styles.background == Color.parse("blue") diff --git a/docs/examples/guide/widgets/loading01.py b/docs/examples/guide/widgets/loading01.py new file mode 100644 index 0000000000..3e25899cbe --- /dev/null +++ b/docs/examples/guide/widgets/loading01.py @@ -0,0 +1,54 @@ +from asyncio import sleep +from random import randint + +from textual import work +from textual.app import App, ComposeResult +from textual.widgets import DataTable + +ROWS = [ + ("lane", "swimmer", "country", "time"), + (4, "Joseph Schooling", "Singapore", 50.39), + (2, "Michael Phelps", "United States", 51.14), + (5, "Chad le Clos", "South Africa", 51.14), + (6, "László Cseh", "Hungary", 51.14), + (3, "Li Zhuhao", "China", 51.26), + (8, "Mehdy Metella", "France", 51.58), + (7, "Tom Shields", "United States", 51.73), + (1, "Aleksandr Sadovnikov", "Russia", 51.84), + (10, "Darren Burns", "Scotland", 51.84), +] + + +class DataApp(App): + CSS = """ + Screen { + layout: grid; + grid-size: 2; + } + DataTable { + height: 1fr; + } + """ + + def compose(self) -> ComposeResult: + yield DataTable() + yield DataTable() + yield DataTable() + yield DataTable() + + def on_mount(self) -> None: + for data_table in self.query(DataTable): + data_table.loading = True # (1)! + self.load_data(data_table) + + @work + async def load_data(self, data_table: DataTable) -> None: + await sleep(randint(2, 10)) # (2)! + data_table.add_columns(*ROWS[0]) + data_table.add_rows(ROWS[1:]) + data_table.loading = False # (3)! + + +if __name__ == "__main__": + app = DataApp() + app.run() diff --git a/docs/examples/guide/workers/weather05.py b/docs/examples/guide/workers/weather05.py index c1da80cb4d..a4d8049965 100644 --- a/docs/examples/guide/workers/weather05.py +++ b/docs/examples/guide/workers/weather05.py @@ -1,3 +1,4 @@ +from urllib.parse import quote from urllib.request import Request, urlopen from rich.text import Text @@ -30,7 +31,7 @@ def update_weather(self, city: str) -> None: worker = get_current_worker() if city: # Query the network API - url = f"https://wttr.in/{city}" + url = f"https://wttr.in/{quote(city)}" request = Request(url) request.add_header("User-agent", "CURL") response_text = urlopen(request).read().decode("utf-8") diff --git a/docs/examples/how-to/render_compose.py b/docs/examples/how-to/render_compose.py new file mode 100644 index 0000000000..b1413d3d0c --- /dev/null +++ b/docs/examples/how-to/render_compose.py @@ -0,0 +1,57 @@ +from time import time + +from textual.app import App, ComposeResult, RenderableType +from textual.containers import Container +from textual.renderables.gradient import LinearGradient +from textual.widgets import Static + +COLORS = [ + "#881177", + "#aa3355", + "#cc6666", + "#ee9944", + "#eedd00", + "#99dd55", + "#44dd88", + "#22ccbb", + "#00bbcc", + "#0099cc", + "#3366bb", + "#663399", +] +STOPS = [(i / (len(COLORS) - 1), color) for i, color in enumerate(COLORS)] + + +class Splash(Container): + """Custom widget that extends Container.""" + + DEFAULT_CSS = """ + Splash { + align: center middle; + } + Static { + width: 40; + padding: 2 4; + } + """ + + def on_mount(self) -> None: + self.auto_refresh = 1 / 30 # (1)! + + def compose(self) -> ComposeResult: + yield Static("Making a splash with Textual!") # (2)! + + def render(self) -> RenderableType: + return LinearGradient(time() * 90, STOPS) # (3)! + + +class SplashApp(App): + """Simple app to show our custom widget.""" + + def compose(self) -> ComposeResult: + yield Splash() + + +if __name__ == "__main__": + app = SplashApp() + app.run() diff --git a/docs/examples/styles/align.py b/docs/examples/styles/align.py index a19a803f64..8115b026ce 100644 --- a/docs/examples/styles/align.py +++ b/docs/examples/styles/align.py @@ -3,9 +3,13 @@ class AlignApp(App): + CSS_PATH = "align.tcss" + def compose(self): yield Label("Vertical alignment with [b]Textual[/]", classes="box") yield Label("Take note, browsers.", classes="box") -app = AlignApp(css_path="align.tcss") +if __name__ == "__main__": + app = AlignApp() + app.run() diff --git a/docs/examples/styles/align_all.py b/docs/examples/styles/align_all.py index 2d409414f1..757445f719 100644 --- a/docs/examples/styles/align_all.py +++ b/docs/examples/styles/align_all.py @@ -18,3 +18,7 @@ def compose(self) -> ComposeResult: yield Container(Label("left bottom"), id="left-bottom") yield Container(Label("center bottom"), id="center-bottom") yield Container(Label("right bottom"), id="right-bottom") + + +if __name__ == "__main__": + AlignAllApp().run() diff --git a/docs/examples/styles/background.py b/docs/examples/styles/background.py index 5c5db8bc76..996ee6ac2b 100644 --- a/docs/examples/styles/background.py +++ b/docs/examples/styles/background.py @@ -3,10 +3,14 @@ class BackgroundApp(App): + CSS_PATH = "background.tcss" + def compose(self): yield Label("Widget 1", id="static1") yield Label("Widget 2", id="static2") yield Label("Widget 3", id="static3") -app = BackgroundApp(css_path="background.tcss") +if __name__ == "__main__": + app = BackgroundApp() + app.run() diff --git a/docs/examples/styles/background_transparency.py b/docs/examples/styles/background_transparency.py index abcdc30375..f753e001b4 100644 --- a/docs/examples/styles/background_transparency.py +++ b/docs/examples/styles/background_transparency.py @@ -5,6 +5,8 @@ class BackgroundTransparencyApp(App): """Simple app to exemplify different transparency settings.""" + CSS_PATH = "background_transparency.tcss" + def compose(self) -> ComposeResult: yield Static("10%", id="t10") yield Static("20%", id="t20") @@ -18,4 +20,6 @@ def compose(self) -> ComposeResult: yield Static("100%", id="t100") -app = BackgroundTransparencyApp(css_path="background_transparency.tcss") +if __name__ == "__main__": + app = BackgroundTransparencyApp() + app.run() diff --git a/docs/examples/styles/border.py b/docs/examples/styles/border.py index 31d244f2c1..cf05a3510b 100644 --- a/docs/examples/styles/border.py +++ b/docs/examples/styles/border.py @@ -3,10 +3,14 @@ class BorderApp(App): + CSS_PATH = "border.tcss" + def compose(self): yield Label("My border is solid red", id="label1") yield Label("My border is dashed green", id="label2") yield Label("My border is tall blue", id="label3") -app = BorderApp(css_path="border.tcss") +if __name__ == "__main__": + app = BorderApp() + app.run() diff --git a/docs/examples/styles/border_all.py b/docs/examples/styles/border_all.py index 2fab42f352..4e5a80675e 100644 --- a/docs/examples/styles/border_all.py +++ b/docs/examples/styles/border_all.py @@ -4,6 +4,8 @@ class AllBordersApp(App): + CSS_PATH = "border_all.tcss" + def compose(self): yield Grid( Label("ascii", id="ascii"), @@ -24,4 +26,6 @@ def compose(self): ) -app = AllBordersApp(css_path="border_all.tcss") +if __name__ == "__main__": + app = AllBordersApp() + app.run() diff --git a/docs/examples/styles/border_sub_title_align_all.py b/docs/examples/styles/border_sub_title_align_all.py index 1ec8340433..1832d97f74 100644 --- a/docs/examples/styles/border_sub_title_align_all.py +++ b/docs/examples/styles/border_sub_title_align_all.py @@ -13,6 +13,8 @@ def make_label_container( # (11)! class BorderSubTitleAlignAll(App[None]): + CSS_PATH = "border_sub_title_align_all.tcss" + def compose(self): with Grid(): yield make_label_container( # (1)! @@ -68,7 +70,6 @@ def compose(self): ) -app = BorderSubTitleAlignAll(css_path="border_sub_title_align_all.tcss") - if __name__ == "__main__": + app = BorderSubTitleAlignAll() app.run() diff --git a/docs/examples/styles/border_subtitle_align.py b/docs/examples/styles/border_subtitle_align.py index 4c858b3df1..0633fcec3f 100644 --- a/docs/examples/styles/border_subtitle_align.py +++ b/docs/examples/styles/border_subtitle_align.py @@ -3,6 +3,8 @@ class BorderSubtitleAlignApp(App): + CSS_PATH = "border_subtitle_align.tcss" + def compose(self): lbl = Label("My subtitle is on the left.", id="label1") lbl.border_subtitle = "< Left" @@ -17,4 +19,6 @@ def compose(self): yield lbl -app = BorderSubtitleAlignApp(css_path="border_subtitle_align.tcss") +if __name__ == "__main__": + app = BorderSubtitleAlignApp() + app.run() diff --git a/docs/examples/styles/border_title_align.py b/docs/examples/styles/border_title_align.py index ba790104f8..fb819a5d83 100644 --- a/docs/examples/styles/border_title_align.py +++ b/docs/examples/styles/border_title_align.py @@ -3,6 +3,8 @@ class BorderTitleAlignApp(App): + CSS_PATH = "border_title_align.tcss" + def compose(self): lbl = Label("My title is on the left.", id="label1") lbl.border_title = "< Left" @@ -17,4 +19,6 @@ def compose(self): yield lbl -app = BorderTitleAlignApp(css_path="border_title_align.tcss") +if __name__ == "__main__": + app = BorderTitleAlignApp() + app.run() diff --git a/docs/examples/styles/box_sizing.py b/docs/examples/styles/box_sizing.py index 9bd4511891..83904a884d 100644 --- a/docs/examples/styles/box_sizing.py +++ b/docs/examples/styles/box_sizing.py @@ -3,9 +3,13 @@ class BoxSizingApp(App): + CSS_PATH = "box_sizing.tcss" + def compose(self): yield Static("I'm using border-box!", id="static1") yield Static("I'm using content-box!", id="static2") -app = BoxSizingApp(css_path="box_sizing.tcss") +if __name__ == "__main__": + app = BoxSizingApp() + app.run() diff --git a/docs/examples/styles/color.py b/docs/examples/styles/color.py index bef97429f8..4fada0d33e 100644 --- a/docs/examples/styles/color.py +++ b/docs/examples/styles/color.py @@ -3,10 +3,14 @@ class ColorApp(App): + CSS_PATH = "color.tcss" + def compose(self): yield Label("I'm red!", id="label1") yield Label("I'm rgb(0, 255, 0)!", id="label2") yield Label("I'm hsl(240, 100%, 50%)!", id="label3") -app = ColorApp(css_path="color.tcss") +if __name__ == "__main__": + app = ColorApp() + app.run() diff --git a/docs/examples/styles/color_auto.py b/docs/examples/styles/color_auto.py index 4bb18f6e49..22d259138a 100644 --- a/docs/examples/styles/color_auto.py +++ b/docs/examples/styles/color_auto.py @@ -3,6 +3,8 @@ class ColorApp(App): + CSS_PATH = "color_auto.tcss" + def compose(self): yield Label("The quick brown fox jumps over the lazy dog!", id="lbl1") yield Label("The quick brown fox jumps over the lazy dog!", id="lbl2") @@ -11,4 +13,6 @@ def compose(self): yield Label("The quick brown fox jumps over the lazy dog!", id="lbl5") -app = ColorApp(css_path="color_auto.tcss") +if __name__ == "__main__": + app = ColorApp() + app.run() diff --git a/docs/examples/styles/column_span.py b/docs/examples/styles/column_span.py index 6d9b582ba5..6d120525ec 100644 --- a/docs/examples/styles/column_span.py +++ b/docs/examples/styles/column_span.py @@ -4,6 +4,8 @@ class MyApp(App): + CSS_PATH = "column_span.tcss" + def compose(self): yield Grid( Placeholder(id="p1"), @@ -16,4 +18,6 @@ def compose(self): ) -app = MyApp(css_path="column_span.tcss") +if __name__ == "__main__": + app = MyApp() + app.run() diff --git a/docs/examples/styles/content_align.py b/docs/examples/styles/content_align.py index 71348d3032..47f61e24a5 100644 --- a/docs/examples/styles/content_align.py +++ b/docs/examples/styles/content_align.py @@ -3,10 +3,14 @@ class ContentAlignApp(App): + CSS_PATH = "content_align.tcss" + def compose(self): yield Label("With [i]content-align[/] you can...", id="box1") yield Label("...[b]Easily align content[/]...", id="box2") yield Label("...Horizontally [i]and[/] vertically!", id="box3") -app = ContentAlignApp(css_path="content_align.tcss") +if __name__ == "__main__": + app = ContentAlignApp() + app.run() diff --git a/docs/examples/styles/content_align_all.py b/docs/examples/styles/content_align_all.py index 5ba2bce7d6..8ad7a87e93 100644 --- a/docs/examples/styles/content_align_all.py +++ b/docs/examples/styles/content_align_all.py @@ -3,6 +3,8 @@ class AllContentAlignApp(App): + CSS_PATH = "content_align_all.tcss" + def compose(self): yield Label("left top", id="left-top") yield Label("center top", id="center-top") @@ -15,4 +17,6 @@ def compose(self): yield Label("right bottom", id="right-bottom") -app = AllContentAlignApp(css_path="content_align_all.tcss") +if __name__ == "__main__": + app = AllContentAlignApp() + app.run() diff --git a/docs/examples/styles/display.py b/docs/examples/styles/display.py index 4da6aa2cae..b8fb0cd18e 100644 --- a/docs/examples/styles/display.py +++ b/docs/examples/styles/display.py @@ -3,10 +3,14 @@ class DisplayApp(App): + CSS_PATH = "display.tcss" + def compose(self): yield Static("Widget 1") yield Static("Widget 2", classes="remove") yield Static("Widget 3") -app = DisplayApp(css_path="display.tcss") +if __name__ == "__main__": + app = DisplayApp() + app.run() diff --git a/docs/examples/styles/dock_all.py b/docs/examples/styles/dock_all.py index f1b024f239..f9b0080e8e 100644 --- a/docs/examples/styles/dock_all.py +++ b/docs/examples/styles/dock_all.py @@ -4,6 +4,8 @@ class DockAllApp(App): + CSS_PATH = "dock_all.tcss" + def compose(self): yield Container( Container(Label("left"), id="left"), @@ -14,4 +16,6 @@ def compose(self): ) -app = DockAllApp(css_path="dock_all.tcss") +if __name__ == "__main__": + app = DockAllApp() + app.run() diff --git a/docs/examples/styles/grid.py b/docs/examples/styles/grid.py index 0c43607c09..fd3355bb37 100644 --- a/docs/examples/styles/grid.py +++ b/docs/examples/styles/grid.py @@ -3,6 +3,8 @@ class GridApp(App): + CSS_PATH = "grid.tcss" + def compose(self): yield Static("Grid cell 1\n\nrow-span: 3;\ncolumn-span: 2;", id="static1") yield Static("Grid cell 2", id="static2") @@ -13,4 +15,6 @@ def compose(self): yield Static("Grid cell 7", id="static7") -app = GridApp(css_path="grid.tcss") +if __name__ == "__main__": + app = GridApp() + app.run() diff --git a/docs/examples/styles/grid_columns.py b/docs/examples/styles/grid_columns.py index 6abbbc5a4d..fa564583af 100644 --- a/docs/examples/styles/grid_columns.py +++ b/docs/examples/styles/grid_columns.py @@ -4,6 +4,8 @@ class MyApp(App): + CSS_PATH = "grid_columns.tcss" + def compose(self): yield Grid( Label("1fr"), @@ -19,4 +21,6 @@ def compose(self): ) -app = MyApp(css_path="grid_columns.tcss") +if __name__ == "__main__": + app = MyApp() + app.run() diff --git a/docs/examples/styles/grid_gutter.py b/docs/examples/styles/grid_gutter.py index 211b0e8c09..7cf39e26f3 100644 --- a/docs/examples/styles/grid_gutter.py +++ b/docs/examples/styles/grid_gutter.py @@ -4,6 +4,8 @@ class MyApp(App): + CSS_PATH = "grid_gutter.tcss" + def compose(self): yield Grid( Label("1"), @@ -17,4 +19,6 @@ def compose(self): ) -app = MyApp(css_path="grid_gutter.tcss") +if __name__ == "__main__": + app = MyApp() + app.run() diff --git a/docs/examples/styles/grid_rows.py b/docs/examples/styles/grid_rows.py index 508c0143a4..f183fedd6c 100644 --- a/docs/examples/styles/grid_rows.py +++ b/docs/examples/styles/grid_rows.py @@ -4,6 +4,8 @@ class MyApp(App): + CSS_PATH = "grid_rows.tcss" + def compose(self): yield Grid( Label("1fr"), @@ -19,4 +21,6 @@ def compose(self): ) -app = MyApp(css_path="grid_rows.tcss") +if __name__ == "__main__": + app = MyApp() + app.run() diff --git a/docs/examples/styles/grid_size_both.py b/docs/examples/styles/grid_size_both.py index 0e60188191..d64944aa3c 100644 --- a/docs/examples/styles/grid_size_both.py +++ b/docs/examples/styles/grid_size_both.py @@ -4,6 +4,8 @@ class MyApp(App): + CSS_PATH = "grid_size_both.tcss" + def compose(self): yield Grid( Label("1"), @@ -14,4 +16,6 @@ def compose(self): ) -app = MyApp(css_path="grid_size_both.tcss") +if __name__ == "__main__": + app = MyApp() + app.run() diff --git a/docs/examples/styles/grid_size_columns.py b/docs/examples/styles/grid_size_columns.py index c6d3392d5b..19608a1c3e 100644 --- a/docs/examples/styles/grid_size_columns.py +++ b/docs/examples/styles/grid_size_columns.py @@ -4,6 +4,8 @@ class MyApp(App): + CSS_PATH = "grid_size_columns.tcss" + def compose(self): yield Grid( Label("1"), @@ -14,4 +16,6 @@ def compose(self): ) -app = MyApp(css_path="grid_size_columns.tcss") +if __name__ == "__main__": + app = MyApp() + app.run() diff --git a/docs/examples/styles/height.py b/docs/examples/styles/height.py index 7eba3bbe10..6d58d76ff4 100644 --- a/docs/examples/styles/height.py +++ b/docs/examples/styles/height.py @@ -3,8 +3,12 @@ class HeightApp(App): + CSS_PATH = "height.tcss" + def compose(self): yield Widget() -app = HeightApp(css_path="height.tcss") +if __name__ == "__main__": + app = HeightApp() + app.run() diff --git a/docs/examples/styles/height_comparison.py b/docs/examples/styles/height_comparison.py index 5fc72b237f..857ab823c9 100644 --- a/docs/examples/styles/height_comparison.py +++ b/docs/examples/styles/height_comparison.py @@ -10,6 +10,8 @@ def compose(self): class HeightComparisonApp(App): + CSS_PATH = "height_comparison.tcss" + def compose(self): yield VerticalScroll( Placeholder(id="cells"), # (1)! @@ -25,4 +27,6 @@ def compose(self): yield Ruler() -app = HeightComparisonApp(css_path="height_comparison.tcss") +if __name__ == "__main__": + app = HeightComparisonApp() + app.run() diff --git a/docs/examples/styles/keyline.py b/docs/examples/styles/keyline.py new file mode 100644 index 0000000000..60a699c6ff --- /dev/null +++ b/docs/examples/styles/keyline.py @@ -0,0 +1,19 @@ +from textual.app import App, ComposeResult +from textual.containers import Grid +from textual.widgets import Placeholder + + +class KeylineApp(App): + CSS_PATH = "keyline.tcss" + + def compose(self) -> ComposeResult: + with Grid(): + yield Placeholder(id="foo") + yield Placeholder(id="bar") + yield Placeholder() + yield Placeholder(classes="hidden") + yield Placeholder(id="baz") + + +if __name__ == "__main__": + KeylineApp().run() diff --git a/docs/examples/styles/keyline.tcss b/docs/examples/styles/keyline.tcss new file mode 100644 index 0000000000..41bf30d5e9 --- /dev/null +++ b/docs/examples/styles/keyline.tcss @@ -0,0 +1,21 @@ +Grid { + grid-size: 3 3; + grid-gutter: 1; + padding: 2 3; + keyline: heavy green; +} +Placeholder { + height: 1fr; +} +.hidden { + visibility: hidden; +} +#foo { + column-span: 2; +} +#bar { + row-span: 2; +} +#baz { + column-span:3; +} diff --git a/docs/examples/styles/keyline_horizontal.py b/docs/examples/styles/keyline_horizontal.py new file mode 100644 index 0000000000..a0660df668 --- /dev/null +++ b/docs/examples/styles/keyline_horizontal.py @@ -0,0 +1,18 @@ +from textual.app import App, ComposeResult +from textual.containers import Horizontal +from textual.widgets import Placeholder + + +class KeylineApp(App): + CSS_PATH = "keyline_horizontal.tcss" + + def compose(self) -> ComposeResult: + with Horizontal(): + yield Placeholder() + yield Placeholder() + yield Placeholder() + + +if __name__ == "__main__": + app = KeylineApp() + app.run() diff --git a/docs/examples/styles/keyline_horizontal.tcss b/docs/examples/styles/keyline_horizontal.tcss new file mode 100644 index 0000000000..d4b02cd0c2 --- /dev/null +++ b/docs/examples/styles/keyline_horizontal.tcss @@ -0,0 +1,8 @@ +Placeholder { + margin: 1; + width: 1fr; +} + +Horizontal { + keyline: thin $secondary; +} diff --git a/docs/examples/styles/layout.py b/docs/examples/styles/layout.py index 07be94c630..bb9a2546f1 100644 --- a/docs/examples/styles/layout.py +++ b/docs/examples/styles/layout.py @@ -4,6 +4,8 @@ class LayoutApp(App): + CSS_PATH = "layout.tcss" + def compose(self): yield Container( Label("Layout"), @@ -19,4 +21,6 @@ def compose(self): ) -app = LayoutApp(css_path="layout.tcss") +if __name__ == "__main__": + app = LayoutApp() + app.run() diff --git a/docs/examples/styles/link_background.py b/docs/examples/styles/link_background.py index 6cc0161ef5..6516f1b6aa 100644 --- a/docs/examples/styles/link_background.py +++ b/docs/examples/styles/link_background.py @@ -3,6 +3,8 @@ class LinkBackgroundApp(App): + CSS_PATH = "link_background.tcss" + def compose(self): yield Label( "Visit the [link=https://textualize.io]Textualize[/link] website.", @@ -22,4 +24,6 @@ def compose(self): ) -app = LinkBackgroundApp(css_path="link_background.tcss") +if __name__ == "__main__": + app = LinkBackgroundApp() + app.run() diff --git a/docs/examples/styles/link_hover_background.py b/docs/examples/styles/link_background_hover.py similarity index 84% rename from docs/examples/styles/link_hover_background.py rename to docs/examples/styles/link_background_hover.py index d7d4d4928b..fc33e576d7 100644 --- a/docs/examples/styles/link_hover_background.py +++ b/docs/examples/styles/link_background_hover.py @@ -3,6 +3,8 @@ class LinkHoverBackgroundApp(App): + CSS_PATH = "link_background_hover.tcss" + def compose(self): yield Label( "Visit the [link=https://textualize.io]Textualize[/link] website.", @@ -22,4 +24,6 @@ def compose(self): ) -app = LinkHoverBackgroundApp(css_path="link_hover_background.tcss") +if __name__ == "__main__": + app = LinkHoverBackgroundApp() + app.run() diff --git a/docs/examples/styles/link_hover_background.tcss b/docs/examples/styles/link_background_hover.tcss similarity index 52% rename from docs/examples/styles/link_hover_background.tcss rename to docs/examples/styles/link_background_hover.tcss index 68d564ae23..53ec6b8151 100644 --- a/docs/examples/styles/link_hover_background.tcss +++ b/docs/examples/styles/link_background_hover.tcss @@ -1,9 +1,9 @@ #lbl1, #lbl2 { - link-hover-background: red; /* (1)! */ + link-background-hover: red; /* (1)! */ } #lbl3 { - link-hover-background: hsl(60,100%,50%) 50%; + link-background-hover: hsl(60,100%,50%) 50%; } #lbl4 { diff --git a/docs/examples/styles/link_color.py b/docs/examples/styles/link_color.py index bd093093b1..3d6a83cc7f 100644 --- a/docs/examples/styles/link_color.py +++ b/docs/examples/styles/link_color.py @@ -3,6 +3,8 @@ class LinkColorApp(App): + CSS_PATH = "link_color.tcss" + def compose(self): yield Label( "Visit the [link=https://textualize.io]Textualize[/link] website.", @@ -22,4 +24,6 @@ def compose(self): ) -app = LinkColorApp(css_path="link_color.tcss") +if __name__ == "__main__": + app = LinkColorApp() + app.run() diff --git a/docs/examples/styles/link_hover_color.py b/docs/examples/styles/link_color_hover.py similarity index 85% rename from docs/examples/styles/link_hover_color.py rename to docs/examples/styles/link_color_hover.py index 67b3acd21e..7344123ae0 100644 --- a/docs/examples/styles/link_hover_color.py +++ b/docs/examples/styles/link_color_hover.py @@ -3,6 +3,8 @@ class LinkHoverColorApp(App): + CSS_PATH = "link_color_hover.tcss" + def compose(self): yield Label( "Visit the [link=https://textualize.io]Textualize[/link] website.", @@ -22,4 +24,6 @@ def compose(self): ) -app = LinkHoverColorApp(css_path="link_hover_color.tcss") +if __name__ == "__main__": + app = LinkHoverColorApp() + app.run() diff --git a/docs/examples/styles/link_color_hover.tcss b/docs/examples/styles/link_color_hover.tcss new file mode 100644 index 0000000000..d4ddd07d64 --- /dev/null +++ b/docs/examples/styles/link_color_hover.tcss @@ -0,0 +1,11 @@ +#lbl1, #lbl2 { + link-color-hover: red; /* (1)! */ +} + +#lbl3 { + link-color-hover: hsl(60,100%,50%) 50%; +} + +#lbl4 { + link-color-hover: black; +} diff --git a/docs/examples/styles/link_hover_color.tcss b/docs/examples/styles/link_hover_color.tcss deleted file mode 100644 index 234184f474..0000000000 --- a/docs/examples/styles/link_hover_color.tcss +++ /dev/null @@ -1,11 +0,0 @@ -#lbl1, #lbl2 { - link-hover-color: red; /* (1)! */ -} - -#lbl3 { - link-hover-color: hsl(60,100%,50%) 50%; -} - -#lbl4 { - link-hover-color: black; -} diff --git a/docs/examples/styles/link_hover_style.tcss b/docs/examples/styles/link_hover_style.tcss deleted file mode 100644 index 169e69d290..0000000000 --- a/docs/examples/styles/link_hover_style.tcss +++ /dev/null @@ -1,11 +0,0 @@ -#lbl1, #lbl2 { - link-hover-style: bold italic; /* (1)! */ -} - -#lbl3 { - link-hover-style: reverse strike; -} - -#lbl4 { - link-hover-style: bold; -} diff --git a/docs/examples/styles/link_style.py b/docs/examples/styles/link_style.py index bab0d7eb8c..405666ca24 100644 --- a/docs/examples/styles/link_style.py +++ b/docs/examples/styles/link_style.py @@ -3,6 +3,8 @@ class LinkStyleApp(App): + CSS_PATH = "link_style.tcss" + def compose(self): yield Label( "Visit the [link=https://textualize.io]Textualize[/link] website.", @@ -22,4 +24,6 @@ def compose(self): ) -app = LinkStyleApp(css_path="link_style.tcss") +if __name__ == "__main__": + app = LinkStyleApp() + app.run() diff --git a/docs/examples/styles/link_hover_style.py b/docs/examples/styles/link_style_hover.py similarity index 85% rename from docs/examples/styles/link_hover_style.py rename to docs/examples/styles/link_style_hover.py index 6ffe727d37..3d47406b48 100644 --- a/docs/examples/styles/link_hover_style.py +++ b/docs/examples/styles/link_style_hover.py @@ -3,6 +3,8 @@ class LinkHoverStyleApp(App): + CSS_PATH = "link_style_hover.tcss" + def compose(self): yield Label( "Visit the [link=https://textualize.io]Textualize[/link] website.", @@ -22,4 +24,6 @@ def compose(self): ) -app = LinkHoverStyleApp(css_path="link_hover_style.tcss") +if __name__ == "__main__": + app = LinkHoverStyleApp() + app.run() diff --git a/docs/examples/styles/link_style_hover.tcss b/docs/examples/styles/link_style_hover.tcss new file mode 100644 index 0000000000..b6c5d4356b --- /dev/null +++ b/docs/examples/styles/link_style_hover.tcss @@ -0,0 +1,11 @@ +#lbl1, #lbl2 { + link-style-hover: bold italic; /* (1)! */ +} + +#lbl3 { + link-style-hover: reverse strike; +} + +#lbl4 { + link-style-hover: bold; +} diff --git a/docs/examples/styles/links.py b/docs/examples/styles/links.py index 93e9eead39..7884d719d4 100644 --- a/docs/examples/styles/links.py +++ b/docs/examples/styles/links.py @@ -7,9 +7,13 @@ class LinksApp(App): + CSS_PATH = "links.tcss" + def compose(self) -> ComposeResult: yield Static(TEXT) yield Static(TEXT, id="custom") -app = LinksApp(css_path="links.tcss") +if __name__ == "__main__": + app = LinksApp() + app.run() diff --git a/docs/examples/styles/margin.py b/docs/examples/styles/margin.py index 03cd13d21d..ed8fae3136 100644 --- a/docs/examples/styles/margin.py +++ b/docs/examples/styles/margin.py @@ -11,8 +11,12 @@ class MarginApp(App): + CSS_PATH = "margin.tcss" + def compose(self): yield Label(TEXT) -app = MarginApp(css_path="margin.tcss") +if __name__ == "__main__": + app = MarginApp() + app.run() diff --git a/docs/examples/styles/margin_all.py b/docs/examples/styles/margin_all.py index 11d6ae3fad..0c6aa2fd78 100644 --- a/docs/examples/styles/margin_all.py +++ b/docs/examples/styles/margin_all.py @@ -4,6 +4,8 @@ class MarginAllApp(App): + CSS_PATH = "margin_all.tcss" + def compose(self): yield Grid( Container(Placeholder("no margin", id="p1"), classes="bordered"), @@ -17,4 +19,6 @@ def compose(self): ) -app = MarginAllApp(css_path="margin_all.tcss") +if __name__ == "__main__": + app = MarginAllApp() + app.run() diff --git a/docs/examples/styles/max_height.py b/docs/examples/styles/max_height.py index b0b0bce391..cc431b248d 100644 --- a/docs/examples/styles/max_height.py +++ b/docs/examples/styles/max_height.py @@ -4,6 +4,8 @@ class MaxHeightApp(App): + CSS_PATH = "max_height.tcss" + def compose(self): yield Horizontal( Placeholder("max-height: 10w", id="p1"), @@ -13,4 +15,6 @@ def compose(self): ) -app = MaxHeightApp(css_path="max_height.tcss") +if __name__ == "__main__": + app = MaxHeightApp() + app.run() diff --git a/docs/examples/styles/max_width.py b/docs/examples/styles/max_width.py index c944ff795b..4c09087085 100644 --- a/docs/examples/styles/max_width.py +++ b/docs/examples/styles/max_width.py @@ -4,6 +4,8 @@ class MaxWidthApp(App): + CSS_PATH = "max_width.tcss" + def compose(self): yield VerticalScroll( Placeholder("max-width: 50h", id="p1"), @@ -13,4 +15,6 @@ def compose(self): ) -app = MaxWidthApp(css_path="max_width.tcss") +if __name__ == "__main__": + app = MaxWidthApp() + app.run() diff --git a/docs/examples/styles/min_height.py b/docs/examples/styles/min_height.py index 6df7b24522..7635be7d61 100644 --- a/docs/examples/styles/min_height.py +++ b/docs/examples/styles/min_height.py @@ -4,6 +4,8 @@ class MinHeightApp(App): + CSS_PATH = "min_height.tcss" + def compose(self): yield Horizontal( Placeholder("min-height: 25%", id="p1"), @@ -13,4 +15,6 @@ def compose(self): ) -app = MinHeightApp(css_path="min_height.tcss") +if __name__ == "__main__": + app = MinHeightApp() + app.run() diff --git a/docs/examples/styles/min_width.py b/docs/examples/styles/min_width.py index 197dbe40e0..0a29abf598 100644 --- a/docs/examples/styles/min_width.py +++ b/docs/examples/styles/min_width.py @@ -4,6 +4,8 @@ class MinWidthApp(App): + CSS_PATH = "min_width.tcss" + def compose(self): yield VerticalScroll( Placeholder("min-width: 25%", id="p1"), @@ -13,4 +15,6 @@ def compose(self): ) -app = MinWidthApp(css_path="min_width.tcss") +if __name__ == "__main__": + app = MinWidthApp() + app.run() diff --git a/docs/examples/styles/offset.py b/docs/examples/styles/offset.py index 5593f9e9af..faadd020cc 100644 --- a/docs/examples/styles/offset.py +++ b/docs/examples/styles/offset.py @@ -3,10 +3,14 @@ class OffsetApp(App): + CSS_PATH = "offset.tcss" + def compose(self): yield Label("Paul (offset 8 2)", classes="paul") yield Label("Duncan (offset 4 10)", classes="duncan") yield Label("Chani (offset 0 -3)", classes="chani") -app = OffsetApp(css_path="offset.tcss") +if __name__ == "__main__": + app = OffsetApp() + app.run() diff --git a/docs/examples/styles/opacity.py b/docs/examples/styles/opacity.py index e3cdd1db7c..e701a9ee42 100644 --- a/docs/examples/styles/opacity.py +++ b/docs/examples/styles/opacity.py @@ -3,6 +3,8 @@ class OpacityApp(App): + CSS_PATH = "opacity.tcss" + def compose(self): yield Label("opacity: 0%", id="zero-opacity") yield Label("opacity: 25%", id="quarter-opacity") @@ -11,4 +13,6 @@ def compose(self): yield Label("opacity: 100%", id="full-opacity") -app = OpacityApp(css_path="opacity.tcss") +if __name__ == "__main__": + app = OpacityApp() + app.run() diff --git a/docs/examples/styles/outline.py b/docs/examples/styles/outline.py index b2e679a0b6..ac7f7fd420 100644 --- a/docs/examples/styles/outline.py +++ b/docs/examples/styles/outline.py @@ -11,8 +11,12 @@ class OutlineApp(App): + CSS_PATH = "outline.tcss" + def compose(self): yield Label(TEXT) -app = OutlineApp(css_path="outline.tcss") +if __name__ == "__main__": + app = OutlineApp() + app.run() diff --git a/docs/examples/styles/outline_all.py b/docs/examples/styles/outline_all.py index c64645e98f..b841b220f3 100644 --- a/docs/examples/styles/outline_all.py +++ b/docs/examples/styles/outline_all.py @@ -4,6 +4,8 @@ class AllOutlinesApp(App): + CSS_PATH = "outline_all.tcss" + def compose(self): yield Grid( Label("ascii", id="ascii"), @@ -24,4 +26,6 @@ def compose(self): ) -app = AllOutlinesApp(css_path="outline_all.tcss") +if __name__ == "__main__": + app = AllOutlinesApp() + app.run() diff --git a/docs/examples/styles/outline_vs_border.py b/docs/examples/styles/outline_vs_border.py index 80e656bcf5..0b82c0c043 100644 --- a/docs/examples/styles/outline_vs_border.py +++ b/docs/examples/styles/outline_vs_border.py @@ -11,10 +11,14 @@ class OutlineBorderApp(App): + CSS_PATH = "outline_vs_border.tcss" + def compose(self): yield Label(TEXT, classes="outline") yield Label(TEXT, classes="border") yield Label(TEXT, classes="outline border") -app = OutlineBorderApp(css_path="outline_vs_border.tcss") +if __name__ == "__main__": + app = OutlineBorderApp() + app.run() diff --git a/docs/examples/styles/overflow.py b/docs/examples/styles/overflow.py index 9fe7cf9253..ea440da189 100644 --- a/docs/examples/styles/overflow.py +++ b/docs/examples/styles/overflow.py @@ -12,6 +12,8 @@ class OverflowApp(App): + CSS_PATH = "overflow.tcss" + def compose(self): yield Horizontal( VerticalScroll(Static(TEXT), Static(TEXT), Static(TEXT), id="left"), @@ -19,4 +21,6 @@ def compose(self): ) -app = OverflowApp(css_path="overflow.tcss") +if __name__ == "__main__": + app = OverflowApp() + app.run() diff --git a/docs/examples/styles/padding.py b/docs/examples/styles/padding.py index e6ed1a9f6d..616c41523e 100644 --- a/docs/examples/styles/padding.py +++ b/docs/examples/styles/padding.py @@ -11,8 +11,12 @@ class PaddingApp(App): + CSS_PATH = "padding.tcss" + def compose(self): yield Label(TEXT) -app = PaddingApp(css_path="padding.tcss") +if __name__ == "__main__": + app = PaddingApp() + app.run() diff --git a/docs/examples/styles/padding_all.py b/docs/examples/styles/padding_all.py index c857c26c1a..d9af47b8f4 100644 --- a/docs/examples/styles/padding_all.py +++ b/docs/examples/styles/padding_all.py @@ -1,9 +1,11 @@ from textual.app import App -from textual.containers import Container, Grid +from textual.containers import Grid from textual.widgets import Placeholder class PaddingAllApp(App): + CSS_PATH = "padding_all.tcss" + def compose(self): yield Grid( Placeholder("no padding", id="p1"), @@ -17,4 +19,6 @@ def compose(self): ) -app = PaddingAllApp(css_path="padding_all.tcss") +if __name__ == "__main__": + app = PaddingAllApp() + app.run() diff --git a/docs/examples/styles/row_span.py b/docs/examples/styles/row_span.py index adfca09099..c1721f98cf 100644 --- a/docs/examples/styles/row_span.py +++ b/docs/examples/styles/row_span.py @@ -4,6 +4,8 @@ class MyApp(App): + CSS_PATH = "row_span.tcss" + def compose(self): yield Grid( Placeholder(id="p1"), @@ -16,4 +18,6 @@ def compose(self): ) -app = MyApp(css_path="row_span.tcss") +if __name__ == "__main__": + app = MyApp() + app.run() diff --git a/docs/examples/styles/scrollbar_corner_color.py b/docs/examples/styles/scrollbar_corner_color.py index 4247099adb..462de93e52 100644 --- a/docs/examples/styles/scrollbar_corner_color.py +++ b/docs/examples/styles/scrollbar_corner_color.py @@ -12,8 +12,12 @@ class ScrollbarCornerColorApp(App): + CSS_PATH = "scrollbar_corner_color.tcss" + def compose(self): yield Label(TEXT.replace("\n", " ") + "\n" + TEXT * 10) -app = ScrollbarCornerColorApp(css_path="scrollbar_corner_color.tcss") +if __name__ == "__main__": + app = ScrollbarCornerColorApp() + app.run() diff --git a/docs/examples/styles/scrollbar_gutter.py b/docs/examples/styles/scrollbar_gutter.py index 42bc81d495..aab689376b 100644 --- a/docs/examples/styles/scrollbar_gutter.py +++ b/docs/examples/styles/scrollbar_gutter.py @@ -11,8 +11,12 @@ class ScrollbarGutterApp(App): + CSS_PATH = "scrollbar_gutter.tcss" + def compose(self): yield Static(TEXT, id="text-box") -app = ScrollbarGutterApp(css_path="scrollbar_gutter.tcss") +if __name__ == "__main__": + app = ScrollbarGutterApp() + app.run() diff --git a/docs/examples/styles/scrollbar_size.py b/docs/examples/styles/scrollbar_size.py index 0191a1a111..e7217052f3 100644 --- a/docs/examples/styles/scrollbar_size.py +++ b/docs/examples/styles/scrollbar_size.py @@ -13,8 +13,12 @@ class ScrollbarApp(App): + CSS_PATH = "scrollbar_size.tcss" + def compose(self): yield ScrollableContainer(Label(TEXT * 5), classes="panel") -app = ScrollbarApp(css_path="scrollbar_size.tcss") +if __name__ == "__main__": + app = ScrollbarApp() + app.run() diff --git a/docs/examples/styles/scrollbar_size2.py b/docs/examples/styles/scrollbar_size2.py index d7c9c55e98..d6bd1d2449 100644 --- a/docs/examples/styles/scrollbar_size2.py +++ b/docs/examples/styles/scrollbar_size2.py @@ -13,6 +13,8 @@ class ScrollbarApp(App): + CSS_PATH = "scrollbar_size2.tcss" + def compose(self): yield Horizontal( ScrollableContainer(Label(TEXT * 5), id="v1"), @@ -21,6 +23,6 @@ def compose(self): ) -app = ScrollbarApp(css_path="scrollbar_size2.tcss") if __name__ == "__main__": + app = ScrollbarApp() app.run() diff --git a/docs/examples/styles/scrollbars.py b/docs/examples/styles/scrollbars.py index 2762313b5e..505e3b4a92 100644 --- a/docs/examples/styles/scrollbars.py +++ b/docs/examples/styles/scrollbars.py @@ -13,6 +13,8 @@ class ScrollbarApp(App): + CSS_PATH = "scrollbars.tcss" + def compose(self): yield Horizontal( ScrollableContainer(Label(TEXT * 10)), @@ -20,6 +22,6 @@ def compose(self): ) -app = ScrollbarApp(css_path="scrollbars.tcss") if __name__ == "__main__": + app = ScrollbarApp() app.run() diff --git a/docs/examples/styles/scrollbars2.py b/docs/examples/styles/scrollbars2.py index be26ca4c00..c53af75367 100644 --- a/docs/examples/styles/scrollbars2.py +++ b/docs/examples/styles/scrollbars2.py @@ -12,8 +12,12 @@ class Scrollbar2App(App): + CSS_PATH = "scrollbars2.tcss" + def compose(self): yield Label(TEXT * 10) -app = Scrollbar2App(css_path="scrollbars2.tcss") +if __name__ == "__main__": + app = Scrollbar2App() + app.run() diff --git a/docs/examples/styles/text_align.py b/docs/examples/styles/text_align.py index 3608f2cfe6..f397c52ad2 100644 --- a/docs/examples/styles/text_align.py +++ b/docs/examples/styles/text_align.py @@ -10,6 +10,8 @@ class TextAlign(App): + CSS_PATH = "text_align.tcss" + def compose(self): yield Grid( Label("[b]Left aligned[/]\n" + TEXT, id="one"), @@ -19,4 +21,6 @@ def compose(self): ) -app = TextAlign(css_path="text_align.tcss") +if __name__ == "__main__": + app = TextAlign() + app.run() diff --git a/docs/examples/styles/text_opacity.py b/docs/examples/styles/text_opacity.py index f34340c2dd..26d44ea7a1 100644 --- a/docs/examples/styles/text_opacity.py +++ b/docs/examples/styles/text_opacity.py @@ -3,6 +3,8 @@ class TextOpacityApp(App): + CSS_PATH = "text_opacity.tcss" + def compose(self): yield Label("text-opacity: 0%", id="zero-opacity") yield Label("text-opacity: 25%", id="quarter-opacity") @@ -11,4 +13,6 @@ def compose(self): yield Label("text-opacity: 100%", id="full-opacity") -app = TextOpacityApp(css_path="text_opacity.tcss") +if __name__ == "__main__": + app = TextOpacityApp() + app.run() diff --git a/docs/examples/styles/text_style.py b/docs/examples/styles/text_style.py index 01f7610d2f..fc22b89719 100644 --- a/docs/examples/styles/text_style.py +++ b/docs/examples/styles/text_style.py @@ -11,10 +11,14 @@ class TextStyleApp(App): + CSS_PATH = "text_style.tcss" + def compose(self): yield Label(TEXT, id="lbl1") yield Label(TEXT, id="lbl2") yield Label(TEXT, id="lbl3") -app = TextStyleApp(css_path="text_style.tcss") +if __name__ == "__main__": + app = TextStyleApp() + app.run() diff --git a/docs/examples/styles/text_style_all.py b/docs/examples/styles/text_style_all.py index c4533a7f6e..48cba3a2d3 100644 --- a/docs/examples/styles/text_style_all.py +++ b/docs/examples/styles/text_style_all.py @@ -12,6 +12,8 @@ class AllTextStyleApp(App): + CSS_PATH = "text_style_all.tcss" + def compose(self): yield Grid( Label("none\n" + TEXT, id="lbl1"), @@ -25,4 +27,6 @@ def compose(self): ) -app = AllTextStyleApp(css_path="text_style_all.tcss") +if __name__ == "__main__": + app = AllTextStyleApp() + app.run() diff --git a/docs/examples/styles/tint.py b/docs/examples/styles/tint.py index ea512b9226..eafa240a55 100644 --- a/docs/examples/styles/tint.py +++ b/docs/examples/styles/tint.py @@ -4,6 +4,8 @@ class TintApp(App): + CSS_PATH = "tint.tcss" + def compose(self): color = Color.parse("green") for tint_alpha in range(0, 101, 10): @@ -12,4 +14,6 @@ def compose(self): yield widget -app = TintApp(css_path="tint.tcss") +if __name__ == "__main__": + app = TintApp() + app.run() diff --git a/docs/examples/styles/visibility.py b/docs/examples/styles/visibility.py index fe67aa31c8..f83169d8a1 100644 --- a/docs/examples/styles/visibility.py +++ b/docs/examples/styles/visibility.py @@ -3,10 +3,14 @@ class VisibilityApp(App): + CSS_PATH = "visibility.tcss" + def compose(self): yield Label("Widget 1") yield Label("Widget 2", classes="invisible") yield Label("Widget 3") -app = VisibilityApp(css_path="visibility.tcss") +if __name__ == "__main__": + app = VisibilityApp() + app.run() diff --git a/docs/examples/styles/visibility_containers.py b/docs/examples/styles/visibility_containers.py index 8be5633867..547cc5fe1c 100644 --- a/docs/examples/styles/visibility_containers.py +++ b/docs/examples/styles/visibility_containers.py @@ -4,6 +4,8 @@ class VisibilityContainersApp(App): + CSS_PATH = "visibility_containers.tcss" + def compose(self): yield VerticalScroll( Horizontal( @@ -27,4 +29,6 @@ def compose(self): ) -app = VisibilityContainersApp(css_path="visibility_containers.tcss") +if __name__ == "__main__": + app = VisibilityContainersApp() + app.run() diff --git a/docs/examples/styles/width.py b/docs/examples/styles/width.py index 736f527495..993c86d43d 100644 --- a/docs/examples/styles/width.py +++ b/docs/examples/styles/width.py @@ -3,8 +3,12 @@ class WidthApp(App): + CSS_PATH = "width.tcss" + def compose(self): yield Widget() -app = WidthApp(css_path="width.tcss") +if __name__ == "__main__": + app = WidthApp() + app.run() diff --git a/docs/examples/styles/width_comparison.py b/docs/examples/styles/width_comparison.py index 509479b155..44d7f27c74 100644 --- a/docs/examples/styles/width_comparison.py +++ b/docs/examples/styles/width_comparison.py @@ -10,6 +10,8 @@ def compose(self): class WidthComparisonApp(App): + CSS_PATH = "width_comparison.tcss" + def compose(self): yield Horizontal( Placeholder(id="cells"), # (1)! @@ -25,6 +27,6 @@ def compose(self): yield Ruler() -app = WidthComparisonApp(css_path="width_comparison.tcss") if __name__ == "__main__": + app = WidthComparisonApp() app.run() diff --git a/docs/examples/widgets/content_switcher.py b/docs/examples/widgets/content_switcher.py index 82cb43aace..8e235fe564 100644 --- a/docs/examples/widgets/content_switcher.py +++ b/docs/examples/widgets/content_switcher.py @@ -1,5 +1,3 @@ -from rich.align import VerticalCenter - from textual.app import App, ComposeResult from textual.containers import Horizontal, VerticalScroll from textual.widgets import Button, ContentSwitcher, DataTable, Markdown diff --git a/docs/examples/widgets/data_table_sort.py b/docs/examples/widgets/data_table_sort.py new file mode 100644 index 0000000000..599a629394 --- /dev/null +++ b/docs/examples/widgets/data_table_sort.py @@ -0,0 +1,92 @@ +from rich.text import Text + +from textual.app import App, ComposeResult +from textual.widgets import DataTable, Footer + +ROWS = [ + ("lane", "swimmer", "country", "time 1", "time 2"), + (4, "Joseph Schooling", Text("Singapore", style="italic"), 50.39, 51.84), + (2, "Michael Phelps", Text("United States", style="italic"), 50.39, 51.84), + (5, "Chad le Clos", Text("South Africa", style="italic"), 51.14, 51.73), + (6, "László Cseh", Text("Hungary", style="italic"), 51.14, 51.58), + (3, "Li Zhuhao", Text("China", style="italic"), 51.26, 51.26), + (8, "Mehdy Metella", Text("France", style="italic"), 51.58, 52.15), + (7, "Tom Shields", Text("United States", style="italic"), 51.73, 51.12), + (1, "Aleksandr Sadovnikov", Text("Russia", style="italic"), 51.84, 50.85), + (10, "Darren Burns", Text("Scotland", style="italic"), 51.84, 51.55), +] + + +class TableApp(App): + BINDINGS = [ + ("a", "sort_by_average_time", "Sort By Average Time"), + ("n", "sort_by_last_name", "Sort By Last Name"), + ("c", "sort_by_country", "Sort By Country"), + ("d", "sort_by_columns", "Sort By Columns (Only)"), + ] + + current_sorts: set = set() + + def compose(self) -> ComposeResult: + yield DataTable() + yield Footer() + + def on_mount(self) -> None: + table = self.query_one(DataTable) + for col in ROWS[0]: + table.add_column(col, key=col) + table.add_rows(ROWS[1:]) + + def sort_reverse(self, sort_type: str): + """Determine if `sort_type` is ascending or descending.""" + reverse = sort_type in self.current_sorts + if reverse: + self.current_sorts.remove(sort_type) + else: + self.current_sorts.add(sort_type) + return reverse + + def action_sort_by_average_time(self) -> None: + """Sort DataTable by average of times (via a function) and + passing of column data through positional arguments.""" + + def sort_by_average_time_then_last_name(row_data): + name, *scores = row_data + return (sum(scores) / len(scores), name.split()[-1]) + + table = self.query_one(DataTable) + table.sort( + "swimmer", + "time 1", + "time 2", + key=sort_by_average_time_then_last_name, + reverse=self.sort_reverse("time"), + ) + + def action_sort_by_last_name(self) -> None: + """Sort DataTable by last name of swimmer (via a lambda).""" + table = self.query_one(DataTable) + table.sort( + "swimmer", + key=lambda swimmer: swimmer.split()[-1], + reverse=self.sort_reverse("swimmer"), + ) + + def action_sort_by_country(self) -> None: + """Sort DataTable by country which is a `Rich.Text` object.""" + table = self.query_one(DataTable) + table.sort( + "country", + key=lambda country: country.plain, + reverse=self.sort_reverse("country"), + ) + + def action_sort_by_columns(self) -> None: + """Sort DataTable without a key.""" + table = self.query_one(DataTable) + table.sort("swimmer", "lane", reverse=self.sort_reverse("columns")) + + +app = TableApp() +if __name__ == "__main__": + app.run() diff --git a/docs/examples/widgets/horizontal_rules.py b/docs/examples/widgets/horizontal_rules.py index 2327e474ec..643f129bbe 100644 --- a/docs/examples/widgets/horizontal_rules.py +++ b/docs/examples/widgets/horizontal_rules.py @@ -1,6 +1,6 @@ from textual.app import App, ComposeResult -from textual.widgets import Rule, Label from textual.containers import Vertical +from textual.widgets import Label, Rule class HorizontalRulesApp(App): diff --git a/docs/examples/widgets/input_types.py b/docs/examples/widgets/input_types.py new file mode 100644 index 0000000000..0544f2562d --- /dev/null +++ b/docs/examples/widgets/input_types.py @@ -0,0 +1,13 @@ +from textual.app import App, ComposeResult +from textual.widgets import Input + + +class InputApp(App): + def compose(self) -> ComposeResult: + yield Input(placeholder="An integer", type="integer") + yield Input(placeholder="A number", type="number") + + +if __name__ == "__main__": + app = InputApp() + app.run() diff --git a/docs/examples/widgets/java_highlights.scm b/docs/examples/widgets/java_highlights.scm new file mode 100644 index 0000000000..b6259be125 --- /dev/null +++ b/docs/examples/widgets/java_highlights.scm @@ -0,0 +1,140 @@ +; Methods + +(method_declaration + name: (identifier) @function.method) +(method_invocation + name: (identifier) @function.method) +(super) @function.builtin + +; Annotations + +(annotation + name: (identifier) @attribute) +(marker_annotation + name: (identifier) @attribute) + +"@" @operator + +; Types + +(type_identifier) @type + +(interface_declaration + name: (identifier) @type) +(class_declaration + name: (identifier) @type) +(enum_declaration + name: (identifier) @type) + +((field_access + object: (identifier) @type) + (#match? @type "^[A-Z]")) +((scoped_identifier + scope: (identifier) @type) + (#match? @type "^[A-Z]")) +((method_invocation + object: (identifier) @type) + (#match? @type "^[A-Z]")) +((method_reference + . (identifier) @type) + (#match? @type "^[A-Z]")) + +(constructor_declaration + name: (identifier) @type) + +[ + (boolean_type) + (integral_type) + (floating_point_type) + (floating_point_type) + (void_type) +] @type.builtin + +; Variables + +((identifier) @constant + (#match? @constant "^_*[A-Z][A-Z\\d_]+$")) + +(identifier) @variable + +(this) @variable.builtin + +; Literals + +[ + (hex_integer_literal) + (decimal_integer_literal) + (octal_integer_literal) + (decimal_floating_point_literal) + (hex_floating_point_literal) +] @number + +[ + (character_literal) + (string_literal) +] @string + +[ + (true) + (false) + (null_literal) +] @constant.builtin + +[ + (line_comment) + (block_comment) +] @comment + +; Keywords + +[ + "abstract" + "assert" + "break" + "case" + "catch" + "class" + "continue" + "default" + "do" + "else" + "enum" + "exports" + "extends" + "final" + "finally" + "for" + "if" + "implements" + "import" + "instanceof" + "interface" + "module" + "native" + "new" + "non-sealed" + "open" + "opens" + "package" + "private" + "protected" + "provides" + "public" + "requires" + "return" + "sealed" + "static" + "strictfp" + "switch" + "synchronized" + "throw" + "throws" + "to" + "transient" + "transitive" + "try" + "uses" + "volatile" + "while" + "with" +] @keyword diff --git a/docs/examples/widgets/select_from_values_widget.py b/docs/examples/widgets/select_from_values_widget.py new file mode 100644 index 0000000000..23ba16acbc --- /dev/null +++ b/docs/examples/widgets/select_from_values_widget.py @@ -0,0 +1,26 @@ +from textual import on +from textual.app import App, ComposeResult +from textual.widgets import Header, Select + +LINES = """I must not fear. +Fear is the mind-killer. +Fear is the little-death that brings total obliteration. +I will face my fear. +I will permit it to pass over me and through me.""".splitlines() + + +class SelectApp(App): + CSS_PATH = "select.tcss" + + def compose(self) -> ComposeResult: + yield Header() + yield Select.from_values(LINES) + + @on(Select.Changed) + def select_changed(self, event: Select.Changed) -> None: + self.title = str(event.value) + + +if __name__ == "__main__": + app = SelectApp() + app.run() diff --git a/docs/examples/widgets/select_widget_no_blank.py b/docs/examples/widgets/select_widget_no_blank.py new file mode 100644 index 0000000000..8fab93667f --- /dev/null +++ b/docs/examples/widgets/select_widget_no_blank.py @@ -0,0 +1,38 @@ +from textual import on +from textual.app import App, ComposeResult +from textual.widgets import Header, Select + +LINES = """I must not fear. +Fear is the mind-killer. +Fear is the little-death that brings total obliteration. +I will face my fear. +I will permit it to pass over me and through me.""".splitlines() + +ALTERNATE_LINES = """Twinkle, twinkle, little star, +How I wonder what you are! +Up above the world so high, +Like a diamond in the sky. +Twinkle, twinkle, little star, +How I wonder what you are!""".splitlines() + + +class SelectApp(App): + CSS_PATH = "select.tcss" + + BINDINGS = [("s", "swap", "Swap Select options")] + + def compose(self) -> ComposeResult: + yield Header() + yield Select(zip(LINES, LINES), allow_blank=False) + + @on(Select.Changed) + def select_changed(self, event: Select.Changed) -> None: + self.title = str(event.value) + + def action_swap(self) -> None: + self.query_one(Select).set_options(zip(ALTERNATE_LINES, ALTERNATE_LINES)) + + +if __name__ == "__main__": + app = SelectApp() + app.run() diff --git a/docs/examples/widgets/tabbed_content_label_color.py b/docs/examples/widgets/tabbed_content_label_color.py new file mode 100644 index 0000000000..573944b948 --- /dev/null +++ b/docs/examples/widgets/tabbed_content_label_color.py @@ -0,0 +1,25 @@ +from textual.app import App, ComposeResult +from textual.widgets import Label, TabbedContent, TabPane + + +class ColorTabsApp(App): + CSS = """ + TabbedContent #--content-tab-green { + color: green; + } + + TabbedContent #--content-tab-red { + color: red; + } + """ + + def compose(self) -> ComposeResult: + with TabbedContent(): + with TabPane("Red", id="red"): + yield Label("Red!") + with TabPane("Green", id="green"): + yield Label("Green!") + + +if __name__ == "__main__": + ColorTabsApp().run() diff --git a/docs/examples/widgets/text_area_custom_language.py b/docs/examples/widgets/text_area_custom_language.py new file mode 100644 index 0000000000..70ee7e16b9 --- /dev/null +++ b/docs/examples/widgets/text_area_custom_language.py @@ -0,0 +1,34 @@ +from pathlib import Path + +from tree_sitter_languages import get_language + +from textual.app import App, ComposeResult +from textual.widgets import TextArea + +java_language = get_language("java") +java_highlight_query = (Path(__file__).parent / "java_highlights.scm").read_text() +java_code = """\ +class HelloWorld { + public static void main(String[] args) { + System.out.println("Hello, World!"); + } +} +""" + + +class TextAreaCustomLanguage(App): + def compose(self) -> ComposeResult: + text_area = TextArea(text=java_code) + text_area.cursor_blink = False + + # Register the Java language and highlight query + text_area.register_language(java_language, java_highlight_query) + + # Switch to Java + text_area.language = "java" + yield text_area + + +app = TextAreaCustomLanguage() +if __name__ == "__main__": + app.run() diff --git a/docs/examples/widgets/text_area_custom_theme.py b/docs/examples/widgets/text_area_custom_theme.py new file mode 100644 index 0000000000..c2c81a115f --- /dev/null +++ b/docs/examples/widgets/text_area_custom_theme.py @@ -0,0 +1,42 @@ +from rich.style import Style + +from textual._text_area_theme import TextAreaTheme +from textual.app import App, ComposeResult +from textual.widgets import TextArea + +TEXT = """\ +# says hello +def hello(name): + print("hello" + name) + +# says goodbye +def goodbye(name): + print("goodbye" + name) +""" + +MY_THEME = TextAreaTheme( + # This name will be used to refer to the theme... + name="my_cool_theme", + # Basic styles such as background, cursor, selection, gutter, etc... + cursor_style=Style(color="white", bgcolor="blue"), + cursor_line_style=Style(bgcolor="yellow"), + # `syntax_styles` maps tokens parsed from the document to Rich styles. + syntax_styles={ + "string": Style(color="red"), + "comment": Style(color="magenta"), + }, +) + + +class TextAreaCustomThemes(App): + def compose(self) -> ComposeResult: + text_area = TextArea(TEXT, language="python") + text_area.cursor_blink = False + text_area.register_theme(MY_THEME) + text_area.theme = "my_cool_theme" + yield text_area + + +app = TextAreaCustomThemes() +if __name__ == "__main__": + app.run() diff --git a/docs/examples/widgets/text_area_example.py b/docs/examples/widgets/text_area_example.py new file mode 100644 index 0000000000..2e0e31c060 --- /dev/null +++ b/docs/examples/widgets/text_area_example.py @@ -0,0 +1,20 @@ +from textual.app import App, ComposeResult +from textual.widgets import TextArea + +TEXT = """\ +def hello(name): + print("hello" + name) + +def goodbye(name): + print("goodbye" + name) +""" + + +class TextAreaExample(App): + def compose(self) -> ComposeResult: + yield TextArea(TEXT, language="python") + + +app = TextAreaExample() +if __name__ == "__main__": + app.run() diff --git a/docs/examples/widgets/text_area_extended.py b/docs/examples/widgets/text_area_extended.py new file mode 100644 index 0000000000..8ac237db88 --- /dev/null +++ b/docs/examples/widgets/text_area_extended.py @@ -0,0 +1,23 @@ +from textual import events +from textual.app import App, ComposeResult +from textual.widgets import TextArea + + +class ExtendedTextArea(TextArea): + """A subclass of TextArea with parenthesis-closing functionality.""" + + def _on_key(self, event: events.Key) -> None: + if event.character == "(": + self.insert("()") + self.move_cursor_relative(columns=-1) + event.prevent_default() + + +class TextAreaKeyPressHook(App): + def compose(self) -> ComposeResult: + yield ExtendedTextArea(language="python") + + +app = TextAreaKeyPressHook() +if __name__ == "__main__": + app.run() diff --git a/docs/examples/widgets/text_area_selection.py b/docs/examples/widgets/text_area_selection.py new file mode 100644 index 0000000000..4165eb2d2d --- /dev/null +++ b/docs/examples/widgets/text_area_selection.py @@ -0,0 +1,23 @@ +from textual.app import App, ComposeResult +from textual.widgets import TextArea +from textual.widgets.text_area import Selection + +TEXT = """\ +def hello(name): + print("hello" + name) + +def goodbye(name): + print("goodbye" + name) +""" + + +class TextAreaSelection(App): + def compose(self) -> ComposeResult: + text_area = TextArea(TEXT, language="python") + text_area.selection = Selection(start=(0, 0), end=(2, 0)) # (1)! + yield text_area + + +app = TextAreaSelection() +if __name__ == "__main__": + app.run() diff --git a/docs/examples/widgets/vertical_rules.py b/docs/examples/widgets/vertical_rules.py index 27592bef8f..5001045305 100644 --- a/docs/examples/widgets/vertical_rules.py +++ b/docs/examples/widgets/vertical_rules.py @@ -1,6 +1,6 @@ from textual.app import App, ComposeResult -from textual.widgets import Rule, Label from textual.containers import Horizontal +from textual.widgets import Label, Rule class VerticalRulesApp(App): diff --git a/docs/getting_started.md b/docs/getting_started.md index 0e031a2660..46addc0bc2 100644 --- a/docs/getting_started.md +++ b/docs/getting_started.md @@ -2,7 +2,7 @@ All you need to get started building Textual apps. ## Requirements -Textual requires Python 3.7 or later (if you have a choice, pick the most recent Python). Textual runs on Linux, macOS, Windows and probably any OS where Python also runs. +Textual requires Python 3.8 or later (if you have a choice, pick the most recent Python). Textual runs on Linux, macOS, Windows and probably any OS where Python also runs. !!! info inline end "Your platform" diff --git a/docs/guide/CSS.md b/docs/guide/CSS.md index 0d38a616cb..5eccb2391a 100644 --- a/docs/guide/CSS.md +++ b/docs/guide/CSS.md @@ -12,7 +12,7 @@ CSS stands for _Cascading Stylesheet_. A stylesheet is a list of styles and rule Let's look at some Textual CSS. -```sass +```css Header { dock: top; height: 3; @@ -26,7 +26,7 @@ This is an example of a CSS _rule set_. There may be many such sections in any g Let's break this CSS code down a bit. -```sass hl_lines="1" +```css hl_lines="1" Header { dock: top; height: 3; @@ -38,7 +38,7 @@ Header { The first line is a _selector_ which tells Textual which widget(s) to modify. In the above example, the styles will be applied to a widget defined by the Python class `Header`. -```sass hl_lines="2 3 4 5 6" +```css hl_lines="2 3 4 5 6" Header { dock: top; height: 3; @@ -153,7 +153,7 @@ These are used by the CSS to identify parts of the DOM. We will cover these in t Here's the CSS file we are applying: -```sass title="dom4.tcss" +```css title="dom4.tcss" --8<-- "docs/examples/guide/dom4.tcss" ``` @@ -206,7 +206,7 @@ class Button(Static): The following rule applies a border to this widget: -```sass +```css Button { border: solid blue; } @@ -214,7 +214,7 @@ Button { The type selector will also match a widget's base classes. Consequently, a `Static` selector will also style the button because the `Button` Python class extends `Static`. -```sass +```css Static { background: blue; border: rounded white; @@ -239,7 +239,7 @@ yield Button(id="next") You can match an ID with a selector starting with a hash (`#`). Here is how you might draw a red outline around the above button: -```sass +```css #next { outline: red; } @@ -267,7 +267,7 @@ yield Button(classes="error disabled") To match a Widget with a given class in CSS you can precede the class name with a dot (`.`). Here's a rule with a class selector to match the `"success"` class name: -```sass +```css .success { background: green; color: white; @@ -280,7 +280,7 @@ To match a Widget with a given class in CSS you can precede the class name with Class name selectors may be _chained_ together by appending another full stop and class name. The selector will match a widget that has _all_ of the class names set. For instance, the following sets a red background on widgets that have both `error` _and_ `disabled` class names. -```sass +```css .error.disabled { background: darkred; } @@ -301,7 +301,7 @@ The _universal_ selector is denoted by an asterisk and will match _all_ widgets. For example, the following will draw a red outline around all widgets: -```sass +```css * { outline: solid red; } @@ -311,7 +311,7 @@ For example, the following will draw a red outline around all widgets: Pseudo classes can be used to match widgets in a particular state. Pseudo classes are set automatically by Textual. For instance, you might want a button to have a green background when the mouse cursor moves over it. We can do this with the `:hover` pseudo selector. -```sass +```css Button:hover { background: green; } @@ -324,7 +324,10 @@ Here are some other pseudo classes: - `:disabled` Matches widgets which are in a disabled state. - `:enabled` Matches widgets which are in an enabled state. - `:focus` Matches widgets which have input focus. -- `:focus-within` Matches widgets with a focused a child widget. +- `:blur` Matches widgets which *do not* have input focus. +- `:focus-within` Matches widgets with a focused child widget. +- `:dark` Matches widgets in dark mode (where `App.dark == True`). +- `:light` Matches widgets in dark mode (where `App.dark == False`). ## Combinators @@ -342,7 +345,7 @@ Here's a section of DOM to illustrate this combinator: Let's say we want to make the text of the buttons in the dialog bold, but we _don't_ want to change the Button in the sidebar. We can do this with the following rule: -```sass hl_lines="1" +```css hl_lines="1" #dialog Button { text-style: bold; } @@ -352,7 +355,7 @@ The `#dialog Button` selector matches all buttons that are below the widget with As with all selectors, you can combine as many as you wish. The following will match a `Button` that is under a `Horizontal` widget _and_ under a widget with an id of `"dialog"`: -```sass +```css #dialog Horizontal Button { text-style: bold; } @@ -370,7 +373,7 @@ Let's use this to match the Button in the sidebar given the following DOM: We can use the following CSS to style all buttons which have a parent with an ID of `sidebar`: -```sass +```css #sidebar > Button { text-style: underline; } @@ -396,7 +399,7 @@ The specificity rules are usually enough to fix any conflicts in your stylesheet Here's an example that makes buttons blue when hovered over with the mouse, regardless of any other selectors that match Buttons: -```sass hl_lines="2" +```css hl_lines="2" Button:hover { background: blue !important; } @@ -408,14 +411,14 @@ You can define variables to reduce repetition and encourage consistency in your Variables in Textual CSS are prefixed with `$`. Here's an example of how you might define a variable called `$border`: -```sass +```css $border: wide green; ``` With our variable assigned, we can write `$border` and it will be substituted with `wide green`. Consider the following snippet: -```sass +```css #foo { border: $border; } @@ -423,7 +426,7 @@ Consider the following snippet: This will be translated into: -```sass +```css #foo { border: wide green; } @@ -440,3 +443,126 @@ Variables can refer to other variables. Let's say we define a variable `$success: lime;`. Our `$border` variable could then be updated to `$border: wide $success;`, which will be translated to `$border: wide lime;`. + +## Initial value + +All CSS rules support a special value called `initial`, which will reset a value back to its default. + +Let's look at an example. +The following will set the background of a button to green: + +```css +Button { + background: green; +} +``` + +If we want a specific button (or buttons) to use the default color, we can set the value to `initial`. +For instance, if we have a widget with a (CSS) class called `dialog`, we could reset the background color of all buttons inside the dialog with the following CSS: + +```css +.dialog Button { + background: initial; +} +``` + +Note that `initial` will set the value back to the value defined in any [default css](./widgets.md#default-css). +If you use `initial` within default css, it will treat the rule as completely unstyled. + + +## Nesting CSS + +!!! tip "Added in version 0.47.0" + +CSS rule sets may be *nested*, i.e. they can contain other rule sets. +When a rule set occurs within an existing rule set, it inherits the selector from the enclosing rule set. + +Let's put this into practical terms. +The following example will display two boxes containing the text "Yes" and "No" respectively. +These could eventually form the basis for buttons, but for this demonstration we are only interested in the CSS. + +=== "nesting01.tcss (no nesting)" + + ```css + --8<-- "docs/examples/guide/css/nesting01.tcss" + ``` + +=== "nesting01.py" + + ```python + --8<-- "docs/examples/guide/css/nesting01.py" + ``` + +=== "Output" + + ```{.textual path="docs/examples/guide/css/nesting01.py"} + ``` + +The CSS is quite straightforward; there is one rule for the container, one for all buttons, and one rule for each of the buttons. +However it is easy to imagine this stylesheet growing more rules as we add features. + +Nesting allows us to group rule sets which have common selectors. +In the example above, the rules all start with `#questions`. +When we see a common prefix on the selectors, this is a good indication that we can use nesting. + +The following produces identical results to the previous example, but adds nesting of the rules. + +=== "nesting02.tcss (with nesting)" + + ```css + --8<-- "docs/examples/guide/css/nesting02.tcss" + ``` + +=== "nesting02.py" + + ```python + --8<-- "docs/examples/guide/css/nesting02.py" + ``` + +=== "Output" + + ```{.textual path="docs/examples/guide/css/nesting02.py"} + ``` + +!!! tip + + Indenting the rule sets is not strictly required, but it does make it easier to understand how the rule sets are related to each other. + +In the first example we had a rule set that began with the selector `#questions .button`, which would match any widget with a class called "button" that is inside a container with id `questions`. + +In the second example, the button rule selector is simply `.button`, but it is *within* the rule set with selector `#questions`. +The nesting means that the button rule set will inherit the selector from the outer rule set, so it is equivalent to `#questions .button`. + +### Nesting selector + +The two remaining rules are nested within the button rule, which means they will inherit their selectors from the button rule set *and* the outer `#questions` rule set. + +You may have noticed that the rules for the button styles contain a syntax we haven't seen before. +The rule for the Yes button is `&.affirmative`. +The ampersand (`&`) is known as the *nesting selector* and it tells Textual that the selector should be combined with the selector from the outer rule set. + +So `&.affirmative` in the example above, produces the equivalent of `#questions .button.affirmative` which selects a widget with both the `button` and `affirmative` classes. +Without `&` it would be equivalent to `#questions .button .affirmative` (note the additional space) which would only match a widget with class `affirmative` inside a container with class `button`. + + +For reference, lets see those two CSS files side-by-side: + +=== "nesting01.tcss" + + ```css + --8<-- "docs/examples/guide/css/nesting01.tcss" + ``` + +=== "nesting02.tcss" + + ```sass + --8<-- "docs/examples/guide/css/nesting02.tcss" + ``` + + +Note how nesting bundles related rules together. +If we were to add other selectors for additional screens or widgets, it would be easier to find the rules which will be applied. + +### Why use nesting? + +There is no requirement to use nested CSS, but it can help to group related rule sets together (which makes it easier to edit). Nested CSS can also help you avoid some repetition in your selectors, i.e. in the nested CSS we only need to type `#questions` once, rather than four times in the non-nested CSS. diff --git a/docs/guide/actions.md b/docs/guide/actions.md index a59440a359..ca5ea8b824 100644 --- a/docs/guide/actions.md +++ b/docs/guide/actions.md @@ -10,13 +10,13 @@ Action methods are methods on your app or widgets prefixed with `action_`. Aside Action methods may be coroutines (defined with the `async` keyword). -Let's write an app with a simple action. +Let's write an app with a simple action method. ```python title="actions01.py" hl_lines="6-7 11" --8<-- "docs/examples/guide/actions/actions01.py" ``` -The `action_set_background` method is an action which sets the background of the screen. The key handler above will call this action if you press the ++r++ key. +The `action_set_background` method is an action method which sets the background of the screen. The key handler above will call this action method if you press the ++r++ key. Although it is possible (and occasionally useful) to call action methods in this way, they are intended to be parsed from an _action string_. For instance, the string `"set_background('red')"` is an action string which would call `self.action_set_background('red')`. @@ -40,9 +40,9 @@ Action strings have a simple syntax, which for the most part replicates Python's Action strings have the following format: -- The name of an action on is own will call the action method with no parameters. For example, an action string of `"bell"` will call `action_bell()`. -- Actions may be followed by braces containing Python objects. For example, the action string `set_background("red")` will call `action_set_background("red")`. -- Actions may be prefixed with a _namespace_ (see below) followed by a dot. +- The name of an action on its own will call the action method with no parameters. For example, an action string of `"bell"` will call `action_bell()`. +- Action strings may be followed by parenthesis containing Python objects. For example, the action string `set_background("red")` will call `action_set_background("red")`. +- Action strings may be prefixed with a _namespace_ ([see below](#namespaces)) and a dot.
--8<-- "docs/images/actions/format.excalidraw.svg" @@ -50,13 +50,13 @@ Action strings have the following format: ### Parameters -If the action string contains parameters, these must be valid Python literals. Which means you can include numbers, strings, dicts, lists etc. but you can't include variables or references to any other Python symbols. +If the action string contains parameters, these must be valid Python literals, which means you can include numbers, strings, dicts, lists, etc., but you can't include variables or references to any other Python symbols. Consequently `"set_background('blue')"` is a valid action string, but `"set_background(new_color)"` is not — because `new_color` is a variable and not a literal. ## Links -Actions may be embedded as links within console markup. You can create such links with a `@click` tag. +Actions may be embedded as links within console markup. You can create such links with a `@click` tag. The following example mounts simple static text with embedded action links. @@ -106,11 +106,11 @@ The following example defines a custom widget with its own `set_background` acti === "actions05.tcss" - ```sass title="actions05.tcss" + ```css title="actions05.tcss" --8<-- "docs/examples/guide/actions/actions05.tcss" ``` -There are two instances of the custom widget mounted. If you click the links in either of them it will changed the background for that widget only. The ++r++, ++g++, and ++b++ key bindings are set on the App so will set the background for the screen. +There are two instances of the custom widget mounted. If you click the links in either of them it will change the background for that widget only. The ++r++, ++g++, and ++b++ key bindings are set on the App so will set the background for the screen. You can optionally prefix an action with a _namespace_, which tells Textual to run actions for a different object. diff --git a/docs/guide/app.md b/docs/guide/app.md index 648847ec28..c78b8b52a7 100644 --- a/docs/guide/app.md +++ b/docs/guide/app.md @@ -262,7 +262,7 @@ The following example enables loading of CSS by adding a `CSS_PATH` class variab If the path is relative (as it is above) then it is taken as relative to where the app is defined. Hence this example references `"question01.tcss"` in the same directory as the Python code. Here is that CSS file: -```sass title="question02.tcss" +```css title="question02.tcss" --8<-- "docs/examples/app/question02.tcss" ``` diff --git a/docs/guide/command_palette.md b/docs/guide/command_palette.md index 126ebd6046..0dd15af0f1 100644 --- a/docs/guide/command_palette.md +++ b/docs/guide/command_palette.md @@ -57,7 +57,7 @@ The following example will display a blank screen initially, but if you bring up If you are running that example from the repository, you may want to add some additional Python files to see how the examples works with multiple files. - ```python title="command01.py" hl_lines="11-39 45" + ```python title="command01.py" hl_lines="14-42 45" --8<-- "docs/examples/guide/command_palette/command01.py" ``` diff --git a/docs/guide/design.md b/docs/guide/design.md index 46b8d4d04d..36723969f9 100644 --- a/docs/guide/design.md +++ b/docs/guide/design.md @@ -16,7 +16,7 @@ Textual pre-defines a number of colors as [CSS variables](../guide/CSS.md#css-va Here's an example of CSS that uses color variables: -```sass +```css MyWidget { background: $primary; color: $text; @@ -93,10 +93,13 @@ textual colors Here's a list of the colors defined in the default light and dark themes. +!!! note + + `$boost` will look different on different backgrounds because of its alpha channel. + ```{.rich title="Textual Theme Colors"} from rich import print from textual.app import DEFAULT_COLORS from textual.design import show_design output = show_design(DEFAULT_COLORS["light"], DEFAULT_COLORS["dark"]) ``` - diff --git a/docs/guide/events.md b/docs/guide/events.md index bd89515f37..d778473e35 100644 --- a/docs/guide/events.md +++ b/docs/guide/events.md @@ -240,8 +240,8 @@ The following example uses the decorator approach to write individual message ha While there are a few more lines of code, it is clearer what will happen when you click any given button. -Note that the decorator requires that the message class has a `control` attribute which should be the widget associated with the message. -Messages from builtin controls will have this attribute, but you may need to add `control` to any [custom messages](#custom-messages) you write. +Note that the decorator requires that the message class has a `control` property which should return the widget associated with the message. +Messages from builtin controls will have this attribute, but you may need to add a `control` property to any [custom messages](#custom-messages) you write. !!! note diff --git a/docs/guide/layout.md b/docs/guide/layout.md index 3ef77ced7e..8c80a14b00 100644 --- a/docs/guide/layout.md +++ b/docs/guide/layout.md @@ -27,7 +27,7 @@ The example below demonstrates how children are arranged inside a container with === "vertical_layout.tcss" - ```sass hl_lines="2" + ```css hl_lines="2" --8<-- "docs/examples/guide/layout/vertical_layout.tcss" ``` @@ -92,7 +92,7 @@ The example below shows how we can arrange widgets horizontally, with minimal ch === "horizontal_layout.tcss" - ```sass hl_lines="2" + ```css hl_lines="2" --8<-- "docs/examples/guide/layout/horizontal_layout.tcss" ``` @@ -125,7 +125,7 @@ To enable horizontal scrolling, we can use the `overflow-x: auto;` declaration: === "horizontal_layout_overflow.tcss" - ```sass hl_lines="3" + ```css hl_lines="3" --8<-- "docs/examples/guide/layout/horizontal_layout_overflow.tcss" ``` @@ -154,7 +154,7 @@ In other words, we have a single row containing two columns. === "utility_containers.tcss" - ```sass hl_lines="2" + ```css hl_lines="2" --8<-- "docs/examples/guide/layout/utility_containers.tcss" ``` @@ -193,7 +193,7 @@ Let's update the [utility containers](#utility-containers) example to use the co === "utility_containers.tcss" - ```sass + ```css --8<-- "docs/examples/guide/layout/utility_containers.tcss" ``` @@ -235,7 +235,7 @@ The following example creates a 3 x 2 grid and adds six widgets to it === "grid_layout1.tcss" - ```sass hl_lines="2 3" + ```css hl_lines="2 3" --8<-- "docs/examples/guide/layout/grid_layout1.tcss" ``` @@ -256,7 +256,7 @@ If we were to yield a seventh widget from our `compose` method, it would not be === "grid_layout2.tcss" - ```sass hl_lines="3" + ```css hl_lines="3" --8<-- "docs/examples/guide/layout/grid_layout2.tcss" ``` @@ -288,7 +288,7 @@ We'll make the first column take up half of the screen width, with the other two === "grid_layout3_row_col_adjust.tcss" - ```sass hl_lines="4" + ```css hl_lines="4" --8<-- "docs/examples/guide/layout/grid_layout3_row_col_adjust.tcss" ``` @@ -317,7 +317,7 @@ and the second row to `75%` height (while retaining the `grid-columns` change fr === "grid_layout4_row_col_adjust.tcss" - ```sass hl_lines="5" + ```css hl_lines="5" --8<-- "docs/examples/guide/layout/grid_layout4_row_col_adjust.tcss" ``` @@ -345,7 +345,7 @@ Let's modify the previous example to make the first column an `auto` column. === "grid_layout_auto.tcss" - ```sass hl_lines="4" + ```css hl_lines="4" --8<-- "docs/examples/guide/layout/grid_layout_auto.tcss" ``` @@ -377,7 +377,7 @@ We'll also add a slight tint using `tint: magenta 40%;` to draw attention to it. === "grid_layout5_col_span.tcss" - ```sass hl_lines="6-9" + ```css hl_lines="6-9" --8<-- "docs/examples/guide/layout/grid_layout5_col_span.tcss" ``` @@ -410,7 +410,7 @@ We again target widget `#two` in our CSS, and add a `row-span: 2;` declaration t === "grid_layout6_row_span.tcss" - ```sass hl_lines="8" + ```css hl_lines="8" --8<-- "docs/examples/guide/layout/grid_layout6_row_span.tcss" ``` @@ -442,7 +442,7 @@ Now if we add `grid-gutter: 1;` to our grid, one cell of spacing appears between === "grid_layout7_gutter.tcss" - ```sass hl_lines="4" + ```css hl_lines="4" --8<-- "docs/examples/guide/layout/grid_layout7_gutter.tcss" ``` @@ -482,7 +482,7 @@ The code below shows a simple sidebar implementation. === "dock_layout1_sidebar.tcss" - ```sass hl_lines="2" + ```css hl_lines="2" --8<-- "docs/examples/guide/layout/dock_layout1_sidebar.tcss" ``` @@ -506,7 +506,7 @@ This new sidebar is double the width of the one previous one, and has a `deeppin === "dock_layout2_sidebar.tcss" - ```sass hl_lines="1-6" + ```css hl_lines="1-6" --8<-- "docs/examples/guide/layout/dock_layout2_sidebar.tcss" ``` @@ -530,7 +530,7 @@ We can yield it inside `compose`, and without any additional CSS, we get a heade === "dock_layout3_sidebar_header.tcss" - ```sass + ```css --8<-- "docs/examples/guide/layout/dock_layout3_sidebar_header.tcss" ``` @@ -573,7 +573,7 @@ However, in this case, both `#box1` and `#box2` are assigned to layers which def === "layers.tcss" - ```sass hl_lines="3 14 19" + ```css hl_lines="3 14 19" --8<-- "docs/examples/guide/layout/layers.tcss" ``` @@ -614,7 +614,7 @@ The example below shows how an advanced layout can be built by combining the var === "combining_layouts.tcss" - ```sass + ```css --8<-- "docs/examples/guide/layout/combining_layouts.tcss" ``` diff --git a/docs/guide/queries.md b/docs/guide/queries.md index ff58ca2774..0b1e5fc105 100644 --- a/docs/guide/queries.md +++ b/docs/guide/queries.md @@ -2,7 +2,7 @@ In the previous chapter we introduced the [DOM](../guide/CSS.md#the-dom) which is how Textual apps keep track of widgets. We saw how you can apply styles to the DOM with CSS [selectors](./CSS.md#selectors). -Selectors are a very useful idea and can do more that apply styles. We can also find widgets in Python code with selectors, and make updates to widgets in a simple expressive way. Let's look at how! +Selectors are a very useful idea and can do more than apply styles. We can also find widgets in Python code with selectors, and make updates to widgets in a simple expressive way. Let's look at how! ## Query one @@ -116,7 +116,7 @@ If the last widget is *not* a button, Textual will raise a [WrongType][textual.c ## Filter -Query objects have a [filter][textual.css.query.DOMQuery.filter] method which further refines a query. This method will return a new query object with widgets that match both the original query _and_ the new selector +Query objects have a [filter][textual.css.query.DOMQuery.filter] method which further refines a query. This method will return a new query object with widgets that match both the original query _and_ the new selector. Let's say we have a query which gets all the buttons in an app, and we want a new query object with just the disabled buttons. We could write something like this: diff --git a/docs/guide/reactivity.md b/docs/guide/reactivity.md index 4a7c2cd41b..c84050ad40 100644 --- a/docs/guide/reactivity.md +++ b/docs/guide/reactivity.md @@ -81,7 +81,7 @@ Let's look at an example which illustrates this. In the following app, the value === "refresh01.tcss" - ```sass + ```css --8<-- "docs/examples/guide/reactivity/refresh01.tcss" ``` @@ -125,7 +125,7 @@ The following example modifies "refresh01.py" so that the greeting has an automa === "refresh02.tcss" - ```sass hl_lines="7-9" + ```css hl_lines="7-9" --8<-- "docs/examples/guide/reactivity/refresh02.tcss" ``` @@ -152,7 +152,7 @@ A common use for this is to restrict numbers to a given range. The following exa === "validate01.tcss" - ```sass + ```css --8<-- "docs/examples/guide/reactivity/validate01.tcss" ``` @@ -185,7 +185,7 @@ The following app will display any color you type in to the input. Try it with a === "watch01.tcss" - ```sass + ```css --8<-- "docs/examples/guide/reactivity/watch01.tcss" ``` @@ -202,6 +202,31 @@ Textual only calls watch methods if the value of a reactive attribute _changes_. If the newly assigned value is the same as the previous value, the watch method is not called. You can override this behaviour by passing `always_update=True` to `reactive`. + +### Dynamically watching reactive attributes + +You can programmatically add watchers to reactive attributes with the method [`watch`][textual.dom.DOMNode.watch]. +This is useful when you want to react to changes to reactive attributes for which you can't edit the watch methods. + +The example below shows a widget `Counter` that defines a reactive attribute `counter`. +The app that uses `Counter` uses the method `watch` to keep its progress bar synced with the reactive attribute: + +=== "dynamic_watch.py" + + ```python hl_lines="9 28-29 31" + --8<-- "docs/examples/guide/reactivity/dynamic_watch.py" + ``` + + 1. `counter` is a reactive attribute defined inside `Counter`. + 2. `update_progress` is a custom callback that will update the progress bar when `counter` changes. + 3. We use the method `watch` to set `update_progress` as an additional watcher for the reactive attribute `counter`. + +=== "Output" + + ```{.textual path="docs/examples/guide/reactivity/dynamic_watch.py" press="enter,enter,enter"} + ``` + + ## Compute methods Compute methods are the final superpower offered by the `reactive` descriptor. Textual runs compute methods to calculate the value of a reactive attribute. Compute methods begin with `compute_` followed by the name of the reactive value. @@ -221,7 +246,7 @@ The following example uses a computed attribute. It displays three inputs for ea === "computed01.tcss" - ```sass + ```css --8<-- "docs/examples/guide/reactivity/computed01.tcss" ``` diff --git a/docs/guide/screens.md b/docs/guide/screens.md index b9aefdc175..4f57412fd0 100644 --- a/docs/guide/screens.md +++ b/docs/guide/screens.md @@ -26,7 +26,7 @@ Let's look at a simple example of writing a screen class to simulate Window's [b === "screen01.tcss" - ```sass title="screen01.tcss" + ```css title="screen01.tcss" --8<-- "docs/examples/guide/screens/screen01.tcss" ``` @@ -55,7 +55,7 @@ You can also _install_ new named screens dynamically with the [install_screen][t === "screen02.tcss" - ```sass title="screen02.tcss" + ```css title="screen02.tcss" --8<-- "docs/examples/guide/screens/screen02.tcss" ``` @@ -171,7 +171,7 @@ From the quit screen you can click either Quit to exit the app immediately, or C === "modal01.tcss" - ```sass title="modal01.tcss" + ```css title="modal01.tcss" --8<-- "docs/examples/guide/screens/modal01.tcss" ``` @@ -213,7 +213,7 @@ Let's see what happens when we use `ModalScreen`. === "modal01.tcss" - ```sass title="modal01.tcss" + ```css title="modal01.tcss" --8<-- "docs/examples/guide/screens/modal01.tcss" ``` @@ -240,7 +240,7 @@ Let's modify the previous example to use `dismiss` rather than an explicit `pop_ === "modal01.tcss" - ```sass title="modal01.tcss" + ```css title="modal01.tcss" --8<-- "docs/examples/guide/screens/modal01.tcss" ``` @@ -258,6 +258,45 @@ You may have noticed in the previous example that we changed the base class to ` The addition of `[bool]` adds typing information that tells the type checker to expect a boolean in the call to `dismiss`, and that any callback set in `push_screen` should also expect the same type. As always, typing is optional in Textual, but this may help you catch bugs. +### Waiting for screens + +It is also possible to wait on a screen to be dismissed, which can feel like a more natural way of expressing logic than a callback. +The [`push_screen_wait()`][textual.app.App.push_screen_wait] method will push a screen and wait for its result (the value from [`Screen.dismiss()`][textual.screen.Screen.dismiss]). + +This can only be done from a [worker](./workers.md), so that waiting for the screen doesn't prevent your app from updating. + +Let's look at an example that uses `push_screen_wait` to ask a question and waits for the user to reply by clicking a button. + + +=== "questions01.py" + + ```python title="questions01.py" hl_lines="35-37" + --8<-- "docs/examples/guide/screens/questions01.py" + ``` + + 1. Dismiss with `True` when pressing the Yes button. + 2. Dismiss with `False` when pressing the No button. + 3. The `work` decorator will make this method run in a worker (background task). + 4. Will return a result when the user clicks one of the buttons. + + +=== "questions01.tcss" + + ```css title="questions01.tcss" + --8<-- "docs/examples/guide/screens/questions01.tcss" + ``` + +=== "Output" + + ```{.textual path="docs/examples/guide/screens/questions01.py"} + ``` + +The mount handler on the app is decorated with `@work`, which makes the code run in a worker (background task). +In the mount handler we push the screen with the `push_screen_wait`. +When the user presses one of the buttons, the screen calls [`dismiss()`][textual.screen.Screen.dismiss] with either `True` or `False`. +This value is then returned from the `push_screen_wait` method in the mount handler. + + ## Modes Some apps may benefit from having multiple screen stacks, rather than just one. diff --git a/docs/guide/styles.md b/docs/guide/styles.md index 22ac00d0b8..33566708af 100644 --- a/docs/guide/styles.md +++ b/docs/guide/styles.md @@ -302,7 +302,7 @@ When you set padding or border it reduces the size of the widget's content area. This is generally desirable when you arrange things on screen as you can add border or padding without breaking your layout. Occasionally though you may want to keep the size of the content area constant and grow the size of the widget to fit padding and border. The [box-sizing](../styles/box_sizing.md) style allows you to switch between these two modes. -If you set `box_sizing` to `"content-box"` then space required for padding and border will be added to the widget dimensions. The default value of `box_sizing` is `"border-box"`. Compare the box model diagram for `content-box` to the to the box model for `border-box`. +If you set `box_sizing` to `"content-box"` then the space required for padding and border will be added to the widget dimensions. The default value of `box_sizing` is `"border-box"`. Compare the box model diagram for `content-box` to the box model for `border-box`. === "content-box" diff --git a/docs/guide/testing.md b/docs/guide/testing.md new file mode 100644 index 0000000000..f22645b604 --- /dev/null +++ b/docs/guide/testing.md @@ -0,0 +1,305 @@ +# Testing + +Code testing is an important part of software development. +This chapter will cover how to write tests for your Textual apps. + +## What is testing? + +It is common to write tests alongside your app. +A *test* is simply a function that confirms your app is working correctly. + +!!! tip "Learn more about testing" + + We recommend [Python Testing with pytest](https://pythontest.com/pytest-book/) for a comprehensive guide to writing tests. + +## Do you need to write tests? + +The short answer is "no", you don't *need* to write tests. + +In practice however, it is almost always a good idea to write tests. +Writing code that is completely bug free is virtually impossible, even for experienced developers. +If you want to have confidence that your application will run as you intended it to, then you should write tests. +Your test code will help you find bugs early, and alert you if you accidentally break something in the future. + +## Testing frameworks for Textual + +Textual doesn't require any particular test framework. +You can use any test framework you are familiar with, but we will be using [pytest](https://docs.pytest.org/) in this chapter. + + +## Testing apps + +You can often test Textual code in the same way as any other app, and use similar techniques. +But when testing user interface interactions, you may need to use Textual's dedicated test features. + +Let's write a simple Textual app so we can demonstrate how to test it. +The following app shows three buttons labelled "red", "green", and "blue". +Clicking one of those buttons or pressing a corresponding ++r++, ++g++, and ++b++ key will change the background color. + +=== "rgb.py" + + ```python + --8<-- "docs/examples/guide/testing/rgb.py" + ``` + +=== "Output" + + ```{.textual path="docs/examples/guide/testing/rgb.py"} + ``` + +Although it is straightforward to test an app like this manually, it is not practical to click every button and hit every key in your app after changing a single line of code. +Tests allow us to automate such testing so we can quickly simulate user interactions and check the result. + +To test our simple app we will use the [`run_test()`][textual.app.App.run_test] method on the `App` class. +This replaces the usual call to [`run()`][textual.app.App.run] and will run the app in *headless* mode, which prevents Textual from updating the terminal but otherwise behaves as normal. + +The `run_test()` method is an *async context manager* which returns a [`Pilot`][textual.pilot.Pilot] object. +You can use this object to interact with the app as if you were operating it with a keyboard and mouse. + +Let's look at the tests for the example above: + +```python title="test_rgb.py" +--8<-- "docs/examples/guide/testing/test_rgb.py" +``` + +1. The `run_test()` method requires that it run in a coroutine, so tests must use the `async` keyword. +2. This runs the app and returns a Pilot instance we can use to interact with it. +3. Simulates pressing the ++r++ key. +4. This checks that pressing the ++r++ key has resulted in the background color changing. +5. Simulates clicking on the widget with an `id` of `red` (the button labelled "Red"). + +There are two tests defined in `test_rgb.py`. +The first to test keys and the second to test button clicks. +Both tests first construct an instance of the app and then call `run_test()` to get a Pilot object. +The `test_keys` function simulates key presses with [`Pilot.press`][textual.pilot.Pilot.press], and `test_buttons` simulates button clicks with [`Pilot.click`][textual.pilot.Pilot.click]. + +After simulating a user interaction, Textual tests will typically check the state has been updated with an `assert` statement. +The `pytest` module will record any failures of these assert statements as a test fail. + +If you run the tests with `pytest test_rgb.py` you should get 2 passes, which will confirm that the user will be able to click buttons or press the keys to change the background color. + +If you later update this app, and accidentally break this functionality, one or more of your tests will fail. +Knowing which test has failed will help you quickly track down where your code was broken. + +## Simulating key presses + +We've seen how the [`press`][textual.pilot.Pilot] method simulates keys. +You can also supply multiple keys to simulate the user typing in to the app. +Here's an example of simulating the user typing the word "hello". + +```python +await pilot.press("h", "e", "l", "l", "o") +``` + +Each string creates a single keypress. +You can also use the name for non-printable keys (such as "enter") and the "ctrl+" modifier. +These are the same identifiers as used for key events, which you can experiment with by running `textual keys`. + +## Simulating clicks + +You can simulate mouse clicks in a similar way with [`Pilot.click`][textual.pilot.Pilot.click]. +If you supply a CSS selector Textual will simulate clicking on the matching widget. + +!!! note + + If there is another widget in front of the widget you want to click, you may end up clicking the topmost widget rather than the widget indicated in the selector. + This is generally what you want, because a real user would experience the same thing. + +### Clicking the screen + +If you don't supply a CSS selector, then the click will be relative to the screen. +For example, the following simulates a click at (0, 0): + +```python +await pilot.click() +``` + +### Click offsets + +If you supply an `offset` value, it will be added to the coordinates of the simulated click. +For example the following line would simulate a click at the coordinates (10, 5). + + +```python +await pilot.click(offset=(10, 5)) +``` + +If you combine this with a selector, then the offset will be relative to the widget. +Here's how you would click the line *above* a button. + +```python +await pilot.click(Button, offset=(0, -1)) +``` + +### Modifier keys + +You can simulate clicks in combination with modifier keys, by setting the `shift`, `meta`, or `control` parameters. +Here's how you could simulate ctrl-clicking a widget with an ID of "slider": + +```python +await pilot.click("#slider", control=True) +``` + +## Changing the screen size + +The default size of a simulated app is (80, 24). +You may want to test what happens when the app has a different size. +To do this, set the `size` parameter of [`run_test`][textual.app.App.run_test] to a different size. +For example, here is how you would simulate a terminal resized to 100 columns and 50 lines: + +```python +async with app.run_test(size=(100, 50)) as pilot: + ... +``` + +## Pausing the pilot + +Some actions in a Textual app won't change the state immediately. +For instance, messages may take a moment to bubble from the widget that sent them. +If you were to post a message and immediately `assert` you may find that it fails because the message hasn't yet been processed. + +You can generally solve this by calling [`pause()`][textual.pilot.Pilot.pause] which will wait for all pending messages to be processed. +You can also supply a `delay` parameter, which will insert a delay prior to waiting for pending messages. + + +## Textual's tests + +Textual itself has a large battery of tests. +If you are interested in how we write tests, see the [tests/](https://github.com/Textualize/textual/tree/main/tests) directory in the Textual repository. + +## Snapshot testing + +Snapshot testing is the process of recording the output of a test, and comparing it against the output from previous runs. + +Textual uses snapshot testing internally to ensure that the builtin widgets look and function correctly in every release. +We've made the pytest plugin we built available for public use. + +The [official Textual pytest plugin](https://github.com/Textualize/pytest-textual-snapshot) can help you catch otherwise difficult to detect visual changes in your app. + +It works by generating an SVG _screenshot_ (such as the images in these docs) from your app. +If the screenshot changes in any test run, you will have the opportunity to visually compare the new output against previous runs. + + +### Installing the plugin + +You can install `pytest-textual-snapshot` using your favorite package manager (`pip`, `poetry`, etc.). + +``` +pip install pytest-textual-snapshot +``` + +### Creating a snapshot test + +With the package installed, you now have access to the `snap_compare` pytest fixture. + +Let's look at an example of how we'd create a snapshot test for the [calculator app](https://github.com/Textualize/textual/blob/main/examples/calculator.py) below. + +```{.textual path="examples/calculator.py" columns=100 lines=41 press="3,.,1,4,5,9,2,wait:400"} +``` + +First, we need to create a new test and specify the path to the Python file containing the app. +This path should be relative to the location of the test. + +```python +def test_calculator(snap_compare): + assert snap_compare("path/to/calculator.py") +``` + +Let's run the test as normal using `pytest`. + +``` +pytest +``` + +When this test runs for the first time, an SVG screenshot of the calculator app is generated, and the test will fail. +Snapshot tests always fail on the first run, since there's no previous version to compare the snapshot to. + +![snapshot_report_console_output.png](../images/testing/snapshot_report_console_output.png) + +If you open the snapshot report in your browser, you'll see something like this: + +![snapshot_report_example.png](../images/testing/snapshot_report_example.png) + +!!! tip + + You can usually open the link directly from the terminal, but some terminal emulators may + require you to hold ++ctrl++ or ++command++ while clicking for links to work. + +The report explains that there's "No history for this test". +It's our job to validate that the initial snapshot looks correct before proceeding. +Our calculator is rendering as we expect, so we'll save this snapshot: + +``` +pytest --snapshot-update +``` + +!!! warning + + Only ever run pytest with `--snapshot-update` if you're happy with how the output looks + on the left hand side of the snapshot report. When using `--snapshot-update`, you're saying "I'm happy with all of the + screenshots in the snapshot test report, and they will now represent the ground truth which all future runs will be compared + against". As such, you should only run `pytest --snapshot-update` _after_ running `pytest` and confirming the output looks good. + +Now that our snapshot is saved, if we run `pytest` (with no arguments) again, the test will pass. +This is because the screenshot taken during this test run matches the one we saved earlier. + +### Catching a bug + +The real power of snapshot testing comes from its ability to catch visual regressions which could otherwise easily be missed. + +Imagine a new developer joins your team, and tries to make a few changes to the calculator. +While making this change they accidentally break some styling which removes the orange coloring from the buttons on the right of the app. +When they run `pytest`, they're presented with a report which reveals the damage: + +![snapshot_report_diff_before.png](../images/testing/snapshot_report_diff_before.png) + +On the right, we can see our "historical" snapshot - this is the one we saved earlier. +On the left is how our app is currently rendering - clearly not how we intended! + +We can click the "Show difference" toggle at the top right of the diff to overlay the two versions: + +![snapshot_report_diff_after.png](../images/testing/snapshot_report_diff_after.png) + +This reveals another problem, which could easily be missed in a quick visual inspection - +our new developer has also deleted the number 4! + +!!! tip + + Snapshot tests work well in CI on all supported operating systems, and the snapshot + report is just an HTML file which can be exported as a build artifact. + + +### Pressing keys + +You can simulate pressing keys before the snapshot is captured using the `press` parameter. + +```python +def test_calculator_pressing_numbers(snap_compare): + assert snap_compare("path/to/calculator.py", press=["1", "2", "3"]) +``` + +### Changing the terminal size + +To capture the snapshot with a different terminal size, pass a tuple `(width, height)` as the `terminal_size` parameter. + +```python +def test_calculator(snap_compare): + assert snap_compare("path/to/calculator.py", terminal_size=(50, 100)) +``` + +### Running setup code + +You can also run arbitrary code before the snapshot is captured using the `run_before` parameter. + +In this example, we use `run_before` to hover the mouse cursor over the widget with ID `number-5` +before taking the snapshot. + +```python +def test_calculator_hover_number(snap_compare): + async def run_before(pilot) -> None: + await pilot.hover("#number-5") + + assert snap_compare("path/to/calculator.py", run_before=run_before) +``` + +For more information, visit the [`pytest-textual-snapshot` repo on GitHub](https://github.com/Textualize/pytest-textual-snapshot). diff --git a/docs/guide/widgets.md b/docs/guide/widgets.md index 26d382169a..d703761caf 100644 --- a/docs/guide/widgets.md +++ b/docs/guide/widgets.md @@ -24,7 +24,7 @@ Let's create a simple custom widget to display a greeting. --8<-- "docs/examples/guide/widgets/hello01.py" ``` -The three highlighted lines define a custom widget class with just a [render()][textual.widget.Widget.render] method. Textual will display whatever is returned from render in the content area of your widget. We have returned a string in the code above, but there are other possible return types which we will cover later. +The highlighted lines define a custom widget class with just a [render()][textual.widget.Widget.render] method. Textual will display whatever is returned from render in the content area of your widget. We have returned a string in the code above, but there are other possible return types which we will cover later. Note that the text contains tags in square brackets, i.e. `[b]`. This is [console markup](https://rich.readthedocs.io/en/latest/markup.html) which allows you to embed various styles within your content. If you run this you will find that `World` is in bold. @@ -42,7 +42,7 @@ This (very simple) custom widget may be [styled](./styles.md) in the same way as === "hello02.tcss" - ```sass title="hello02.tcss" + ```css title="hello02.tcss" --8<-- "docs/examples/guide/widgets/hello02.tcss" ``` @@ -59,13 +59,13 @@ Let's use Static to create a widget which cycles through "hello" in various lang === "hello03.py" - ```python title="hello03.py" hl_lines="24-36" + ```python title="hello03.py" hl_lines="23-35" --8<-- "docs/examples/guide/widgets/hello03.py" ``` === "hello03.tcss" - ```sass title="hello03.tcss" + ```css title="hello03.tcss" --8<-- "docs/examples/guide/widgets/hello03.tcss" ``` @@ -88,13 +88,13 @@ Here's the Hello example again, this time the widget has embedded default CSS: === "hello04.py" - ```python title="hello04.py" hl_lines="27-36" + ```python title="hello04.py" hl_lines="26-35" --8<-- "docs/examples/guide/widgets/hello04.py" ``` === "hello04.tcss" - ```sass title="hello04.tcss" + ```css title="hello04.tcss" --8<-- "docs/examples/guide/widgets/hello04.tcss" ``` @@ -103,6 +103,13 @@ Here's the Hello example again, this time the widget has embedded default CSS: ```{.textual path="docs/examples/guide/widgets/hello04.py"} ``` +#### Scoped CSS + +Default CSS is *scoped* by default. +All this means is that CSS defined in `DEFAULT_CSS` will affect the widget and potentially its children only. +This is to prevent you from inadvertently breaking an unrelated widget. + +You can disabled scoped CSS by setting the class var `SCOPED_CSS` to `False`. #### Default specificity @@ -124,13 +131,13 @@ Let's use markup links in the hello example so that the greeting becomes a link === "hello05.py" - ```python title="hello05.py" hl_lines="24-33" + ```python title="hello05.py" hl_lines="23-32" --8<-- "docs/examples/guide/widgets/hello05.py" ``` === "hello05.tcss" - ```sass title="hello05.tcss" + ```css title="hello05.tcss" --8<-- "docs/examples/guide/widgets/hello05.tcss" ``` @@ -168,7 +175,7 @@ Let's demonstrate setting a title, both as a class variable and a instance varia === "hello06.tcss" - ```sass title="hello06.tcss" + ```css title="hello06.tcss" --8<-- "docs/examples/guide/widgets/hello06.tcss" ``` @@ -199,7 +206,7 @@ This app will "play" fizz buzz by displaying a table of the first 15 numbers and === "fizzbuzz01.tcss" - ```sass title="fizzbuzz01.tcss" hl_lines="32-35" + ```css title="fizzbuzz01.tcss" hl_lines="32-35" --8<-- "docs/examples/guide/widgets/fizzbuzz01.tcss" ``` @@ -223,7 +230,7 @@ Let's modify the default width for the fizzbuzz example. By default, the table w === "fizzbuzz02.tcss" - ```sass title="fizzbuzz02.tcss" + ```css title="fizzbuzz02.tcss" --8<-- "docs/examples/guide/widgets/fizzbuzz02.tcss" ``` @@ -287,6 +294,37 @@ Add a rule to your CSS that targets `Tooltip`. Here's an example: ```{.textual path="docs/examples/guide/widgets/tooltip02.py" hover="Button"} ``` +## Loading indicator + +Widgets have a [`loading`][textual.widget.Widget.loading] reactive which when set to `True` will temporarily replace your widget with a [`LoadingIndicator`](../widgets/loading_indicator.md). + +You can use this to indicate to the user that the app is currently working on getting data, and there will be content when that data is available. +Let's look at an example of this. + +=== "loading01.py" + + ```python title="loading01.py" + --8<-- "docs/examples/guide/widgets/loading01.py" + ``` + + 1. Shows the loading indicator in place of the data table. + 2. Insert a random sleep to simulate a network request. + 3. Show the new data. + +=== "Output" + + ```{.textual path="docs/examples/guide/widgets/loading01.py"} + ``` + + +In this example we have four [DataTable](../widgets/data_table.md) widgets, which we put into a loading state by setting the widget's `loading` property to `True`. +This will temporarily replace the widget with a loading indicator animation. +When the (simulated) data has been retrieved, we reset the `loading` property to show the new data. + +!!! tip + + See the guide on [Workers](./workers.md) if you want to know more about the `@work` decorator. + ## Line API A downside of widgets that return Rich renderables is that Textual will redraw the entire widget when its state is updated or it changes size. @@ -513,7 +551,7 @@ In this section we will show how to design and build a fully-working app, while ### Designing the app -We are going to build a *byte editor* which allows you to enter a number in both decimal and binary. You could use this a teaching aid for binary numbers. +We are going to build a *byte editor* which allows you to enter a number in both decimal and binary. You could use this as a teaching aid for binary numbers. Here's a sketch of what the app should ultimately look like: @@ -526,7 +564,11 @@ Here's a sketch of what the app should ultimately look like: --8<-- "docs/images/byte01.excalidraw.svg"
-There are three types of built-in widget in the sketch, namely ([Input](../widgets/input.md), [Label](../widgets/label.md), and [Switch](../widgets/switch.md)). Rather than manage these as a single collection of widgets, we can arrange them in to logical groups with compound widgets. This will make our app easier to work with. +There are three types of built-in widget in the sketch, namely ([Input](../widgets/input.md), [Label](../widgets/label.md), and [Switch](../widgets/switch.md)). Rather than manage these as a single collection of widgets, we can arrange them into logical groups with compound widgets. This will make our app easier to work with. + +??? textualize "Try in Textual-web" + +
### Identifying components @@ -563,7 +605,7 @@ Note the `compose()` methods of each of the widgets. - The `ByteInput` yields 8 `BitSwitch` widgets and arranges them horizontally. It also adds a `focus-within` style in its CSS to draw an accent border when any of the switches are focused. -- The `ByteEditor` yields a `ByteInput` and an `Input` control. The default CSS stacks the two controls on top of each other to divide the screen in to two parts. +- The `ByteEditor` yields a `ByteInput` and an `Input` control. The default CSS stacks the two controls on top of each other to divide the screen into two parts. With these three widgets, the [DOM](CSS.md#the-dom) for our app will look like this: diff --git a/docs/guide/workers.md b/docs/guide/workers.md index a8eff8432d..71a06c093e 100644 --- a/docs/guide/workers.md +++ b/docs/guide/workers.md @@ -28,7 +28,7 @@ The following app uses [httpx](https://www.python-httpx.org/) to get the current === "weather.tcss" - ```sass title="weather.tcss" + ```css title="weather.tcss" --8<-- "docs/examples/guide/workers/weather.tcss" ``` @@ -158,7 +158,7 @@ The second difference is that you can't cancel threads in the same way as corout Let's demonstrate thread workers by replacing `httpx` with `urllib.request` (in the standard library). The `urllib` module is not async aware, so we will need to use threads: -```python title="weather05.py" hl_lines="1 26-43" +```python title="weather05.py" hl_lines="1-2 27-44" --8<-- "docs/examples/guide/workers/weather05.py" ``` diff --git a/docs/how-to/design-a-layout.md b/docs/how-to/design-a-layout.md index b9f17c51c7..80c82c9d7e 100644 --- a/docs/how-to/design-a-layout.md +++ b/docs/how-to/design-a-layout.md @@ -27,6 +27,11 @@ Here's our sketch: It's rough, but it's all we need. +??? textualize "Try in Textual-web" + +
+ + ## Tip 2. Work outside in Like a sculpture with a block of marble, it is best to work from the outside towards the center. diff --git a/docs/how-to/render-and-compose.md b/docs/how-to/render-and-compose.md new file mode 100644 index 0000000000..de43f26e98 --- /dev/null +++ b/docs/how-to/render-and-compose.md @@ -0,0 +1,63 @@ +# Render and compose + +A common question that comes up on the [Textual Discord server](https://discord.gg/Enf6Z3qhVr) is what is the difference between [`render`][textual.widget.Widget.render] and [`compose`][textual.widget.Widget.compose] methods on a widget? +In this article we will clarify the differences, and use both these methods to build something fun. + +
+ +
+ +## Which method to use? + +Render and compose are easy to confuse because they both ultimately define what a widget will look like, but they have quite different uses. + +The `render` method on a widget returns a [Rich](https://rich.readthedocs.io/en/latest/) renderable, which is anything you could print with Rich. +The simplest renderable is just text; so `render()` methods often return a string, but could equally return a [`Text`](https://rich.readthedocs.io/en/latest/text.html) instance, a [`Table`](https://rich.readthedocs.io/en/latest/tables.html), or anything else from Rich (or third party library). +Whatever is returned from `render()` will be combined with any styles from CSS and displayed within the widget's borders. + +The `compose` method is used to build [*compound* widgets](../guide/widgets.md#compound-widgets) (widgets composed of other widgets). + +A general rule of thumb, is that if you implement a `compose` method, there is no need for a `render` method because it is the widgets yielded from `compose` which define how the custom widget will look. +However, you *can* mix these two methods. +If you implement both, the `render` method will set the custom widget's *background* and `compose` will add widgets on top of that background. + +## Combining render and compose + +Let's look at an example that combines both these methods. +We will create a custom widget with a [linear gradient][textual.renderables.gradient.LinearGradient] as a background. +The background will be animated (I did promise *fun*)! + +=== "render_compose.py" + + ```python + --8<-- "docs/examples/how-to/render_compose.py" + ``` + + 1. Refresh the widget 30 times a second. + 2. Compose our compound widget, which contains a single Static. + 3. Render a linear gradient in the background. + +=== "Output" + + ```{.textual path="docs/examples/how-to/render_compose.py" columns="100" lines="40"} + ``` + +The `Splash` custom widget has a `compose` method which adds a simple `Static` widget to display a message. +Additionally there is a `render` method which returns a renderable to fill the background with a gradient. + +!!! tip + + As fun as this is, spinning animated gradients may be too distracting for most apps! + +## Summary + +Keep the following in mind when building [custom widgets](../guide/widgets.md). + +1. Use `render` to return simple text, or a Rich renderable. +2. Use `compose` to create a widget out of other widgets. +3. If you define both, then `render` will be used as a *background*. + + +--- + +We are here to [help](../help.md)! diff --git a/docs/images/icons/logo light transparent.svg b/docs/images/icons/logo light transparent.svg new file mode 100644 index 0000000000..411625e4a7 --- /dev/null +++ b/docs/images/icons/logo light transparent.svg @@ -0,0 +1,12 @@ + + + + + + diff --git a/docs/images/testing/snapshot_report_console_output.png b/docs/images/testing/snapshot_report_console_output.png new file mode 100644 index 0000000000..50389b4102 Binary files /dev/null and b/docs/images/testing/snapshot_report_console_output.png differ diff --git a/docs/images/testing/snapshot_report_diff_after.png b/docs/images/testing/snapshot_report_diff_after.png new file mode 100644 index 0000000000..99334082dd Binary files /dev/null and b/docs/images/testing/snapshot_report_diff_after.png differ diff --git a/docs/images/testing/snapshot_report_diff_before.png b/docs/images/testing/snapshot_report_diff_before.png new file mode 100644 index 0000000000..575cafd44b Binary files /dev/null and b/docs/images/testing/snapshot_report_diff_before.png differ diff --git a/docs/images/testing/snapshot_report_example.png b/docs/images/testing/snapshot_report_example.png new file mode 100644 index 0000000000..5cb49828f2 Binary files /dev/null and b/docs/images/testing/snapshot_report_example.png differ diff --git a/docs/index.md b/docs/index.md index c8e48c5748..1c06781407 100644 --- a/docs/index.md +++ b/docs/index.md @@ -1,11 +1,19 @@ -# Introduction +--- +hide: + - toc + - navigation +--- -Welcome to the [Textual](https://github.com/Textualize/textual) framework documentation. +!!! tip inline end + + See the navigation links in the header or side-bar. -!!! tip + Click :octicons-three-bars-16: (top left) on mobile. - See the navigation links in the header or side-bars. Click the :octicons-three-bars-16: button (top left) on mobile. +# Welcome + +Welcome to the [Textual](https://github.com/Textualize/textual) framework documentation. [Get started](./getting_started.md){ .md-button .md-button--primary } or go straight to the [Tutorial](./tutorial.md) @@ -16,7 +24,7 @@ Welcome to the [Textual](https://github.com/Textualize/textual) framework docume Textual is a *Rapid Application Development* framework for Python, built by [Textualize.io](https://www.textualize.io). -Build sophisticated user interfaces with a simple Python API. Run your apps in the terminal and (*coming soon*) a web browser. +Build sophisticated user interfaces with a simple Python API. Run your apps in the terminal *or* a [web browser](https://github.com/Textualize/textual-web)! diff --git a/docs/roadmap.md b/docs/roadmap.md index 90e05d1e1d..d5a9f1a3b6 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -19,9 +19,8 @@ High-level features we plan on implementing. * [x] Monochrome mode * [ ] High contrast theme * [ ] Color-blind themes -- [ ] Command interface - * [ ] Command menu - * [ ] Fuzzy search +- [X] Command palette + * [X] Fuzzy search - [ ] Configuration (.toml based extensible configuration format) - [x] Console - [ ] Devtools @@ -75,8 +74,8 @@ Widgets are key to making user-friendly interfaces. The builtin widgets should c - [X] Spark-lines - [X] Switch - [X] Tabs -- [ ] TextArea (multi-line input) - * [ ] Basic controls +- [X] TextArea (multi-line input) + * [X] Basic controls * [ ] Indentation guides * [ ] Smart features for various languages - * [ ] Syntax highlighting + * [X] Syntax highlighting diff --git a/docs/snippets/border_sub_title_align_all_example.md b/docs/snippets/border_sub_title_align_all_example.md index 6550a92ca0..4d916ce982 100644 --- a/docs/snippets/border_sub_title_align_all_example.md +++ b/docs/snippets/border_sub_title_align_all_example.md @@ -8,7 +8,7 @@ Open the code tabs to see the details of the code examples. === "border_sub_title_align_all.py" - ```py hl_lines="6 18 24 30 39 40 42 45 51 57 63" + ```py hl_lines="6 20 26 32 41 42 44 47 53 59 65" --8<-- "docs/examples/styles/border_sub_title_align_all.py" ``` @@ -26,7 +26,7 @@ Open the code tabs to see the details of the code examples. === "border_sub_title_align_all.tcss" - ```sass hl_lines="12 16 30 34 41 46" + ```css hl_lines="12 16 30 34 41 46" --8<-- "docs/examples/styles/border_sub_title_align_all.tcss" ``` diff --git a/docs/snippets/border_title_color.md b/docs/snippets/border_title_color.md index 36b473c24a..edcc9c5c0d 100644 --- a/docs/snippets/border_title_color.md +++ b/docs/snippets/border_title_color.md @@ -13,6 +13,6 @@ The following examples demonstrates customization of the border color and text s === "border_title_colors.tcss" - ```sass + ```css --8<-- "docs/examples/styles/border_title_colors.tcss" ``` diff --git a/docs/snippets/border_vs_outline_example.md b/docs/snippets/border_vs_outline_example.md index 55d035f47d..239a4c79a0 100644 --- a/docs/snippets/border_vs_outline_example.md +++ b/docs/snippets/border_vs_outline_example.md @@ -16,6 +16,6 @@ This example also shows that a widget cannot contain both a `border` and an `out === "outline_vs_border.tcss" - ```sass hl_lines="5-7 9-11" + ```css hl_lines="5-7 9-11" --8<-- "docs/examples/styles/outline_vs_border.tcss" ``` diff --git a/docs/styles/_template.md b/docs/styles/_template.md index 5cddb8919c..b08423bb3b 100644 --- a/docs/styles/_template.md +++ b/docs/styles/_template.md @@ -46,7 +46,7 @@ Short description of the first example. === "style.tcss" - ```sass + ```css --8<-- "docs/examples/styles/style.tcss" ``` --> @@ -68,7 +68,7 @@ Short description of the second example. === "style.tcss" - ```sass + ```css --8<-- "docs/examples/styles/style.tcss" ``` @@ -84,7 +84,7 @@ Include comments when relevant. Include all variations. List all values, if possible and sensible. -```sass +```css rule-name: value1 rule-name: value2 rule-name: different-syntax-value shown-here diff --git a/docs/styles/align.md b/docs/styles/align.md index 810e26303a..72da641027 100644 --- a/docs/styles/align.md +++ b/docs/styles/align.md @@ -34,7 +34,7 @@ This example contains a simple app with two labels centered on the screen with ` === "align.tcss" - ```sass hl_lines="2" + ```css hl_lines="2" --8<-- "docs/examples/styles/align.tcss" ``` @@ -56,13 +56,13 @@ Each label has been aligned differently inside its container, and its text shows === "align_all.tcss" - ```sass hl_lines="2 6 10 14 18 22 26 30 34" + ```css hl_lines="2 6 10 14 18 22 26 30 34" --8<-- "docs/examples/styles/align_all.tcss" ``` ## CSS -```sass +```css /* Align child widgets to the center. */ align: center middle; /* Align child widget to the top right */ diff --git a/docs/styles/background.md b/docs/styles/background.md index 9a1c4f04f2..420d08eae8 100644 --- a/docs/styles/background.md +++ b/docs/styles/background.md @@ -29,7 +29,7 @@ This example creates three widgets and applies a different background to each. === "background.tcss" - ```sass hl_lines="9 13 17" + ```css hl_lines="9 13 17" --8<-- "docs/examples/styles/background.tcss" ``` @@ -50,13 +50,13 @@ The next example creates ten widgets laid out side by side to show the effect of === "background_transparency.tcss" - ```sass hl_lines="2 6 10 14 18 22 26 30 34 38" + ```css hl_lines="2 6 10 14 18 22 26 30 34 38" --8<-- "docs/examples/styles/background_transparency.tcss" ``` ## CSS -```sass +```css /* Blue background */ background: blue; diff --git a/docs/styles/border.md b/docs/styles/border.md index 71643fd4fe..eb3e4991df 100644 --- a/docs/styles/border.md +++ b/docs/styles/border.md @@ -53,7 +53,7 @@ This examples shows three widgets with different border styles. === "border.tcss" - ```sass hl_lines="4 10 16" + ```css hl_lines="4 10 16" --8<-- "docs/examples/styles/border.tcss" ``` @@ -68,13 +68,13 @@ The next example shows a grid with all the available border types. === "border_all.py" - ```py hl_lines="2 6 10 14 18 22 26 30 34 38 42 46 50 54 58" + ```py --8<-- "docs/examples/styles/border_all.py" ``` === "border_all.tcss" - ```sass + ```css --8<-- "docs/examples/styles/border_all.tcss" ``` @@ -84,7 +84,7 @@ The next example shows a grid with all the available border types. ## CSS -```sass +```css /* Set a heavy white border */ border: heavy white; diff --git a/docs/styles/border_subtitle_align.md b/docs/styles/border_subtitle_align.md index 7723062cdb..6484d277f6 100644 --- a/docs/styles/border_subtitle_align.md +++ b/docs/styles/border_subtitle_align.md @@ -35,7 +35,7 @@ This example shows three labels, each with a different border subtitle alignment === "border_subtitle_align.tcss" - ```sass + ```css --8<-- "docs/examples/styles/border_subtitle_align.tcss" ``` @@ -47,7 +47,7 @@ This example shows three labels, each with a different border subtitle alignment ## CSS -```sass +```css border-subtitle-align: left; border-subtitle-align: center; border-subtitle-align: right; diff --git a/docs/styles/border_subtitle_background.md b/docs/styles/border_subtitle_background.md index 8d60b3e150..21a407c67e 100644 --- a/docs/styles/border_subtitle_background.md +++ b/docs/styles/border_subtitle_background.md @@ -18,7 +18,7 @@ border-subtitle-background: (<color> | ## CSS -```sass +```css border-subtitle-background: blue; ``` diff --git a/docs/styles/border_subtitle_color.md b/docs/styles/border_subtitle_color.md index 3880111595..0629896ea3 100644 --- a/docs/styles/border_subtitle_color.md +++ b/docs/styles/border_subtitle_color.md @@ -17,7 +17,7 @@ border-subtitle-color: (<color> | auto ## CSS -```sass +```css border-subtitle-color: red; ``` diff --git a/docs/styles/border_subtitle_style.md b/docs/styles/border_subtitle_style.md index 3b826568f8..0c8c16afb2 100644 --- a/docs/styles/border_subtitle_style.md +++ b/docs/styles/border_subtitle_style.md @@ -17,7 +17,7 @@ border-subtitle-style: <text-style><color> | au ## CSS -```sass +```css border-title-background: blue; ``` diff --git a/docs/styles/border_title_color.md b/docs/styles/border_title_color.md index 2272de9c6f..5831295984 100644 --- a/docs/styles/border_title_color.md +++ b/docs/styles/border_title_color.md @@ -15,7 +15,7 @@ border-title-color: (<color> | auto) [ ## CSS -```sass +```css border-title-color: red; ``` diff --git a/docs/styles/border_title_style.md b/docs/styles/border_title_style.md index 09e8319cca..7d16fce9ba 100644 --- a/docs/styles/border_title_style.md +++ b/docs/styles/border_title_style.md @@ -18,7 +18,7 @@ border-title-style: <text-style>; ## CSS -```sass +```css border-title-style: bold underline; ``` diff --git a/docs/styles/box_sizing.md b/docs/styles/box_sizing.md index 147929f8ba..b2fe4bf52f 100644 --- a/docs/styles/box_sizing.md +++ b/docs/styles/box_sizing.md @@ -34,13 +34,13 @@ The bottom widget has `box-sizing: content-box` which increases the size of the === "box_sizing.tcss" - ```sass hl_lines="2 6" + ```css hl_lines="2 6" --8<-- "docs/examples/styles/box_sizing.tcss" ``` ## CSS -```sass +```css /* Set box sizing to border-box (default) */ box-sizing: border-box; diff --git a/docs/styles/color.md b/docs/styles/color.md index 49b55dbb00..3a1f18c8b7 100644 --- a/docs/styles/color.md +++ b/docs/styles/color.md @@ -31,7 +31,7 @@ This example sets a different text color for each of three different widgets. === "color.tcss" - ```sass hl_lines="8 12 16" + ```css hl_lines="8 12 16" --8<-- "docs/examples/styles/color.tcss" ``` @@ -52,13 +52,13 @@ The next example shows how `auto` chooses between a lighter or a darker text col === "color_auto.tcss" - ```sass hl_lines="2" + ```css hl_lines="2" --8<-- "docs/examples/styles/color_auto.tcss" ``` ## CSS -```sass +```css /* Blue text */ color: blue; diff --git a/docs/styles/content_align.md b/docs/styles/content_align.md index 63d0ba298f..88a10ea6f7 100644 --- a/docs/styles/content_align.md +++ b/docs/styles/content_align.md @@ -39,7 +39,7 @@ This first example shows three labels stacked vertically, each with different co === "content_align.tcss" - ```sass hl_lines="2 7-8 13" + ```css hl_lines="2 7-8 13" --8<-- "docs/examples/styles/content_align.tcss" ``` @@ -61,13 +61,13 @@ Each label has its text aligned differently. === "content_align_all.tcss" - ```sass hl_lines="2 5 8 11 14 17 20 23 26" + ```css hl_lines="2 5 8 11 14 17 20 23 26" --8<-- "docs/examples/styles/content_align_all.tcss" ``` ## CSS -```sass +```css /* Align content in the very center of a widget */ content-align: center middle; /* Align content at the top right of a widget */ diff --git a/docs/styles/display.md b/docs/styles/display.md index 6a40dfcb54..c2fe31fc81 100644 --- a/docs/styles/display.md +++ b/docs/styles/display.md @@ -32,13 +32,13 @@ Note that the second widget is hidden by adding the `"remove"` class which sets === "display.tcss" - ```sass hl_lines="13" + ```css hl_lines="13" --8<-- "docs/examples/styles/display.tcss" ``` ## CSS -```sass +```css /* Widget is shown */ display: block; diff --git a/docs/styles/dock.md b/docs/styles/dock.md index 25464c5f7b..00cb0e4195 100644 --- a/docs/styles/dock.md +++ b/docs/styles/dock.md @@ -30,7 +30,7 @@ Notice that even though the content is scrolled, the sidebar remains fixed. === "dock_layout1_sidebar.tcss" - ```sass hl_lines="2" + ```css hl_lines="2" --8<-- "docs/examples/guide/layout/dock_layout1_sidebar.tcss" ``` @@ -52,13 +52,13 @@ The labels will remain in that position (docked) even if the container they are === "dock_all.tcss" - ```sass hl_lines="2-5 8-11 14-17 20-23" + ```css hl_lines="2-5 8-11 14-17 20-23" --8<-- "docs/examples/styles/dock_all.tcss" ``` ## CSS -```sass +```css dock: bottom; /* Docks on the bottom edge of the parent container. */ dock: left; /* Docks on the left edge of the parent container. */ dock: right; /* Docks on the right edge of the parent container. */ diff --git a/docs/styles/grid/column_span.md b/docs/styles/grid/column_span.md index d712edb835..3ed7af7c3b 100644 --- a/docs/styles/grid/column_span.md +++ b/docs/styles/grid/column_span.md @@ -31,13 +31,13 @@ The example below shows a 4 by 4 grid where many placeholders span over several === "column_span.tcss" - ```sass hl_lines="2 5 8 11 14 20" + ```css hl_lines="2 5 8 11 14 20" --8<-- "docs/examples/styles/column_span.tcss" ``` ## CSS -```sass +```css column-span: 3; ``` diff --git a/docs/styles/grid/grid_columns.md b/docs/styles/grid/grid_columns.md index 89b589c6d6..47e31b849e 100644 --- a/docs/styles/grid/grid_columns.md +++ b/docs/styles/grid/grid_columns.md @@ -42,13 +42,13 @@ Because there are more rows than scalars in the style definition, the scalars wi === "grid_columns.tcss" - ```sass hl_lines="3" + ```css hl_lines="3" --8<-- "docs/examples/styles/grid_columns.tcss" ``` ## CSS -```sass +```css /* Set all columns to have 50% width */ grid-columns: 50%; diff --git a/docs/styles/grid/grid_gutter.md b/docs/styles/grid/grid_gutter.md index 39e8981c55..21264848af 100644 --- a/docs/styles/grid/grid_gutter.md +++ b/docs/styles/grid/grid_gutter.md @@ -37,7 +37,7 @@ The example below employs a common trick to apply visually consistent spacing ar === "grid_gutter.tcss" - ```sass hl_lines="3" + ```css hl_lines="3" --8<-- "docs/examples/styles/grid_gutter.tcss" ``` @@ -45,7 +45,7 @@ The example below employs a common trick to apply visually consistent spacing ar ## CSS -```sass +```css /* Set vertical and horizontal gutters to be the same */ grid-gutter: 5; diff --git a/docs/styles/grid/grid_rows.md b/docs/styles/grid/grid_rows.md index 816ce708ef..c76cfda56f 100644 --- a/docs/styles/grid/grid_rows.md +++ b/docs/styles/grid/grid_rows.md @@ -42,13 +42,13 @@ Because there are more rows than scalars in the style definition, the scalars wi === "grid_rows.tcss" - ```sass hl_lines="3" + ```css hl_lines="3" --8<-- "docs/examples/styles/grid_rows.tcss" ``` ## CSS -```sass +```css /* Set all rows to have 50% height */ grid-rows: 50%; diff --git a/docs/styles/grid/grid_size.md b/docs/styles/grid/grid_size.md index b225b858fc..33c6a67b4e 100644 --- a/docs/styles/grid/grid_size.md +++ b/docs/styles/grid/grid_size.md @@ -37,7 +37,7 @@ In the first example, we create a grid with 2 columns and 5 rows, although we do === "grid_size_both.tcss" - ```sass hl_lines="2" + ```css hl_lines="2" --8<-- "docs/examples/styles/grid_size_both.tcss" ``` @@ -60,7 +60,7 @@ In the second example, we create a grid with 2 columns and however many rows are === "grid_size_columns.tcss" - ```sass hl_lines="2" + ```css hl_lines="2" --8<-- "docs/examples/styles/grid_size_columns.tcss" ``` @@ -68,7 +68,7 @@ In the second example, we create a grid with 2 columns and however many rows are ## CSS -```sass +```css /* Grid with 3 rows and 5 columns */ grid-size: 3 5; diff --git a/docs/styles/grid/index.md b/docs/styles/grid/index.md index 012365e00e..23a0a6ae84 100644 --- a/docs/styles/grid/index.md +++ b/docs/styles/grid/index.md @@ -51,7 +51,7 @@ The spacing between grid cells is defined by the `grid-gutter` style. === "grid.tcss" - ```sass + ```css --8<-- "docs/examples/styles/grid.tcss" ``` diff --git a/docs/styles/grid/row_span.md b/docs/styles/grid/row_span.md index 145015e434..9b9dd0da58 100644 --- a/docs/styles/grid/row_span.md +++ b/docs/styles/grid/row_span.md @@ -34,13 +34,13 @@ After placing the placeholders `#p1`, `#p2`, `#p3`, and `#p4`, the next availabl === "row_span.tcss" - ```sass hl_lines="2 5 8 11 14 17 20" + ```css hl_lines="2 5 8 11 14 17 20" --8<-- "docs/examples/styles/row_span.tcss" ``` ## CSS -```sass +```css row-span: 3 ``` diff --git a/docs/styles/height.md b/docs/styles/height.md index e3f8b980c0..7fe8d17158 100644 --- a/docs/styles/height.md +++ b/docs/styles/height.md @@ -30,7 +30,7 @@ This examples creates a widget with a height of 50% of the screen. === "height.tcss" - ```sass hl_lines="3" + ```css hl_lines="3" --8<-- "docs/examples/styles/height.tcss" ``` @@ -47,7 +47,7 @@ Open the CSS file tab to see the comments that explain how each height is comput === "height_comparison.py" - ```py hl_lines="15-23" + ```py hl_lines="17-25" --8<-- "docs/examples/styles/height_comparison.py" ``` @@ -55,7 +55,7 @@ Open the CSS file tab to see the comments that explain how each height is comput === "height_comparison.tcss" - ```sass hl_lines="2 5 8 11 14 17 20 23 26" + ```css hl_lines="2 5 8 11 14 17 20 23 26" --8<-- "docs/examples/styles/height_comparison.tcss" ``` @@ -73,7 +73,7 @@ Open the CSS file tab to see the comments that explain how each height is comput ## CSS -```sass +```css /* Explicit cell height */ height: 10; diff --git a/docs/styles/keyline.md b/docs/styles/keyline.md new file mode 100644 index 0000000000..77c2f43ed5 --- /dev/null +++ b/docs/styles/keyline.md @@ -0,0 +1,83 @@ +# Keyline + +The `keyline` style is applied to a container and will draw lines around child widgets. + +A keyline is superficially like the [border](./border.md) rule, but rather than draw inside the widget, a keyline is drawn outside of the widget's border. Additionally, unlike `border`, keylines can overlap and cross to create dividing lines between widgets. + +Because keylines are drawn in the widget's margin, you will need to apply the [margin](./margin.md) or [grid-gutter](./grid/grid_gutter.md) rule to see the effect. + + +## Syntax + +--8<-- "docs/snippets/syntax_block_start.md" +keyline: [<keyline>] [<color>]; +--8<-- "docs/snippets/syntax_block_end.md" + + +## Examples + +### Horizontal Keyline + +The following examples shows a simple horizontal layout with a thin keyline. + +=== "Output" + + ```{.textual path="docs/examples/styles/keyline_horizontal.py"} + ``` + +=== "keyline.py" + + ```python + --8<-- "docs/examples/styles/keyline_horizontal.py" + ``` + +=== "keyline.tcss" + + ```css + --8<-- "docs/examples/styles/keyline_horizontal.tcss" + ``` + + + +### Grid keyline + +The following examples shows a grid layout with a *heavy* keyline. + +=== "Output" + + ```{.textual path="docs/examples/styles/keyline.py"} + ``` + +=== "keyline.py" + + ```python + --8<-- "docs/examples/styles/keyline.py" + ``` + +=== "keyline.tcss" + + ```css + --8<-- "docs/examples/styles/keyline.tcss" + ``` + + +## CSS + +```css +/* Set a thin green keyline */ +/* Note: Must be set on a container or a widget with a layout. */ +keyline: thin green; +``` + +## Python + +You can set a keyline in Python with a tuple of type and color: + +```python +widget.styles.keyline = ("thin", "green") +``` + + +## See also + + - [`border`](./border.md) to add a border around a widget. diff --git a/docs/styles/layer.md b/docs/styles/layer.md index d1504dd592..bdb56b9be6 100644 --- a/docs/styles/layer.md +++ b/docs/styles/layer.md @@ -37,13 +37,13 @@ However, since `#box1` is on the higher layer, it is drawn on top of `#box2`. === "layers.tcss" - ```sass hl_lines="3 14 19" + ```css hl_lines="3 14 19" --8<-- "docs/examples/guide/layout/layers.tcss" ``` ## CSS -```sass +```css /* Draw the widget on the layer called 'below' */ layer: below; ``` diff --git a/docs/styles/layers.md b/docs/styles/layers.md index 685b5659cf..c31f1f5d36 100644 --- a/docs/styles/layers.md +++ b/docs/styles/layers.md @@ -35,13 +35,13 @@ However, since `#box1` is on the higher layer, it is drawn on top of `#box2`. === "layers.tcss" - ```sass hl_lines="3 14 19" + ```css hl_lines="3 14 19" --8<-- "docs/examples/guide/layout/layers.tcss" ``` ## CSS -```sass +```css /* Bottom layer is called 'below', layer above it is called 'above' */ layers: below above; ``` diff --git a/docs/styles/layout.md b/docs/styles/layout.md index deda25d0cf..590ea3ec5d 100644 --- a/docs/styles/layout.md +++ b/docs/styles/layout.md @@ -23,7 +23,7 @@ See the [layout](../guide/layout.md) guide for more information. ## Example Note how the `layout` style affects the arrangement of widgets in the example below. -To learn more about the grid layout, you can see the [layout guide](../guide/layout.md) or the [grid reference](./grid.md). +To learn more about the grid layout, you can see the [layout guide](../guide/layout.md) or the [grid reference](../grid.md). === "Output" @@ -38,13 +38,13 @@ To learn more about the grid layout, you can see the [layout guide](../guide/lay === "layout.tcss" - ```sass hl_lines="2 8" + ```css hl_lines="2 8" --8<-- "docs/examples/styles/layout.tcss" ``` ## CSS -```sass +```css layout: horizontal; ``` diff --git a/docs/styles/links/demos/link_hover_background_demo.gif b/docs/styles/links/demos/link_background_hover_demo.gif similarity index 100% rename from docs/styles/links/demos/link_hover_background_demo.gif rename to docs/styles/links/demos/link_background_hover_demo.gif diff --git a/docs/styles/links/demos/link_hover_color_demo.gif b/docs/styles/links/demos/link_color_hover_demo.gif similarity index 100% rename from docs/styles/links/demos/link_hover_color_demo.gif rename to docs/styles/links/demos/link_color_hover_demo.gif diff --git a/docs/styles/links/demos/link_hover_style_demo.gif b/docs/styles/links/demos/link_style_hover_demo.gif similarity index 100% rename from docs/styles/links/demos/link_hover_style_demo.gif rename to docs/styles/links/demos/link_style_hover_demo.gif diff --git a/docs/styles/links/index.md b/docs/styles/links/index.md index f2984ba046..87070a5ba0 100644 --- a/docs/styles/links/index.md +++ b/docs/styles/links/index.md @@ -10,11 +10,11 @@ There are a number of styles which influence the appearance of these links withi | Property | Description | |-------------------------------------------------------|-------------------------------------------------------------------| | [`link-background`](./link_background.md) | The background color of the link text. | +| [`link-background-hover`](./link_background_hover.md) | The background color of the link text when the cursor is over it. | | [`link-color`](./link_color.md) | The color of the link text. | -| [`link-hover-background`](./link_hover_background.md) | The background color of the link text when the cursor is over it. | -| [`link-hover-color`](./link_hover_color.md) | The color of the link text when the cursor is over it. | -| [`link-hover-style`](./link_hover_style.md) | The style of the link text when the cursor is over it. | +| [`link-color-hover`](./link_color_hover.md) | The color of the link text when the cursor is over it. | | [`link-style`](./link_style.md) | The style of the link text (e.g. underline). | +| [`link-style-hover`](./link_style_hover.md) | The style of the link text when the cursor is over it. | ## Syntax @@ -25,11 +25,11 @@ There are a number of styles which influence the appearance of these links withi link-style: <text-style>; -link-hover-background: <color> [<percentage>]; +link-background-hover: <color> [<percentage>]; -link-hover-color: <color> [<percentage>]; +link-color-hover: <color> [<percentage>]; -link-hover-style: <text-style>; +link-style-hover: <text-style>; --8<-- "docs/snippets/syntax_block_end.md" Visit each style's reference page to learn more about how the values are used. @@ -52,7 +52,7 @@ The second label uses CSS to customize the link color, background, and style. === "links.tcss" - ```sass + ```css --8<-- "docs/examples/styles/links.tcss" ``` diff --git a/docs/styles/links/link_background.md b/docs/styles/links/link_background.md index a9ccc96cbb..c6cbdc3ad3 100644 --- a/docs/styles/links/link_background.md +++ b/docs/styles/links/link_background.md @@ -26,18 +26,18 @@ It also shows that `link-background` does not affect hyperlinks. === "link_background.py" - ```py hl_lines="8-9 12-13 16-17 20-21" + ```py hl_lines="10-11 14-15 18-20 22-23" --8<-- "docs/examples/styles/link_background.py" ``` - 1. This label has an hyperlink so it won't be affected by the `link-background` rule. + 1. This label has a hyperlink so it won't be affected by the `link-background` rule. 2. This label has an "action link" that can be styled with `link-background`. 3. This label has an "action link" that can be styled with `link-background`. 4. This label has an "action link" that can be styled with `link-background`. === "link_background.tcss" - ```sass hl_lines="2 6 10" + ```css hl_lines="2 6 10" --8<-- "docs/examples/styles/link_background.tcss" ``` @@ -45,7 +45,7 @@ It also shows that `link-background` does not affect hyperlinks. ## CSS -```sass +```css link-background: red 70%; link-background: $accent; ``` @@ -63,4 +63,4 @@ widget.styles.link_background = Color(100, 30, 173) ## See also - [`link-color`](./link_color.md) to set the color of link text. - - [`link-hover-background](./link_hover_background.md) to set the background color of link text when the mouse pointer is over it. + - [`link-background-hover](./link_background_hover.md) to set the background color of link text when the mouse pointer is over it. diff --git a/docs/styles/links/link_hover_background.md b/docs/styles/links/link_background_hover.md similarity index 55% rename from docs/styles/links/link_hover_background.md rename to docs/styles/links/link_background_hover.md index e396e0c615..2d8fbe463e 100644 --- a/docs/styles/links/link_hover_background.md +++ b/docs/styles/links/link_background_hover.md @@ -1,53 +1,53 @@ -# Link-hover-background +# Link-background-hover -The `link-hover-background` style sets the background color of the link when the mouse cursor is over the link. +The `link-background-hover` style sets the background color of the link when the mouse cursor is over the link. !!! note - `link-hover-background` only applies to Textual action links as described in the [actions guide](../../guide/actions.md#links) and not to regular hyperlinks. + `link-background-hover` only applies to Textual action links as described in the [actions guide](../../guide/actions.md#links) and not to regular hyperlinks. ## Syntax --8<-- "docs/snippets/syntax_block_start.md" -link-hover-background: <color> [<percentage>]; +link-background-hover: <color> [<percentage>]; --8<-- "docs/snippets/syntax_block_end.md" -`link-hover-background` accepts a [``](../../css_types/color.md) (with an optional opacity level defined by a [``](../../css_types/percentage.md)) that is used to define the background color of text enclosed in Textual action links when the mouse pointer is over it. +`link-background-hover` accepts a [``](../../css_types/color.md) (with an optional opacity level defined by a [``](../../css_types/percentage.md)) that is used to define the background color of text enclosed in Textual action links when the mouse pointer is over it. ### Defaults -If not provided, a Textual action link will have `link-hover-background` set to `$accent`. +If not provided, a Textual action link will have `link-background-hover` set to `$accent`. ## Example -The example below shows some links that have their background colour changed when the mouse moves over it and it shows that there is a default color for `link-hover-background`. +The example below shows some links that have their background colour changed when the mouse moves over it and it shows that there is a default color for `link-background-hover`. -It also shows that `link-hover-background` does not affect hyperlinks. +It also shows that `link-background-hover` does not affect hyperlinks. === "Output" - ![](./demos/link_hover_background_demo.gif) + ![](./demos/link_background_hover_demo.gif) !!! note The GIF has reduced quality to make it easier to load in the documentation. - Try running the example yourself with `textual run docs/examples/styles/link_hover_background.py`. + Try running the example yourself with `textual run docs/examples/styles/link_background_hover.py`. -=== "link_hover_background.py" +=== "link_background_hover.py" - ```py hl_lines="8-9 12-13 16-17 20-21" - --8<-- "docs/examples/styles/link_hover_background.py" + ```py hl_lines="10-11 14-15 18-19 22-23" + --8<-- "docs/examples/styles/link_background_hover.py" ``` - 1. This label has an hyperlink so it won't be affected by the `link-hover-background` rule. - 2. This label has an "action link" that can be styled with `link-hover-background`. - 3. This label has an "action link" that can be styled with `link-hover-background`. - 4. This label has an "action link" that can be styled with `link-hover-background`. + 1. This label has a hyperlink so it won't be affected by the `link-background-hover` rule. + 2. This label has an "action link" that can be styled with `link-background-hover`. + 3. This label has an "action link" that can be styled with `link-background-hover`. + 4. This label has an "action link" that can be styled with `link-background-hover`. -=== "link_hover_background.tcss" +=== "link_background_hover.tcss" - ```sass hl_lines="2 6 10" - --8<-- "docs/examples/styles/link_hover_background.tcss" + ```css hl_lines="2 6 10" + --8<-- "docs/examples/styles/link_background_hover.tcss" ``` 1. This will only affect one of the labels because action links are the only links that this rule affects. @@ -55,23 +55,23 @@ It also shows that `link-hover-background` does not affect hyperlinks. ## CSS -```sass -link-hover-background: red 70%; -link-hover-background: $accent; +```css +link-background-hover: red 70%; +link-background-hover: $accent; ``` ## Python ```py -widget.styles.link_hover_background = "red 70%" -widget.styles.link_hover_background = "$accent" +widget.styles.link_background_hover = "red 70%" +widget.styles.link_background_hover = "$accent" # You can also use a `Color` object directly: -widget.styles.link_hover_background = Color(100, 30, 173) +widget.styles.link_background_hover = Color(100, 30, 173) ``` ## See also - [`link-background`](./link_background.md) to set the background color of link text. - - [`link-hover-color](./link_hover_color.md) to set the color of link text when the mouse pointer is over it. - - [`link-hover-style](./link_hover_style.md) to set the style of link text when the mouse pointer is over it. + - [`link-color-hover](./link_color_hover.md) to set the color of link text when the mouse pointer is over it. + - [`link-style-hover](./link_style_hover.md) to set the style of link text when the mouse pointer is over it. diff --git a/docs/styles/links/link_color.md b/docs/styles/links/link_color.md index 44e0cd72ac..8d25fdd16d 100644 --- a/docs/styles/links/link_color.md +++ b/docs/styles/links/link_color.md @@ -26,18 +26,18 @@ It also shows that `link-color` does not affect hyperlinks. === "link_color.py" - ```py hl_lines="8-9 12-13 16-17 20-21" + ```py hl_lines="10-11 14-15 18-19 22-23" --8<-- "docs/examples/styles/link_color.py" ``` - 1. This label has an hyperlink so it won't be affected by the `link-color` rule. + 1. This label has a hyperlink so it won't be affected by the `link-color` rule. 2. This label has an "action link" that can be styled with `link-color`. 3. This label has an "action link" that can be styled with `link-color`. 4. This label has an "action link" that can be styled with `link-color`. === "link_color.tcss" - ```sass hl_lines="2 6 10" + ```css hl_lines="2 6 10" --8<-- "docs/examples/styles/link_color.tcss" ``` @@ -45,7 +45,7 @@ It also shows that `link-color` does not affect hyperlinks. ## CSS -```sass +```css link-color: red 70%; link-color: $accent; ``` @@ -63,4 +63,4 @@ widget.styles.link_color = Color(100, 30, 173) ## See also - [`link-background`](./link_background.md) to set the background color of link text. - - [`link-hover-color](./link_hover_color.md) to set the color of link text when the mouse pointer is over it. + - [`link-color-hover](./link_color_hover.md) to set the color of link text when the mouse pointer is over it. diff --git a/docs/styles/links/link_hover_color.md b/docs/styles/links/link_color_hover.md similarity index 53% rename from docs/styles/links/link_hover_color.md rename to docs/styles/links/link_color_hover.md index b525647314..adb2fa78c2 100644 --- a/docs/styles/links/link_hover_color.md +++ b/docs/styles/links/link_color_hover.md @@ -1,80 +1,80 @@ -# Link-hover-color +# Link-color-hover -The `link-hover-color` style sets the color of the link text when the mouse cursor is over the link. +The `link-color-hover` style sets the color of the link text when the mouse cursor is over the link. !!! note - `link-hover-color` only applies to Textual action links as described in the [actions guide](../../guide/actions.md#links) and not to regular hyperlinks. + `link-color-hover` only applies to Textual action links as described in the [actions guide](../../guide/actions.md#links) and not to regular hyperlinks. ## Syntax --8<-- "docs/snippets/syntax_block_start.md" -link-hover-color: <color> [<percentage>]; +link-color-hover: <color> [<percentage>]; --8<-- "docs/snippets/syntax_block_end.md" -`link-hover-color` accepts a [``](../../css_types/color.md) (with an optional opacity level defined by a [``](../../css_types/percentage.md)) that is used to define the color of text enclosed in Textual action links when the mouse pointer is over it. +`link-color-hover` accepts a [``](../../css_types/color.md) (with an optional opacity level defined by a [``](../../css_types/percentage.md)) that is used to define the color of text enclosed in Textual action links when the mouse pointer is over it. ### Defaults -If not provided, a Textual action link will have `link-hover-color` set to `white`. +If not provided, a Textual action link will have `link-color-hover` set to `white`. ## Example The example below shows some links that have their colour changed when the mouse moves over it. -It also shows that `link-hover-color` does not affect hyperlinks. +It also shows that `link-color-hover` does not affect hyperlinks. === "Output" - ![](./demos/link_hover_color_demo.gif) + ![](./demos/link_color_hover_demo.gif) !!! note The background color also changes when the mouse moves over the links because that is the default behavior. - That can be customised by setting [`link-hover-background`](./link_hover_background.md) but we haven't done so in this example. + That can be customised by setting [`link-background-hover`](./link_background_hover.md) but we haven't done so in this example. !!! note The GIF has reduced quality to make it easier to load in the documentation. - Try running the example yourself with `textual run docs/examples/styles/link_hover_color.py`. + Try running the example yourself with `textual run docs/examples/styles/link_color_hover.py`. -=== "link_hover_color.py" +=== "link_color_hover.py" - ```py hl_lines="8-9 12-13 16-17 20-21" - --8<-- "docs/examples/styles/link_hover_color.py" + ```py hl_lines="10-11 14-15 18-19 22-23" + --8<-- "docs/examples/styles/link_color_hover.py" ``` - 1. This label has an hyperlink so it won't be affected by the `link-hover-color` rule. - 2. This label has an "action link" that can be styled with `link-hover-color`. - 3. This label has an "action link" that can be styled with `link-hover-color`. - 4. This label has an "action link" that can be styled with `link-hover-color`. + 1. This label has a hyperlink so it won't be affected by the `link-color-hover` rule. + 2. This label has an "action link" that can be styled with `link-color-hover`. + 3. This label has an "action link" that can be styled with `link-color-hover`. + 4. This label has an "action link" that can be styled with `link-color-hover`. -=== "link_hover_color.tcss" +=== "link_color_hover.tcss" - ```sass hl_lines="2 6 10" - --8<-- "docs/examples/styles/link_hover_color.tcss" + ```css hl_lines="2 6 10" + --8<-- "docs/examples/styles/link_color_hover.tcss" ``` 1. This will only affect one of the labels because action links are the only links that this rule affects. ## CSS -```sass -link-hover-color: red 70%; -link-hover-color: black; +```css +link-color-hover: red 70%; +link-color-hover: black; ``` ## Python ```py -widget.styles.link_hover_color = "red 70%" -widget.styles.link_hover_color = "black" +widget.styles.link_color_hover = "red 70%" +widget.styles.link_color_hover = "black" # You can also use a `Color` object directly: -widget.styles.link_hover_color = Color(100, 30, 173) +widget.styles.link_color_hover = Color(100, 30, 173) ``` ## See also - [`link-color`](./link_color.md) to set the color of link text. - - [`link-hover-background](./link_hover_background.md) to set the background color of link text when the mouse pointer is over it. - - [`link-hover-style](./link_hover_style.md) to set the style of link text when the mouse pointer is over it. + - [`link-background-hover](./link_background_hover.md) to set the background color of link text when the mouse pointer is over it. + - [`link-style-hover](./link_style_hover.md) to set the style of link text when the mouse pointer is over it. diff --git a/docs/styles/links/link_style.md b/docs/styles/links/link_style.md index b5d100b8c5..dce4153fbe 100644 --- a/docs/styles/links/link_style.md +++ b/docs/styles/links/link_style.md @@ -30,18 +30,18 @@ It also shows that `link-style` does not affect hyperlinks. === "link_style.py" - ```py hl_lines="8-9 12-13 16-17 20-21" + ```py hl_lines="10-11 14-15 18-19 22-23" --8<-- "docs/examples/styles/link_style.py" ``` - 1. This label has an hyperlink so it won't be affected by the `link-style` rule. + 1. This label has a hyperlink so it won't be affected by the `link-style` rule. 2. This label has an "action link" that can be styled with `link-style`. 3. This label has an "action link" that can be styled with `link-style`. 4. This label has an "action link" that can be styled with `link-style`. === "link_style.tcss" - ```sass hl_lines="2 6 10" + ```css hl_lines="2 6 10" --8<-- "docs/examples/styles/link_style.tcss" ``` @@ -49,7 +49,7 @@ It also shows that `link-style` does not affect hyperlinks. ## CSS -```sass +```css link-style: bold; link-style: bold italic reverse; ``` @@ -63,5 +63,5 @@ widget.styles.link_style = "bold italic reverse" ## See also - - [`link-hover-style](./link_hover_style.md) to set the style of link text when the mouse pointer is over it. + - [`link-style-hover](./link_style_hover.md) to set the style of link text when the mouse pointer is over it. - [`text-style`](../text_style.md) to set the style of text in a widget. diff --git a/docs/styles/links/link_hover_style.md b/docs/styles/links/link_style_hover.md similarity index 55% rename from docs/styles/links/link_hover_style.md rename to docs/styles/links/link_style_hover.md index 53cee01ec4..5598994f0f 100644 --- a/docs/styles/links/link_hover_style.md +++ b/docs/styles/links/link_style_hover.md @@ -1,57 +1,57 @@ -# Link-hover-style +# Link-style-hover -The `link-hover-style` style sets the text style for the link text when the mouse cursor is over the link. +The `link-style-hover` style sets the text style for the link text when the mouse cursor is over the link. !!! note - `link-hover-style` only applies to Textual action links as described in the [actions guide](../../guide/actions.md#links) and not to regular hyperlinks. + `link-style-hover` only applies to Textual action links as described in the [actions guide](../../guide/actions.md#links) and not to regular hyperlinks. ## Syntax --8<-- "docs/snippets/syntax_block_start.md" -link-hover-style: <text-style>; +link-style-hover: <text-style>; --8<-- "docs/snippets/syntax_block_end.md" -`link-hover-style` applies its [``](../../css_types/text_style.md) to the text of Textual action links when the mouse pointer is over them. +`link-style-hover` applies its [``](../../css_types/text_style.md) to the text of Textual action links when the mouse pointer is over them. ### Defaults -If not provided, a Textual action link will have `link-hover-style` set to `bold`. +If not provided, a Textual action link will have `link-style-hover` set to `bold`. ## Example The example below shows some links that have their colour changed when the mouse moves over it. -It also shows that `link-hover-style` does not affect hyperlinks. +It also shows that `link-style-hover` does not affect hyperlinks. === "Output" - ![](./demos/link_hover_style_demo.gif) + ![](./demos/link_style_hover_demo.gif) !!! note The background color also changes when the mouse moves over the links because that is the default behavior. - That can be customised by setting [`link-hover-background`](./link_hover_background.md) but we haven't done so in this example. + That can be customised by setting [`link-background-hover`](./link_background_hover.md) but we haven't done so in this example. !!! note The GIF has reduced quality to make it easier to load in the documentation. - Try running the example yourself with `textual run docs/examples/styles/link_hover_style.py`. + Try running the example yourself with `textual run docs/examples/styles/link_style_hover.py`. -=== "link_hover_style.py" +=== "link_style_hover.py" - ```py hl_lines="8-9 12-13 16-17 20-21" - --8<-- "docs/examples/styles/link_hover_style.py" + ```py hl_lines="10-11 14-15 18-19 22-23" + --8<-- "docs/examples/styles/link_style_hover.py" ``` - 1. This label has an hyperlink so it won't be affected by the `link-hover-style` rule. - 2. This label has an "action link" that can be styled with `link-hover-style`. - 3. This label has an "action link" that can be styled with `link-hover-style`. - 4. This label has an "action link" that can be styled with `link-hover-style`. + 1. This label has a hyperlink so it won't be affected by the `link-style-hover` rule. + 2. This label has an "action link" that can be styled with `link-style-hover`. + 3. This label has an "action link" that can be styled with `link-style-hover`. + 4. This label has an "action link" that can be styled with `link-style-hover`. -=== "link_hover_style.tcss" +=== "link_style_hover.tcss" - ```sass hl_lines="2 6 10" - --8<-- "docs/examples/styles/link_hover_style.tcss" + ```css hl_lines="2 6 10" + --8<-- "docs/examples/styles/link_style_hover.tcss" ``` 1. This will only affect one of the labels because action links are the only links that this rule affects. @@ -59,21 +59,21 @@ It also shows that `link-hover-style` does not affect hyperlinks. ## CSS -```sass -link-hover-style: bold; -link-hover-style: bold italic reverse; +```css +link-style-hover: bold; +link-style-hover: bold italic reverse; ``` ## Python ```py -widget.styles.link_hover_style = "bold" -widget.styles.link_hover_style = "bold italic reverse" +widget.styles.link_style_hover = "bold" +widget.styles.link_style_hover = "bold italic reverse" ``` ## See also - - [`link-hover-background](./link_hover_background.md) to set the background color of link text when the mouse pointer is over it. - - [`link-hover-color](./link_hover_color.md) to set the color of link text when the mouse pointer is over it. + - [`link-background-hover](./link_background_hover.md) to set the background color of link text when the mouse pointer is over it. + - [`link-color-hover](./link_color_hover.md) to set the color of link text when the mouse pointer is over it. - [`link-style`](./link_style.md) to set the style of link text. - [`text-style`](../text_style.md) to set the style of text in a widget. diff --git a/docs/styles/margin.md b/docs/styles/margin.md index a8f47832ea..e6b4b8b63e 100644 --- a/docs/styles/margin.md +++ b/docs/styles/margin.md @@ -51,7 +51,7 @@ In the example below we add a large margin to a label, which makes it move away === "margin.tcss" - ```sass hl_lines="7" + ```css hl_lines="7" --8<-- "docs/examples/styles/margin.tcss" ``` @@ -73,13 +73,13 @@ In each cell, we have a placeholder that has its margins set in different ways. === "margin_all.tcss" - ```sass hl_lines="25 29 33 37 41 45 49 53" + ```css hl_lines="25 29 33 37 41 45 49 53" --8<-- "docs/examples/styles/margin_all.tcss" ``` ## CSS -```sass +```css /* Set margin of 1 around all edges */ margin: 1; /* Set margin of 2 on the top and bottom edges, and 4 on the left and right */ diff --git a/docs/styles/max_height.md b/docs/styles/max_height.md index d23faa9bab..a90c7888c3 100644 --- a/docs/styles/max_height.md +++ b/docs/styles/max_height.md @@ -29,7 +29,7 @@ Then, we set `max-height` individually on each placeholder. === "max_height.tcss" - ```sass hl_lines="12 16 20 24" + ```css hl_lines="12 16 20 24" --8<-- "docs/examples/styles/max_height.tcss" ``` @@ -37,7 +37,7 @@ Then, we set `max-height` individually on each placeholder. ## CSS -```sass +```css /* Set the maximum height to 10 rows */ max-height: 10; diff --git a/docs/styles/max_width.md b/docs/styles/max_width.md index 5d4596ad05..6c6e6b908a 100644 --- a/docs/styles/max_width.md +++ b/docs/styles/max_width.md @@ -29,7 +29,7 @@ Then, we set `max-width` individually on each placeholder. === "max_width.tcss" - ```sass hl_lines="12 16 20 24" + ```css hl_lines="12 16 20 24" --8<-- "docs/examples/styles/max_width.tcss" ``` @@ -37,7 +37,7 @@ Then, we set `max-width` individually on each placeholder. ## CSS -```sass +```css /* Set the maximum width to 10 rows */ max-width: 10; diff --git a/docs/styles/min_height.md b/docs/styles/min_height.md index 6c23958cc1..9c98083a4a 100644 --- a/docs/styles/min_height.md +++ b/docs/styles/min_height.md @@ -29,7 +29,7 @@ Then, we set `min-height` individually on each placeholder. === "min_height.tcss" - ```sass hl_lines="13 17 21 25" + ```css hl_lines="13 17 21 25" --8<-- "docs/examples/styles/min_height.tcss" ``` @@ -37,7 +37,7 @@ Then, we set `min-height` individually on each placeholder. ## CSS -```sass +```css /* Set the minimum height to 10 rows */ min-height: 10; diff --git a/docs/styles/min_width.md b/docs/styles/min_width.md index a8771fc0b3..1c08ee35c3 100644 --- a/docs/styles/min_width.md +++ b/docs/styles/min_width.md @@ -29,7 +29,7 @@ Then, we set `min-width` individually on each placeholder. === "min_width.tcss" - ```sass hl_lines="13 17 21 25" + ```css hl_lines="13 17 21 25" --8<-- "docs/examples/styles/min_width.tcss" ``` @@ -37,7 +37,7 @@ Then, we set `min-width` individually on each placeholder. ## CSS -```sass +```css /* Set the minimum width to 10 rows */ min-width: 10; diff --git a/docs/styles/offset.md b/docs/styles/offset.md index 47c836166b..eee43e81be 100644 --- a/docs/styles/offset.md +++ b/docs/styles/offset.md @@ -32,13 +32,13 @@ In this example, we have 3 widgets with differing offsets. === "offset.tcss" - ```sass hl_lines="13 20 27" + ```css hl_lines="13 20 27" --8<-- "docs/examples/styles/offset.tcss" ``` ## CSS -```sass +```css /* Move the widget 8 cells in the x direction and 2 in the y direction */ offset: 8 2; diff --git a/docs/styles/opacity.md b/docs/styles/opacity.md index 69b657401e..b5e6a8b2a1 100644 --- a/docs/styles/opacity.md +++ b/docs/styles/opacity.md @@ -36,13 +36,13 @@ When the opacity is zero, all we see is the (black) background. === "opacity.tcss" - ```sass hl_lines="2 6 10 14 18" + ```css hl_lines="2 6 10 14 18" --8<-- "docs/examples/styles/opacity.tcss" ``` ## CSS -```sass +```css /* Fade the widget to 50% against its parent's background */ opacity: 50%; ``` diff --git a/docs/styles/outline.md b/docs/styles/outline.md index b3fb75ff2a..66e760bf96 100644 --- a/docs/styles/outline.md +++ b/docs/styles/outline.md @@ -50,7 +50,7 @@ Note how the outline occludes the text area. === "outline.tcss" - ```sass hl_lines="8" + ```css hl_lines="8" --8<-- "docs/examples/styles/outline.tcss" ``` @@ -71,7 +71,7 @@ The next example shows a grid with all the available outline types. === "outline_all.tcss" - ```sass hl_lines="2 6 10 14 18 22 26 30 34 38 42 46 50 54 58" + ```css hl_lines="2 6 10 14 18 22 26 30 34 38 42 46 50 54 58" --8<-- "docs/examples/styles/outline_all.tcss" ``` @@ -81,7 +81,7 @@ The next example shows a grid with all the available outline types. ## CSS -```sass +```css /* Set a heavy white outline */ outline:heavy white; diff --git a/docs/styles/overflow.md b/docs/styles/overflow.md index d4807ae4dd..caf3b9ccdc 100644 --- a/docs/styles/overflow.md +++ b/docs/styles/overflow.md @@ -47,13 +47,13 @@ The right side has `overflow-y: hidden` which will prevent a scrollbar from bein === "overflow.tcss" - ```sass hl_lines="19" + ```css hl_lines="19" --8<-- "docs/examples/styles/overflow.tcss" ``` ## CSS -```sass +```css /* Automatic scrollbars on both axes (the default) */ overflow: auto auto; diff --git a/docs/styles/padding.md b/docs/styles/padding.md index a26d767b9a..ca34e84a79 100644 --- a/docs/styles/padding.md +++ b/docs/styles/padding.md @@ -50,7 +50,7 @@ This example adds padding around some text. === "padding.tcss" - ```sass hl_lines="7" + ```css hl_lines="7" --8<-- "docs/examples/styles/padding.tcss" ``` @@ -73,13 +73,13 @@ The effect of each padding setting is noticeable in the colored background aroun === "padding_all.tcss" - ```sass hl_lines="16 20 24 28 32 36 40 44" + ```css hl_lines="16 20 24 28 32 36 40 44" --8<-- "docs/examples/styles/padding_all.tcss" ``` ## CSS -```sass +```css /* Set padding of 1 around all edges */ padding: 1; /* Set padding of 2 on the top and bottom edges, and 4 on the left and right */ diff --git a/docs/styles/scrollbar_colors/index.md b/docs/styles/scrollbar_colors/index.md index c0ef25a37e..1ee1b04de0 100644 --- a/docs/styles/scrollbar_colors/index.md +++ b/docs/styles/scrollbar_colors/index.md @@ -51,6 +51,6 @@ The right panel sets `scrollbar-background`, `scrollbar-color`, and `scrollbar-c === "scrollbars.tcss" - ```sass + ```css --8<-- "docs/examples/styles/scrollbars.tcss" ``` diff --git a/docs/styles/scrollbar_colors/scrollbar_background.md b/docs/styles/scrollbar_colors/scrollbar_background.md index 5dff38c3e1..443bf84189 100644 --- a/docs/styles/scrollbar_colors/scrollbar_background.md +++ b/docs/styles/scrollbar_colors/scrollbar_background.md @@ -28,13 +28,13 @@ The `scrollbar-background` style sets the background color of the scrollbar. === "scrollbars2.tcss" - ```sass hl_lines="2" + ```css hl_lines="2" --8<-- "docs/examples/styles/scrollbars2.tcss" ``` ## CSS -```sass +```css scrollbar-backround: blue; ``` diff --git a/docs/styles/scrollbar_colors/scrollbar_background_active.md b/docs/styles/scrollbar_colors/scrollbar_background_active.md index 41e687f582..f54e9e6e3b 100644 --- a/docs/styles/scrollbar_colors/scrollbar_background_active.md +++ b/docs/styles/scrollbar_colors/scrollbar_background_active.md @@ -29,13 +29,13 @@ The `scrollbar-background-active` style sets the background color of the scrollb === "scrollbars2.tcss" - ```sass hl_lines="3" + ```css hl_lines="3" --8<-- "docs/examples/styles/scrollbars2.tcss" ``` ## CSS -```sass +```css scrollbar-backround-active: red; ``` diff --git a/docs/styles/scrollbar_colors/scrollbar_background_hover.md b/docs/styles/scrollbar_colors/scrollbar_background_hover.md index caaa552a10..b02d8b25ef 100644 --- a/docs/styles/scrollbar_colors/scrollbar_background_hover.md +++ b/docs/styles/scrollbar_colors/scrollbar_background_hover.md @@ -29,13 +29,13 @@ The `scrollbar-background-hover` style sets the background color of the scrollba === "scrollbars2.tcss" - ```sass hl_lines="4" + ```css hl_lines="4" --8<-- "docs/examples/styles/scrollbars2.tcss" ``` ## CSS -```sass +```css scrollbar-background-hover: purple; ``` diff --git a/docs/styles/scrollbar_colors/scrollbar_color.md b/docs/styles/scrollbar_colors/scrollbar_color.md index dac2d0daa7..f5c2ea9d95 100644 --- a/docs/styles/scrollbar_colors/scrollbar_color.md +++ b/docs/styles/scrollbar_colors/scrollbar_color.md @@ -29,13 +29,13 @@ The `scrollbar-color` style sets the color of the scrollbar. === "scrollbars2.tcss" - ```sass hl_lines="5" + ```css hl_lines="5" --8<-- "docs/examples/styles/scrollbars2.tcss" ``` ## CSS -```sass +```css scrollbar-color: cyan; ``` diff --git a/docs/styles/scrollbar_colors/scrollbar_color_active.md b/docs/styles/scrollbar_colors/scrollbar_color_active.md index 34ffeff813..3e373e303d 100644 --- a/docs/styles/scrollbar_colors/scrollbar_color_active.md +++ b/docs/styles/scrollbar_colors/scrollbar_color_active.md @@ -29,13 +29,13 @@ The `scrollbar-color-active` style sets the color of the scrollbar when the thum === "scrollbars2.tcss" - ```sass hl_lines="6" + ```css hl_lines="6" --8<-- "docs/examples/styles/scrollbars2.tcss" ``` ## CSS -```sass +```css scrollbar-color-active: yellow; ``` diff --git a/docs/styles/scrollbar_colors/scrollbar_color_hover.md b/docs/styles/scrollbar_colors/scrollbar_color_hover.md index 25e06b436e..fb88b47acf 100644 --- a/docs/styles/scrollbar_colors/scrollbar_color_hover.md +++ b/docs/styles/scrollbar_colors/scrollbar_color_hover.md @@ -29,13 +29,13 @@ The `scrollbar-color-hover` style sets the color of the scrollbar when the curso === "scrollbars2.tcss" - ```sass hl_lines="7" + ```css hl_lines="7" --8<-- "docs/examples/styles/scrollbars2.tcss" ``` ## CSS -```sass +```css scrollbar-color-hover: pink; ``` diff --git a/docs/styles/scrollbar_colors/scrollbar_corner_color.md b/docs/styles/scrollbar_colors/scrollbar_corner_color.md index 7482cd62a1..8c2c9c3140 100644 --- a/docs/styles/scrollbar_colors/scrollbar_corner_color.md +++ b/docs/styles/scrollbar_colors/scrollbar_corner_color.md @@ -27,13 +27,13 @@ The example below sets the scrollbar corner (bottom-right corner of the screen) === "scrollbar_corner_color.tcss" - ```sass hl_lines="3" + ```css hl_lines="3" --8<-- "docs/examples/styles/scrollbar_corner_color.tcss" ``` ## CSS -```sass +```css scrollbar-corner-color: white; ``` diff --git a/docs/styles/scrollbar_gutter.md b/docs/styles/scrollbar_gutter.md index 1666f8a03a..22cd8069c7 100644 --- a/docs/styles/scrollbar_gutter.md +++ b/docs/styles/scrollbar_gutter.md @@ -35,13 +35,13 @@ terminal window. === "scrollbar_gutter.tcss" - ```sass hl_lines="2" + ```css hl_lines="2" --8<-- "docs/examples/styles/scrollbar_gutter.tcss" ``` ## CSS -```sass +```css scrollbar-gutter: auto; /* Don't reserve space for a vertical scrollbar. */ scrollbar-gutter: stable; /* Reserve space for a vertical scrollbar. */ ``` diff --git a/docs/styles/scrollbar_size.md b/docs/styles/scrollbar_size.md index cd392b1fcc..25281214bd 100644 --- a/docs/styles/scrollbar_size.md +++ b/docs/styles/scrollbar_size.md @@ -36,7 +36,7 @@ In this example we modify the size of the widget's scrollbar to be _much_ larger === "scrollbar_size.tcss" - ```sass hl_lines="13" + ```css hl_lines="13" --8<-- "docs/examples/styles/scrollbar_size.tcss" ``` @@ -44,6 +44,11 @@ In this example we modify the size of the widget's scrollbar to be _much_ larger In the next example we show three containers with differently sized scrollbars. +!!! tip + + If you want to hide the scrollbar but still allow the container to scroll + using the mousewheel or keyboard, you can set the scrollbar size to `0`. + === "Output" ```{.textual path="docs/examples/styles/scrollbar_size2.py"} @@ -57,13 +62,13 @@ In the next example we show three containers with differently sized scrollbars. === "scrollbar_size2.tcss" - ```sass hl_lines="6 11 16" + ```css hl_lines="6 11 16" --8<-- "docs/examples/styles/scrollbar_size2.tcss" ``` ## CSS -```sass +```css /* Set horizontal scrollbar to 10, and vertical scrollbar to 4 */ scrollbar-size: 10 4; diff --git a/docs/styles/text_align.md b/docs/styles/text_align.md index d503f6de2a..f0a3977b1a 100644 --- a/docs/styles/text_align.md +++ b/docs/styles/text_align.md @@ -31,7 +31,7 @@ This example shows, from top to bottom: `left`, `center`, `right`, and `justify` === "text_align.tcss" - ```sass hl_lines="2 7 12 17" + ```css hl_lines="2 7 12 17" --8<-- "docs/examples/styles/text_align.tcss" ``` @@ -39,7 +39,7 @@ This example shows, from top to bottom: `left`, `center`, `right`, and `justify` ## CSS -```sass +```css /* Set text in the widget to be right aligned */ text-align: right; ``` diff --git a/docs/styles/text_opacity.md b/docs/styles/text_opacity.md index d178800c32..5c21cf7f92 100644 --- a/docs/styles/text_opacity.md +++ b/docs/styles/text_opacity.md @@ -38,13 +38,13 @@ This example shows, from top to bottom, increasing `text-opacity` values. === "text_opacity.tcss" - ```sass hl_lines="2 6 10 14 18" + ```css hl_lines="2 6 10 14 18" --8<-- "docs/examples/styles/text_opacity.tcss" ``` ## CSS -```sass +```css /* Set the text to be "half-faded" against the background of the widget */ text-opacity: 50%; ``` diff --git a/docs/styles/text_style.md b/docs/styles/text_style.md index e684b440e1..d252140b35 100644 --- a/docs/styles/text_style.md +++ b/docs/styles/text_style.md @@ -29,7 +29,7 @@ Each of the three text panels has a different text style, respectively `bold`, ` === "text_style.tcss" - ```sass hl_lines="9 13 17" + ```css hl_lines="9 13 17" --8<-- "docs/examples/styles/text_style.tcss" ``` @@ -50,13 +50,13 @@ The next example shows all different text styles on their own, as well as some c === "text_style_all.tcss" - ```sass hl_lines="2 6 10 14 18 22 26 30" + ```css hl_lines="2 6 10 14 18 22 26 30" --8<-- "docs/examples/styles/text_style_all.tcss" ``` ## CSS -```sass +```css text-style: italic; ``` diff --git a/docs/styles/tint.md b/docs/styles/tint.md index cc2b29f46b..b692dacbf8 100644 --- a/docs/styles/tint.md +++ b/docs/styles/tint.md @@ -21,7 +21,7 @@ This examples shows a green tint with gradually increasing alpha. === "tint.py" - ```python hl_lines="11" + ```python hl_lines="13" --8<-- "docs/examples/styles/tint.py" ``` @@ -29,13 +29,13 @@ This examples shows a green tint with gradually increasing alpha. === "tint.tcss" - ```sass + ```css --8<-- "docs/examples/styles/tint.tcss" ``` ## CSS -```sass +```css /* A red tint (could indicate an error) */ tint: red 20%; diff --git a/docs/styles/visibility.md b/docs/styles/visibility.md index b80105a48c..78dfe932c4 100644 --- a/docs/styles/visibility.md +++ b/docs/styles/visibility.md @@ -47,7 +47,7 @@ Note that the second widget is hidden while leaving a space where it would have === "visibility.tcss" - ```sass hl_lines="14" + ```css hl_lines="14" --8<-- "docs/examples/styles/visibility.tcss" ``` @@ -74,7 +74,7 @@ The containers all have a white background, and then: === "visibility_containers.tcss" - ```sass hl_lines="2-3 6 8-10 12-14 16-18" + ```css hl_lines="2-3 7 9-11 13-15 17-19" --8<-- "docs/examples/styles/visibility_containers.tcss" ``` @@ -86,7 +86,7 @@ The containers all have a white background, and then: ## CSS -```sass +```css /* Widget is invisible */ visibility: hidden; diff --git a/docs/styles/width.md b/docs/styles/width.md index a0f7553bac..d694b0169a 100644 --- a/docs/styles/width.md +++ b/docs/styles/width.md @@ -30,7 +30,7 @@ This example adds a widget with 50% width of the screen. === "width.tcss" - ```sass hl_lines="3" + ```css hl_lines="3" --8<-- "docs/examples/styles/width.tcss" ``` @@ -51,7 +51,7 @@ This example adds a widget with 50% width of the screen. === "width_comparison.tcss" - ```sass hl_lines="2 5 8 11 14 17 20 23 26" + ```css hl_lines="2 5 8 11 14 17 20 23 26" --8<-- "docs/examples/styles/width_comparison.tcss" ``` @@ -74,7 +74,7 @@ This example adds a widget with 50% width of the screen. ## CSS -```sass +```css /* Explicit cell width */ width: 10; diff --git a/docs/stylesheets/custom.css b/docs/stylesheets/custom.css index ee10d06544..6d54ce45b4 100644 --- a/docs/stylesheets/custom.css +++ b/docs/stylesheets/custom.css @@ -14,6 +14,7 @@ h3 .doc-heading code { } body[data-md-color-primary="black"] .excalidraw svg { + will-change: filter; filter: invert(100%) hue-rotate(180deg); } @@ -72,3 +73,45 @@ td code { opacity: 0.85; } + +.textual-web-demo iframe { + border: none; + width: 100%; + aspect-ratio: 16 / 9; + padding: 0; + margin: 0; +} + + +.textual-web-demo { + display: flex; + width: 100%; + aspect-ratio: 16 / 9; + padding: 0; + margin: 0; + opacity: 0; + transition: 0.3s opacity; +} + +.textual-web-demo.-loaded { + opacity: 1.0; + transition: 0.3s opacity; +} + +:root { + --md-admonition-icon--textualize: url('/images/icons/logo light transparent.svg') +} +.md-typeset .admonition.textualize, +.md-typeset details.textualize { + border-color: rgb(43, 155, 70); +} +.md-typeset .textualize > .admonition-title, +.md-typeset .textualize > summary { + background-color: rgba(43, 155, 70, 0.1); +} +.md-typeset .textualize > .admonition-title::before, +.md-typeset .textualize > summary::before { + background-color: rgb(43, 155, 70); + -webkit-mask-image: var(--md-admonition-icon--textualize); + mask-image: var(--md-admonition-icon--textualize); +} diff --git a/docs/tutorial.md b/docs/tutorial.md index 6c875aa059..ce7ed15d5a 100644 --- a/docs/tutorial.md +++ b/docs/tutorial.md @@ -31,6 +31,21 @@ Here's what the finished app will look like: ```{.textual path="docs/examples/tutorial/stopwatch.py" title="stopwatch.py" press="tab,enter,tab,enter,tab,enter,tab,enter"} ``` +### Try it out! + +The following is *not* a screenshot, but a fully interactive Textual app running in your browser. + + +!!! textualize "Try in Textual-web" + +
+ + +!!! tip + + See [textual-web](https://github.com/Textualize/textual-web) if you are interested in publishing your Textual apps on the web. + + ### Get the code If you want to try the finished Stopwatch app and follow along with the code, first make sure you have [Textual installed](getting_started.md) then check out the [Textual](https://github.com/Textualize/textual) repository: @@ -216,7 +231,7 @@ Let's add a CSS file to our application. Adding the `CSS_PATH` class variable tells Textual to load the following file when the app starts: -```sass title="stopwatch03.tcss" +```css title="stopwatch03.tcss" --8<-- "docs/examples/tutorial/stopwatch03.tcss" ``` @@ -231,7 +246,7 @@ This app looks much more like our sketch. Let's look at how Textual uses `stopwa CSS files contain a number of _declaration blocks_. Here's the first such block from `stopwatch03.tcss` again: -```sass +```css Stopwatch { layout: horizontal; background: $boost; @@ -260,7 +275,7 @@ Here's how this CSS code changes how the `Stopwatch` widget is displayed. Here's the rest of `stopwatch03.tcss` which contains further declaration blocks: -```sass +```css TimeDisplay { content-align: center middle; opacity: 60%; @@ -308,7 +323,7 @@ We can accomplish this with a CSS _class_. Not to be confused with a Python clas Here's the new CSS: -```sass title="stopwatch04.tcss" hl_lines="33-53" +```css title="stopwatch04.tcss" hl_lines="33-53" --8<-- "docs/examples/tutorial/stopwatch04.tcss" ``` @@ -316,7 +331,7 @@ These new rules are prefixed with `.started`. The `.` indicates that `.started` Some of the new styles have more than one selector separated by a space. The space indicates that the rule should match the second selector if it is a child of the first. Let's look at one of these styles: -```sass +```css .started #start { display: none } diff --git a/docs/widget_gallery.md b/docs/widget_gallery.md index 559fc62929..ca82b5d4e3 100644 --- a/docs/widget_gallery.md +++ b/docs/widget_gallery.md @@ -281,7 +281,7 @@ Displays simple static content. Typically used as a base class. ## Switch -A on / off control, inspired by toggle buttons. +An on / off control, inspired by toggle buttons. [Switch reference](./widgets/switch.md){ .md-button .md-button--primary } @@ -307,6 +307,14 @@ A Combination of Tabs and ContentSwitcher to navigate static content. ```{.textual path="docs/examples/widgets/tabbed_content.py" press="j"} ``` +## TextArea + +A multi-line text area which supports syntax highlighting various languages. + +[TextArea reference](./widgets/text_area.md){ .md-button .md-button--primary } + +```{.textual path="docs/examples/widgets/text_area_example.py" columns="42" lines="8"} +``` ## Tree diff --git a/docs/widgets/_template.md b/docs/widgets/_template.md index c4e83c06aa..497db695da 100644 --- a/docs/widgets/_template.md +++ b/docs/widgets/_template.md @@ -25,13 +25,14 @@ Example app showing the widget: === "checkbox.tcss" - ```sass + ```css --8<-- "docs/examples/widgets/checkbox.tcss" ``` -## Reactive attributes +## Reactive Attributes +## Messages ## Bindings diff --git a/docs/widgets/button.md b/docs/widgets/button.md index 290895d374..4e8c7b01c5 100644 --- a/docs/widgets/button.md +++ b/docs/widgets/button.md @@ -25,7 +25,7 @@ Clicking any of the non-disabled buttons in the example app below will result in === "button.tcss" - ```sass + ```css --8<-- "docs/examples/widgets/button.tcss" ``` @@ -41,6 +41,14 @@ Clicking any of the non-disabled buttons in the example app below will result in - [Button.Pressed][textual.widgets.Button.Pressed] +## Bindings + +This widget has no bindings. + +## Component Classes + +This widget has no component classes. + ## Additional Notes - The spacing between the text and the edges of a button are _not_ due to padding. The default styling for a `Button` has the `height` set to 3 lines and a `min-width` of 16 columns. To create a button with zero visible padding, you will need to change these values and also remove the border with `border: none;`. diff --git a/docs/widgets/checkbox.md b/docs/widgets/checkbox.md index a8d6520c2f..fd47c918d6 100644 --- a/docs/widgets/checkbox.md +++ b/docs/widgets/checkbox.md @@ -24,7 +24,7 @@ The example below shows check boxes in various states. === "checkbox.tcss" - ```sass + ```css --8<-- "docs/examples/widgets/checkbox.tcss" ``` @@ -34,6 +34,10 @@ The example below shows check boxes in various states. | ------- | ------ | ------- | -------------------------- | | `value` | `bool` | `False` | The value of the checkbox. | +## Messages + +- [Checkbox.Changed][textual.widgets.Checkbox.Changed] + ## Bindings The checkbox widget defines the following bindings: @@ -45,17 +49,13 @@ The checkbox widget defines the following bindings: ## Component Classes -The checkbox widget provides the following component classes: +The checkbox widget inherits the following component classes: ::: textual.widgets._toggle_button.ToggleButton.COMPONENT_CLASSES options: show_root_heading: false show_root_toc_entry: false -## Messages - -- [Checkbox.Changed][textual.widgets.Checkbox.Changed] - --- diff --git a/docs/widgets/collapsible.md b/docs/widgets/collapsible.md index 6ff479582d..9ba79f8b47 100644 --- a/docs/widgets/collapsible.md +++ b/docs/widgets/collapsible.md @@ -120,11 +120,29 @@ The following example shows `Collapsible` widgets with custom expand/collapse sy --8<-- "docs/examples/widgets/collapsible_custom_symbol.py" ``` -## Reactive attributes +## Reactive Attributes -| Name | Type | Default | Description | -| ----------- | ------ | ------- | ---------------------------------------------------- | -| `collapsed` | `bool` | `True` | Controls the collapsed/expanded state of the widget. | +| Name | Type | Default | Description | +| ----------- | ------ | ------------| ---------------------------------------------------- | +| `collapsed` | `bool` | `True` | Controls the collapsed/expanded state of the widget. | +| `title` | `str` | `"Toggle"` | Title of the collapsed/expanded contents. | + +## Messages + +- [Collapsible.Toggled][textual.widgets.Collapsible.Toggled] + +## Bindings + +The collapsible widget defines the following binding on its title: + +::: textual.widgets._collapsible.CollapsibleTitle.BINDINGS + options: + show_root_heading: false + show_root_toc_entry: false + +## Component Classes + +This widget has no component classes. ::: textual.widgets.Collapsible diff --git a/docs/widgets/content_switcher.md b/docs/widgets/content_switcher.md index dc8f06bf22..126213c94b 100644 --- a/docs/widgets/content_switcher.md +++ b/docs/widgets/content_switcher.md @@ -50,6 +50,18 @@ When the user presses the "Markdown" button the view is switched: | --------- | --------------- | ------- | ----------------------------------------------------------------------- | | `current` | `str` \| `None` | `None` | The ID of the currently-visible child. `None` means nothing is visible. | +## Messages + +This widget posts no messages. + +## Bindings + +This widget has no bindings. + +## Component Classes + +This widget has no component classes. + --- diff --git a/docs/widgets/data_table.md b/docs/widgets/data_table.md index 05c7409982..ab1981c0f1 100644 --- a/docs/widgets/data_table.md +++ b/docs/widgets/data_table.md @@ -10,7 +10,7 @@ A table widget optimized for displaying a lot of data. ### Adding data The following example shows how to fill a table with data. -First, we use [add_columns][textual.widgets.DataTable.add_rows] to include the `lane`, `swimmer`, `country`, and `time` columns in the table. +First, we use [add_columns][textual.widgets.DataTable.add_columns] to include the `lane`, `swimmer`, `country`, and `time` columns in the table. After that, we use the [add_rows][textual.widgets.DataTable.add_rows] method to insert the rows into the table. === "Output" @@ -143,11 +143,22 @@ visible as you scroll through the data table. ### Sorting -The `DataTable` can be sorted using the [sort][textual.widgets.DataTable.sort] method. -In order to sort your data by a column, you must have supplied a `key` to the `add_column` method -when you added it. -You can then pass this key to the `sort` method to sort by that column. -Additionally, you can sort by multiple columns by passing multiple keys to `sort`. +The `DataTable` can be sorted using the [sort][textual.widgets.DataTable.sort] method. In order to sort your data by a column, you can provide the `key` you supplied to the `add_column` method or a `ColumnKey`. You can then pass one more column keys to the `sort` method to sort by one or more columns. + +Additionally, you can sort your `DataTable` with a custom function (or other callable) via the `key` argument. Similar to the `key` parameter of the built-in [sorted()](https://docs.python.org/3/library/functions.html#sorted) function, your function (or other callable) should take a single argument (row) and return a key to use for sorting purposes. + +Providing both `columns` and `key` will limit the row information sent to your `key` function (or other callable) to only the columns specified. + +=== "Output" + + ```{.textual path="docs/examples/widgets/data_table_sort.py"} + ``` + +=== "data_table_sort.py" + + ```python + --8<-- "docs/examples/widgets/data_table_sort.py" + ``` ### Labelled rows diff --git a/docs/widgets/digits.md b/docs/widgets/digits.md index 6dd33044ce..4fb919f762 100644 --- a/docs/widgets/digits.md +++ b/docs/widgets/digits.md @@ -44,15 +44,19 @@ Here's another example which uses `Digits` to display the current time: --8<-- "docs/examples/widgets/clock.py" ``` -## Reactive attributes +## Reactive Attributes This widget has no reactive attributes. +## Messages + +This widget posts no messages. + ## Bindings This widget has no bindings. -## Component classes +## Component Classes This widget has no component classes. diff --git a/docs/widgets/directory_tree.md b/docs/widgets/directory_tree.md index 56f1a00375..992a9fc127 100644 --- a/docs/widgets/directory_tree.md +++ b/docs/widgets/directory_tree.md @@ -34,10 +34,6 @@ and directories: --8<-- "docs/examples/widgets/directory_tree_filtered.py" ~~~ -## Messages - -- [DirectoryTree.FileSelected][textual.widgets.DirectoryTree.FileSelected] - ## Reactive Attributes | Name | Type | Default | Description | @@ -46,6 +42,14 @@ and directories: | `show_guides` | `bool` | `True` | Show guide lines between levels. | | `guide_depth` | `int` | `4` | Amount of indentation between parent and child. | +## Messages + +- [DirectoryTree.FileSelected][textual.widgets.DirectoryTree.FileSelected] + +## Bindings + +The directory tree widget inherits [the bindings from the tree widget][textual.widgets.Tree.BINDINGS]. + ## Component Classes The directory tree widget provides the following component classes: diff --git a/docs/widgets/footer.md b/docs/widgets/footer.md index 4affbe2191..fcb25cf836 100644 --- a/docs/widgets/footer.md +++ b/docs/widgets/footer.md @@ -30,7 +30,11 @@ widget. Notice how the `Footer` automatically displays the keybinding. ## Messages -This widget sends no messages. +This widget posts no messages. + +## Bindings + +This widget has no bindings. ## Component Classes diff --git a/docs/widgets/header.md b/docs/widgets/header.md index c589ddcf00..1ffdf70dd1 100644 --- a/docs/widgets/header.md +++ b/docs/widgets/header.md @@ -45,7 +45,15 @@ This example shows how to set the text in the `Header` using `App.title` and `Ap ## Messages -This widget sends no messages. +This widget posts no messages. + +## Bindings + +This widget has no bindings. + +## Component Classes + +This widget has no component classes. --- diff --git a/docs/widgets/input.md b/docs/widgets/input.md index 455861a397..1a7b8aa65f 100644 --- a/docs/widgets/input.md +++ b/docs/widgets/input.md @@ -22,6 +22,47 @@ The example below shows how you might create a simple form using two `Input` wid --8<-- "docs/examples/widgets/input.py" ``` + +### Input Types + +The `Input` widget supports a `type` parameter which will prevent the user from typing invalid characters. +You can set `type` to any of the following values: + + +| input.type | Description | +| ----------- | ------------------------------------------- | +| `"integer"` | Restricts input to integers. | +| `"number"` | Restricts input to a floating point number. | +| `"text"` | Allow all text (no restrictions). | + +=== "Output" + + ```{.textual path="docs/examples/widgets/input_types.py" press="1234"} + ``` + +=== "input_types.py" + + ```python + --8<-- "docs/examples/widgets/input_types.py" + ``` + +If you set `type` to something other than `"text"`, then the `Input` will apply the appropriate [validator](#validating-input). + +### Restricting Input + +You can limit input to particular characters by supplying the `restrict` parameter, which should be a regular expression. +The `Input` widget will prevent the addition of any characters that would cause the regex to no longer match. +For instance, if you wanted to limit characters to binary you could set `restrict=r"[01]*"`. + +!!! note + + The `restrict` regular expression is applied to the full value and not just to the new character. + +### Maximum Length + +You can limit the length of the input by setting `max_length` to a value greater than zero. +This will prevent the user from typing any more characters when the maximum has been reached. + ### Validating Input You can supply one or more *[validators][textual.validation.Validator]* to the `Input` widget to validate the value. @@ -71,15 +112,23 @@ Textual offers several [built-in validators][textual.validation] for common requ but you can easily roll your own by extending [Validator][textual.validation.Validator], as seen for `Palindrome` in the example above. +#### Validate Empty + +If you set `valid_empty=True` then empty values will bypass any validators, and empty values will be considered valid. + ## Reactive Attributes -| Name | Type | Default | Description | -|-------------------|--------|---------|-----------------------------------------------------------------| -| `cursor_blink` | `bool` | `True` | True if cursor blinking is enabled. | -| `value` | `str` | `""` | The value currently in the text input. | -| `cursor_position` | `int` | `0` | The index of the cursor in the value string. | -| `placeholder` | `str` | `str` | The dimmed placeholder text to display when the input is empty. | -| `password` | `bool` | `False` | True if the input should be masked. | +| Name | Type | Default | Description | +| ----------------- | ------ | -------- | --------------------------------------------------------------- | +| `cursor_blink` | `bool` | `True` | True if cursor blinking is enabled. | +| `value` | `str` | `""` | The value currently in the text input. | +| `cursor_position` | `int` | `0` | The index of the cursor in the value string. | +| `placeholder` | `str` | `""` | The dimmed placeholder text to display when the input is empty. | +| `password` | `bool` | `False` | True if the input should be masked. | +| `restrict` | `str` | `None` | Optional regular expression to restrict input. | +| `type` | `str` | `"text"` | The type of the input. | +| `max_length` | `int` | `None` | Maximum length of the input value. | +| `valid_empty` | `bool` | `False` | Allow empty values to bypass validation. | ## Messages @@ -88,7 +137,7 @@ as seen for `Palindrome` in the example above. ## Bindings -The Input widget defines the following bindings: +The input widget defines the following bindings: ::: textual.widgets.Input.BINDINGS options: diff --git a/docs/widgets/label.md b/docs/widgets/label.md index ae1216d0a2..2a0c1819a7 100644 --- a/docs/widgets/label.md +++ b/docs/widgets/label.md @@ -28,7 +28,15 @@ This widget has no reactive attributes. ## Messages -This widget sends no messages. +This widget posts no messages. + +## Bindings + +This widget has no bindings. + +## Component Classes + +This widget has no component classes. --- diff --git a/docs/widgets/list_item.md b/docs/widgets/list_item.md index 309079ea87..c4d306cb78 100644 --- a/docs/widgets/list_item.md +++ b/docs/widgets/list_item.md @@ -29,12 +29,17 @@ of multiple `ListItem`s. The arrow keys can be used to navigate the list. | ------------- | ------ | ------- | ------------------------------------ | | `highlighted` | `bool` | `False` | True if this ListItem is highlighted | +## Messages -#### Attributes +This widget posts no messages. -| attribute | type | purpose | -| --------- | ---------- | --------------------------- | -| `item` | `ListItem` | The item that was selected. | +## Bindings + +This widget has no bindings. + +## Component Classes + +This widget has no component classes. --- diff --git a/docs/widgets/list_view.md b/docs/widgets/list_view.md index cc403f2c8c..ad92a3dd40 100644 --- a/docs/widgets/list_view.md +++ b/docs/widgets/list_view.md @@ -25,15 +25,15 @@ The example below shows an app with a simple `ListView`. === "list_view.tcss" - ```sass + ```css --8<-- "docs/examples/widgets/list_view.tcss" ``` ## Reactive Attributes -| Name | Type | Default | Description | -| ------- | ----- | ------- | ------------------------------- | -| `index` | `int` | `0` | The currently highlighted index | +| Name | Type | Default | Description | +| ------- | ----- | ------- | -------------------------------- | +| `index` | `int` | `0` | The currently highlighted index. | ## Messages @@ -49,6 +49,10 @@ The list view widget defines the following bindings: show_root_heading: false show_root_toc_entry: false +## Component Classes + +This widget has no component classes. + --- diff --git a/docs/widgets/loading_indicator.md b/docs/widgets/loading_indicator.md index 1936115522..0e2f6f43fe 100644 --- a/docs/widgets/loading_indicator.md +++ b/docs/widgets/loading_indicator.md @@ -7,27 +7,49 @@ Displays pulsating dots to indicate when data is being loaded. - [ ] Focusable - [ ] Container +## Example + +Simple usage example: + +=== "Output" + + ```{.textual path="docs/examples/widgets/loading_indicator.py"} + ``` + +=== "loading_indicator.py" + + ```python + --8<-- "docs/examples/widgets/loading_indicator.py" + ``` + +## Changing Indicator Color + You can set the color of the loading indicator by setting its `color` style. Here's how you would do that with CSS: -```sass +```css LoadingIndicator { color: red; } ``` +## Reactive Attributes -=== "Output" +This widget has no reactive attributes. - ```{.textual path="docs/examples/widgets/loading_indicator.py"} - ``` +## Messages -=== "loading_indicator.py" +This widget posts no messages. + +## Bindings + +This widget has no bindings. + +## Component Classes + +This widget has no component classes. - ```python - --8<-- "docs/examples/widgets/loading_indicator.py" - ``` --- diff --git a/docs/widgets/log.md b/docs/widgets/log.md index 04e54f0f00..72509313a6 100644 --- a/docs/widgets/log.md +++ b/docs/widgets/log.md @@ -37,10 +37,17 @@ The example below shows how to write text to a `Log` widget: | `max_lines` | `int` | `None` | Maximum number of lines in the log or `None` for no maximum. | | `auto_scroll` | `bool` | `False` | Scroll to end of log when new lines are added. | - ## Messages -This widget sends no messages. +This widget posts no messages. + +## Bindings + +This widget has no bindings. + +## Component Classes + +This widget has no component classes. --- diff --git a/docs/widgets/markdown.md b/docs/widgets/markdown.md index 6897c4c713..b4bd53ba51 100644 --- a/docs/widgets/markdown.md +++ b/docs/widgets/markdown.md @@ -4,7 +4,7 @@ A widget to display a Markdown document. -- [x] Focusable +- [ ] Focusable - [ ] Container @@ -27,12 +27,29 @@ The following example displays Markdown from a string. --8<-- "docs/examples/widgets/markdown.py" ~~~ +## Reactive Attributes + +This widget has no reactive attributes. + ## Messages - [Markdown.TableOfContentsUpdated][textual.widgets.Markdown.TableOfContentsUpdated] - [Markdown.TableOfContentsSelected][textual.widgets.Markdown.TableOfContentsSelected] - [Markdown.LinkClicked][textual.widgets.Markdown.LinkClicked] +## Bindings + +This widget has no bindings. + +## Component Classes + +The markdown widget provides the following component classes: + +::: textual.widgets.Markdown.COMPONENT_CLASSES + options: + show_root_heading: false + show_root_toc_entry: false + ## See Also diff --git a/docs/widgets/markdown_viewer.md b/docs/widgets/markdown_viewer.md index 6a4e3f47df..cb209b2801 100644 --- a/docs/widgets/markdown_viewer.md +++ b/docs/widgets/markdown_viewer.md @@ -33,6 +33,18 @@ The following example displays Markdown from a string and a Table of Contents. | ------------------------ | ---- | ------- | ----------------------------------------------------------------- | | `show_table_of_contents` | bool | True | Wether a Table of Contents should be displayed with the Markdown. | +## Messages + +This widget posts no messages. + +## Bindings + +This widget has no bindings. + +## Component Classes + +This widget has no component classes. + ## See Also * [Markdown][textual.widgets.Markdown] code reference @@ -45,3 +57,9 @@ The following example displays Markdown from a string and a Table of Contents. ::: textual.widgets.MarkdownViewer options: heading_level: 2 + + +::: textual.widgets.markdown + options: + show_root_heading: true + show_root_toc_entry: true diff --git a/docs/widgets/option_list.md b/docs/widgets/option_list.md index b0a3170857..100c3052aa 100644 --- a/docs/widgets/option_list.md +++ b/docs/widgets/option_list.md @@ -90,7 +90,7 @@ tables](https://rich.readthedocs.io/en/latest/tables.html): - [OptionList.OptionHighlighted][textual.widgets.OptionList.OptionHighlighted] - [OptionList.OptionSelected][textual.widgets.OptionList.OptionSelected] -Both of the messages above inherit from the common base [`OptionList`][textual.widgets.OptionList.OptionMessage], so refer to its documentation to see what attributes are available. +Both of the messages above inherit from the common base [`OptionList.OptionMessage`][textual.widgets.OptionList.OptionMessage], so refer to its documentation to see what attributes are available. ## Bindings diff --git a/docs/widgets/placeholder.md b/docs/widgets/placeholder.md index c566b871dd..9dce8ac0a6 100644 --- a/docs/widgets/placeholder.md +++ b/docs/widgets/placeholder.md @@ -28,7 +28,7 @@ The example below shows each placeholder variant. === "placeholder.tcss" - ```sass + ```css --8<-- "docs/examples/widgets/placeholder.tcss" ``` @@ -41,7 +41,15 @@ The example below shows each placeholder variant. ## Messages -This widget sends no messages. +This widget posts no messages. + +## Bindings + +This widget has no bindings. + +## Component Classes + +This widget has no component classes. --- diff --git a/docs/widgets/progress_bar.md b/docs/widgets/progress_bar.md index ab02516c98..ba3f5a346f 100644 --- a/docs/widgets/progress_bar.md +++ b/docs/widgets/progress_bar.md @@ -67,7 +67,7 @@ The example below shows a simple app with a progress bar that is keeping track o === "progress_bar.tcss" - ```sass + ```css --8<-- "docs/examples/widgets/progress_bar.tcss" ``` @@ -100,19 +100,10 @@ Refer to the [section below](#styling-the-progress-bar) for more information. === "progress_bar_styled.tcss" - ```sass + ```css --8<-- "docs/examples/widgets/progress_bar_styled.tcss" ``` -## Reactive Attributes - -| Name | Type | Default | Description | -| ------------ | ------- | ------- | ------------------------------------------------------------------------------------------------------- | -| `percentage` | `float | None` | The read-only percentage of progress that has been made. This is `None` if the `total` hasn't been set. | -| `progress` | `float` | `0` | The number of steps of progress already made. | -| `total` | `float | None` | The total number of steps that we are keeping track of. | - - ## Styling the Progress Bar The progress bar is composed of three sub-widgets that can be styled independently: @@ -130,8 +121,27 @@ The progress bar is composed of three sub-widgets that can be styled independent show_root_heading: false show_root_toc_entry: false ---- +## Reactive Attributes + +| Name | Type | Default | Description | +| ------------ | ------- | ------- | ------------------------------------------------------------------------------------------------------- | +| `percentage` | `float | None` | The read-only percentage of progress that has been made. This is `None` if the `total` hasn't been set. | +| `progress` | `float` | `0` | The number of steps of progress already made. | +| `total` | `float | None` | The total number of steps that we are keeping track of. | + +## Messages +This widget posts no messages. + +## Bindings + +This widget has no bindings. + +## Component Classes + +This widget has no component classes. + +--- ::: textual.widgets.ProgressBar options: diff --git a/docs/widgets/radiobutton.md b/docs/widgets/radiobutton.md index 36df3a3c0a..aa2b1c7205 100644 --- a/docs/widgets/radiobutton.md +++ b/docs/widgets/radiobutton.md @@ -26,7 +26,7 @@ The example below shows radio buttons, used within a [`RadioSet`](./radioset.md) === "radio_button.tcss" - ```sass + ```css --8<-- "docs/examples/widgets/radio_button.tcss" ``` @@ -36,6 +36,10 @@ The example below shows radio buttons, used within a [`RadioSet`](./radioset.md) | ------- | ------ | ------- | ------------------------------ | | `value` | `bool` | `False` | The value of the radio button. | +## Messages + +- [RadioButton.Changed][textual.widgets.RadioButton.Changed] + ## Bindings The radio button widget defines the following bindings: @@ -47,17 +51,13 @@ The radio button widget defines the following bindings: ## Component Classes -The radio button widget provides the following component classes: +The checkbox widget inherits the following component classes: ::: textual.widgets._toggle_button.ToggleButton.COMPONENT_CLASSES options: show_root_heading: false show_root_toc_entry: false -## Messages - -- [RadioButton.Changed][textual.widgets.RadioButton.Changed] - ## See Also - [RadioSet](./radioset.md) diff --git a/docs/widgets/radioset.md b/docs/widgets/radioset.md index e51e56b784..4285b4ffa0 100644 --- a/docs/widgets/radioset.md +++ b/docs/widgets/radioset.md @@ -9,6 +9,8 @@ A container widget that groups [`RadioButton`](./radiobutton.md)s together. ## Example +### Simple example + The example below shows two radio sets, one built using a collection of [radio buttons](./radiobutton.md), the other a collection of simple strings. @@ -25,15 +27,11 @@ The example below shows two radio sets, one built using a collection of === "radio_set.tcss" - ```sass + ```css --8<-- "docs/examples/widgets/radio_set.tcss" ``` -## Messages - -- [RadioSet.Changed][textual.widgets.RadioSet.Changed] - -#### Example +### Reacting to Changes in a Radio Set Here is an example of using the message to react to changes in a `RadioSet`: @@ -50,10 +48,27 @@ Here is an example of using the message to react to changes in a `RadioSet`: === "radio_set_changed.tcss" - ```sass + ```css --8<-- "docs/examples/widgets/radio_set_changed.tcss" ``` +## Messages + +- [RadioSet.Changed][textual.widgets.RadioSet.Changed] + +## Bindings + +The `RadioSet` widget defines the following bindings: + +::: textual.widgets.RadioSet.BINDINGS + options: + show_root_heading: false + show_root_toc_entry: false + +## Component Classes + +This widget has no component classes. + ## See Also diff --git a/docs/widgets/rich_log.md b/docs/widgets/rich_log.md index 2778db7ea3..5f373218fd 100644 --- a/docs/widgets/rich_log.md +++ b/docs/widgets/rich_log.md @@ -42,6 +42,14 @@ The example below shows an application showing a `RichLog` with different kinds This widget sends no messages. +## Bindings + +This widget has no bindings. + +## Component Classes + +This widget has no component classes. + --- diff --git a/docs/widgets/rule.md b/docs/widgets/rule.md index bc7a2ec1de..5cf4cec983 100644 --- a/docs/widgets/rule.md +++ b/docs/widgets/rule.md @@ -26,7 +26,7 @@ The example below shows horizontal rules with all the available line styles. === "horizontal_rules.tcss" - ```sass + ```css --8<-- "docs/examples/widgets/horizontal_rules.tcss" ``` @@ -47,7 +47,7 @@ The example below shows vertical rules with all the available line styles. === "vertical_rules.tcss" - ```sass + ```css --8<-- "docs/examples/widgets/vertical_rules.tcss" ``` @@ -62,6 +62,14 @@ The example below shows vertical rules with all the available line styles. This widget sends no messages. +## Bindings + +This widget has no bindings. + +## Component Classes + +This widget has no component classes. + --- diff --git a/docs/widgets/select.md b/docs/widgets/select.md index 7687e2e584..2ba1f1bb72 100644 --- a/docs/widgets/select.md +++ b/docs/widgets/select.md @@ -31,7 +31,9 @@ my_select: Select[int] = Select(options) If you aren't familiar with typing or don't want to worry about it right now, feel free to ignore it. -## Example +## Examples + +### Basic Example The following example presents a `Select` with a number of options. @@ -45,7 +47,6 @@ The following example presents a `Select` with a number of options. ```{.textual path="docs/examples/widgets/select_widget.py" press="tab,enter,down,down"} ``` - === "select_widget.py" ```python @@ -54,23 +55,55 @@ The following example presents a `Select` with a number of options. === "select.tcss" - ```sass + ```css --8<-- "docs/examples/widgets/select.tcss" ``` -## Messages +### Example using Class Method -- [Select.Changed][textual.widgets.Select.Changed] +The following example presents a `Select` created using the `from_values` class method. + +=== "Output" + + ```{.textual path="docs/examples/widgets/select_from_values_widget.py"} + ``` +=== "Output (expanded)" + + ```{.textual path="docs/examples/widgets/select_from_values_widget.py" press="tab,enter,down,down"} + ``` -## Reactive attributes +=== "select_from_values_widget.py" -| Name | Type | Default | Description | -|------------|------------------------|---------|-------------------------------------| -| `expanded` | `bool` | `False` | True to expand the options overlay. | -| `value` | `SelectType` \| `None` | `None` | Current value of the Select. | + ```python + --8<-- "docs/examples/widgets/select_from_values_widget.py" + ``` +=== "select.tcss" + + ```css + --8<-- "docs/examples/widgets/select.tcss" + ``` + +## Blank state + +The widget `Select` has an option `allow_blank` for its constructor. +If set to `True`, the widget may be in a state where there is no selection, in which case its value will be the special constant [`Select.BLANK`][textual.widgets.Select.BLANK]. +The auxiliary methods [`Select.is_blank`][textual.widgets.Select.is_blank] and [`Select.clear`][textual.widgets.Select.clear] provide a convenient way to check if the widget is in this state and to set this state, respectively. + + +## Reactive Attributes + + +| Name | Type | Default | Description | +|------------|--------------------------------|------------------------------------------------|-------------------------------------| +| `expanded` | `bool` | `False` | True to expand the options overlay. | +| `value` | `SelectType` \| `_NoSelection` | [`Select.BLANK`][textual.widgets.Select.BLANK] | Current value of the Select. | + +## Messages + +- [Select.Changed][textual.widgets.Select.Changed] ## Bindings @@ -81,6 +114,9 @@ The Select widget defines the following bindings: show_root_heading: false show_root_toc_entry: false +## Component Classes + +This widget has no component classes. --- @@ -88,3 +124,7 @@ The Select widget defines the following bindings: ::: textual.widgets.Select options: heading_level: 2 + +::: textual.widgets.select + options: + heading_level: 2 diff --git a/docs/widgets/sparkline.md b/docs/widgets/sparkline.md index 98790f9c65..8c0bbb2fa9 100644 --- a/docs/widgets/sparkline.md +++ b/docs/widgets/sparkline.md @@ -38,7 +38,7 @@ The example below illustrates the relationship between the data, its length, the === "sparkline_basic.tcss" - ```sass + ```css --8<-- "docs/examples/widgets/sparkline_basic.tcss" ``` @@ -66,7 +66,7 @@ The summary function is what determines the height of each bar. === "sparkline.tcss" - ```sass + ```css --8<-- "docs/examples/widgets/sparkline.tcss" ``` @@ -87,7 +87,7 @@ The example below shows how to use component classes to change the colors of the === "sparkline_colors.tcss" - ```sass + ```css --8<-- "docs/examples/widgets/sparkline_colors.tcss" ``` @@ -102,7 +102,20 @@ The example below shows how to use component classes to change the colors of the ## Messages -This widget sends no messages. +This widget posts no messages. + +## Bindings + +This widget has no bindings. + +## Component Classes + +The sparkline widget provides the following component classes: + +::: textual.widgets.Sparkline.COMPONENT_CLASSES + options: + show_root_heading: false + show_root_toc_entry: false --- diff --git a/docs/widgets/static.md b/docs/widgets/static.md index 561f053431..9df032994b 100644 --- a/docs/widgets/static.md +++ b/docs/widgets/static.md @@ -27,7 +27,15 @@ This widget has no reactive attributes. ## Messages -This widget sends no messages. +This widget posts no messages. + +## Bindings + +This widget has no bindings. + +## Component Classes + +This widget has no component classes. ## See Also diff --git a/docs/widgets/switch.md b/docs/widgets/switch.md index 4cd8b61825..e7543299a9 100644 --- a/docs/widgets/switch.md +++ b/docs/widgets/switch.md @@ -22,7 +22,7 @@ The example below shows switches in various states. === "switch.tcss" - ```sass + ```css --8<-- "docs/examples/widgets/switch.tcss" ``` @@ -32,6 +32,10 @@ The example below shows switches in various states. | ------- | ------ | ------- | ------------------------ | | `value` | `bool` | `False` | The value of the switch. | +## Messages + +- [Switch.Changed][textual.widgets.Switch.Changed] + ## Bindings The switch widget defines the following bindings: @@ -50,10 +54,6 @@ The switch widget provides the following component classes: show_root_heading: false show_root_toc_entry: false -## Messages - -- [Switch.Changed][textual.widgets.Switch.Changed] - ## Additional Notes - To remove the spacing around a `Switch`, set `border: none;` and `padding: 0;`. diff --git a/docs/widgets/tabbed_content.md b/docs/widgets/tabbed_content.md index 7a61318dfc..15164e7907 100644 --- a/docs/widgets/tabbed_content.md +++ b/docs/widgets/tabbed_content.md @@ -94,7 +94,31 @@ The following example contains a `TabbedContent` with three tabs. --8<-- "docs/examples/widgets/tabbed_content.py" ``` -## Reactive attributes +## Styling + +The `TabbedContent` widget is composed of two main sub-widgets: a +[`Tabs`](tabs.md) and a [`ContentSwitcher`]((content_switcher.md)); you can +style them accordingly. + +The tabs within the `Tabs` widget will have prefixed IDs; each ID being the +ID of the `TabPane` the `Tab` is for, prefixed with `--content-tab-`. If you +wish to style individual tabs within the `TabbedContent` widget you will +need to use that prefix for the `Tab` IDs. + +For example, to create a `TabbedContent` that has red and green labels: + +=== "Output" + + ```{.textual path="docs/examples/widgets/tabbed_content_label_color.py"} + ``` + +=== "tabbed_content.py" + + ```python + --8<-- "docs/examples/widgets/tabbed_content_label_color.py" + ``` + +## Reactive Attributes | Name | Type | Default | Description | | -------- | ----- | ------- | -------------------------------------------------------------- | @@ -105,6 +129,14 @@ The following example contains a `TabbedContent` with three tabs. - [TabbedContent.TabActivated][textual.widgets.TabbedContent.TabActivated] +## Bindings + +This widget has no bindings. + +## Component Classes + +This widget has no component classes. + ## See also diff --git a/docs/widgets/tabs.md b/docs/widgets/tabs.md index b7d7130d74..a076fb715b 100644 --- a/docs/widgets/tabs.md +++ b/docs/widgets/tabs.md @@ -73,6 +73,9 @@ The Tabs widget defines the following bindings: show_root_heading: false show_root_toc_entry: false +## Component Classes + +This widget has no component classes. --- diff --git a/docs/widgets/text_area.md b/docs/widgets/text_area.md new file mode 100644 index 0000000000..bc3a5e25ad --- /dev/null +++ b/docs/widgets/text_area.md @@ -0,0 +1,484 @@ + +# TextArea + +!!! tip "Added in version 0.38.0" + +A widget for editing text which may span multiple lines. +Supports syntax highlighting for a selection of languages. + +- [x] Focusable +- [ ] Container + + +## Guide + +### Syntax highlighting dependencies + +To enable syntax highlighting, you'll need to install the `syntax` extra dependencies: + +=== "pip" + + ``` + pip install "textual[syntax]" + ``` + +=== "poetry" + + ``` + poetry add "textual[syntax]" + ``` + +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. + +### Loading text + +In this example we load some initial text into the `TextArea`, and set the language to `"python"` to enable syntax highlighting. + +=== "Output" + + ```{.textual path="docs/examples/widgets/text_area_example.py" columns="42" lines="8"} + ``` + +=== "text_area_example.py" + + ```python + --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 parser used for syntax highlighting, set the [`language`][textual.widgets._text_area.TextArea.language] reactive attribute: + +```python +# Set the language to Markdown +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`: + +- The [`TextArea.text`][textual.widgets._text_area.TextArea.text] property returns all content in the text area as a string. +- The [`TextArea.selected_text`][textual.widgets._text_area.TextArea.selected_text] property returns the text corresponding to the current selection. +- The [`TextArea.get_text_range`][textual.widgets._text_area.TextArea.get_text_range] method returns the text between two locations. + +In all cases, when multiple lines of text are retrieved, the [document line separator](#line-separators) will be used. + +### Editing content inside `TextArea` + +The content of the `TextArea` can be updated using the [`replace`][textual.widgets._text_area.TextArea.replace] method. +This method is the programmatic equivalent of selecting some text and then pasting. + +Some other convenient methods are available, such as [`insert`][textual.widgets._text_area.TextArea.insert], [`delete`][textual.widgets._text_area.TextArea.delete], and [`clear`][textual.widgets._text_area.TextArea.clear]. + +### Working with the cursor + +#### 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. +Writing a new value to `cursor_location` will immediately update the location of the cursor. + +```python +>>> text_area = TextArea() +>>> text_area.cursor_location +(0, 0) +>>> text_area.cursor_location = (0, 4) +>>> text_area.cursor_location +(0, 4) +``` + +`cursor_location` is a simple way to move the cursor programmatically, but it doesn't let us select text. + +#### Selecting text + +To select text, we can use the `selection` reactive attribute. +Let's select the first two lines of text in a document by adding `text_area.selection = Selection(start=(0, 0), end=(2, 0))` to our code: + +=== "Output" + + ```{.textual path="docs/examples/widgets/text_area_selection.py" columns="42" lines="8"} + ``` + +=== "text_area_selection.py" + + ```python hl_lines="17" + --8<-- "docs/examples/widgets/text_area_selection.py" + ``` + + 1. Selects the first two lines of text. + +Note that selections can happen in both directions, so `Selection((2, 0), (0, 0))` is also valid. + +!!! tip + + The `end` attribute of the `selection` is always equal to `TextArea.cursor_location`. In other words, + the `cursor_location` attribute is simply a convenience for accessing `text_area.selection.end`. + +#### More cursor utilities + +There are a number of additional utility methods available for interacting with the cursor. + +##### Location information + +A number of 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. + +We can also check the location the cursor _would_ arrive at if we were to move it. +For example, [`get_cursor_right_location`][textual.widgets._text_area.TextArea.get_cursor_right_location] returns the location +the cursor would move to if it were to move right. +A number of similar methods exist, with names like `get_cursor_*_location`. + +##### Cursor movement methods + +The [`move_cursor`][textual.widgets._text_area.TextArea.move_cursor] method allows you to move the cursor to a new location while selecting +text, or move the cursor and scroll to keep it centered. + +```python +# Move the cursor from its current location to row index 4, +# column index 8, while selecting all the text between. +text_area.move_cursor((4, 8), select=True) +``` + +The [`move_cursor_relative`][textual.widgets._text_area.TextArea.move_cursor_relative] method offers a very similar interface, but moves the cursor relative +to its current location. + +##### Common selections + +There are some methods available which make common selections easier: + +- [`select_line`][textual.widgets._text_area.TextArea.select_line] selects a line by index. Bound to ++f6++ by default. +- [`select_all`][textual.widgets._text_area.TextArea.select_all] selects all text. Bound to ++f7++ by default. + +### Themes + +`TextArea` ships with some builtin themes, and you can easily add your own. + +Themes give you control over the look and feel, including syntax highlighting, +the cursor, selection, gutter, and more. + +#### 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") +``` + +You can check which themes are available using the [`available_themes`][textual.widgets._text_area.TextArea.available_themes] property. + +```python +>>> text_area = TextArea() +>>> print(text_area.available_themes) +{'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] +attribute to one of the available themes. + +```python +text_area.theme = "vscode_dark" +``` + +On setting this attribute the `TextArea` will immediately refresh to display the updated theme. + +#### Custom themes + +Using custom (non-builtin) themes is 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]. + +##### 1. Creating a theme + +Let's create a simple theme, `"my_cool_theme"`, which colors the cursor blue, and the cursor line yellow. +Our theme will also syntax highlight strings as red, and comments as magenta. + +```python +from rich.style import Style +from textual.widgets.text_area import TextAreaTheme +# ... +my_theme = TextAreaTheme( + # This name will be used to refer to the theme... + name="my_cool_theme", + # Basic styles such as background, cursor, selection, gutter, etc... + cursor_style=Style(color="white", bgcolor="blue"), + cursor_line_style=Style(bgcolor="yellow"), + # `syntax_styles` is for syntax highlighting. + # It maps tokens parsed from the document to Rich styles. + syntax_styles={ + "string": Style(color="red"), + "comment": Style(color="magenta"), + } +) +``` + +Attributes like `cursor_style` and `cursor_line_style` apply general language-agnostic +styling to the widget. + +The `syntax_styles` attribute of `TextAreaTheme` is used for syntax highlighting and +depends on the `language` currently in use. +For more details, see [syntax highlighting](#syntax-highlighting). + +If you wish to build on an existing theme, you can obtain a reference to it using the [`TextAreaTheme.get_builtin_theme`][textual.widgets.text_area.TextAreaTheme.get_builtin_theme] classmethod: + +```python +from textual.widgets.text_area import TextAreaTheme + +monokai = TextAreaTheme.get_builtin_theme("monokai") +``` + +##### 2. Registering a theme + +Our theme can now be registered with the `TextArea` instance. + +```python +text_area.register_theme(my_theme) +``` + +After registering a theme, it'll appear in the `available_themes`: + +```python +>>> print(text_area.available_themes) +{'dracula', 'github_light', 'monokai', 'vscode_dark', 'my_cool_theme'} +``` + +We can now switch to it: + +```python +text_area.theme = "my_cool_theme" +``` + +This immediately updates the appearance of the `TextArea`: + +```{.textual path="docs/examples/widgets/text_area_custom_theme.py" columns="42" lines="8"} +``` + +### 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. + +### Line separators + +When content is loaded into `TextArea`, the content is scanned from beginning to end +and the first occurrence of a line separator is recorded. + +This separator will then be used when content is later read from the `TextArea` via +the `text` property. The `TextArea` widget does not support exporting text which +contains mixed line endings. + +Similarly, newline characters pasted into the `TextArea` will be converted. + +You can check the line separator of the current document by inspecting `TextArea.document.newline`: + +```python +>>> text_area = TextArea() +>>> text_area.document.newline +'\n' +``` + +### Line numbers + +The gutter (column on the left containing line numbers) can be toggled by setting +the `show_line_numbers` attribute to `True` or `False`. + +Setting this attribute will immediately repaint the `TextArea` to reflect the new value. + +### Extending `TextArea` + +Sometimes, you may wish to subclass `TextArea` to add some extra functionality. +In this section, we'll briefly explore how we can extend the widget to achieve common goals. + +#### Hooking into key presses + +You may wish to hook into certain key presses to inject some functionality. +This can be done by over-riding `_on_key` and adding the required functionality. + +##### Example - closing parentheses automatically + +Let's extend `TextArea` to add a feature which automatically closes parentheses and moves the cursor to a sensible location. + +```python +--8<-- "docs/examples/widgets/text_area_extended.py" +``` + +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: + +```{.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"} +``` + +### Advanced concepts + +#### Syntax highlighting + +Syntax highlighting inside the `TextArea` is powered by a library called [`tree-sitter`](https://tree-sitter.github.io/tree-sitter/). + +Each time you update the document in a `TextArea`, an internal syntax tree is updated. +This tree is frequently _queried_ to find location ranges relevant to syntax highlighting. +We give these ranges _names_, and ultimately map them to Rich styles inside `TextAreaTheme.syntax_styles`. + +To illustrate how this works, lets look at how the "Monokai" `TextAreaTheme` highlights Markdown files. + +When the `language` attribute is set to `"markdown"`, a highlight query similar to the one below is used (trimmed for brevity). + +```scheme +(heading_content) @heading +(link) @link +``` + +This highlight query maps `heading_content` nodes returned by the Markdown parser to the name `@heading`, +and `link` nodes to the name `@link`. + +Inside our `TextAreaTheme.syntax_styles` dict, we can map the name `@heading` to a Rich style. +Here's a snippet from the "Monokai" theme which does just that: + +```python +TextAreaTheme( + name="monokai", + base_style=Style(color="#f8f8f2", bgcolor="#272822"), + gutter_style=Style(color="#90908a", bgcolor="#272822"), + # ... + syntax_styles={ + # Colorise @heading and make them bold + "heading": Style(color="#F92672", bold=True), + # Colorise and underline @link + "link": Style(color="#66D9EF", underline=True), + # ... + }, +) +``` + +To understand which names can be mapped inside `syntax_styles`, we recommend looking at the existing +themes and highlighting queries (`.scm` files) in the Textual repository. + +!!! tip + + You may also wish to take a look at the contents of `TextArea._highlights` on an + active `TextArea` instance to see which highlights have been generated for the + open document. + +#### Adding support for custom languages + +To add support for a language to a `TextArea`, use the [`register_language`][textual.widgets._text_area.TextArea.register_language] method. + +To register a language, we require two things: + +1. A tree-sitter `Language` object which contains the grammar for the language. +2. A highlight query which is used for [syntax highlighting](#syntax-highlighting). + +##### Example - adding Java support + +The easiest way to obtain a `Language` object is using the [`py-tree-sitter-languages`](https://github.com/grantjenks/py-tree-sitter-languages) package. Here's how we can use this package to obtain a reference to a `Language` object representing Java: + +```python +from tree_sitter_languages import get_language +java_language = get_language("java") +``` + +The exact version of the parser used when you call `get_language` can be checked via +the [`repos.txt` file](https://github.com/grantjenks/py-tree-sitter-languages/blob/a6d4f7c903bf647be1bdcfa504df967d13e40427/repos.txt) in +the version of `py-tree-sitter-languages` you're using. This file contains links to the GitHub +repos and commit hashes of the tree-sitter parsers. In these repos you can often find pre-made highlight queries at `queries/highlights.scm`, +and a file showing all the available node types which can be used in highlight queries at `src/node-types.json`. + +Since we're adding support for Java, lets grab the Java highlight query from the repo by following these steps: + +1. Open [`repos.txt` file](https://github.com/grantjenks/py-tree-sitter-languages/blob/a6d4f7c903bf647be1bdcfa504df967d13e40427/repos.txt) from the `py-tree-sitter-languages` repo. +2. Find the link corresponding to `tree-sitter-java` and go to the repo on GitHub (you may also need to go to the specific commit referenced in `repos.txt`). +3. Go to [`queries/highlights.scm`](https://github.com/tree-sitter/tree-sitter-java/blob/ac14b4b1884102839455d32543ab6d53ae089ab7/queries/highlights.scm) to see the example highlight query for Java. + +Be sure to check the license in the repo to ensure it can be freely copied. + +!!! warning + + It's important to use a highlight query which is compatible with the parser in use, so + pay attention to the commit hash when visiting the repo via `repos.txt`. + +We now have our `Language` and our highlight query, so we can register Java as a language. + +```python +--8<-- "docs/examples/widgets/text_area_custom_language.py" +``` + +Running our app, we can see that the Java code is highlighted. +We can freely edit the text, and the syntax highlighting will update immediately. + +```{.textual path="docs/examples/widgets/text_area_custom_language.py" columns="52" lines="8"} +``` + +Recall that we map names (like `@heading`) from the tree-sitter highlight query to Rich style objects inside the `TextAreaTheme.syntax_styles` dictionary. +If you notice some highlights are missing after registering a language, the issue may be: + +1. The current `TextAreaTheme` doesn't contain a mapping for the name in the highlight query. Adding a new to `syntax_styles` should resolve the issue. +2. The highlight query doesn't assign a name to the pattern you expect to be highlighted. In this case you'll need to update the highlight query to assign to the name. + +!!! tip + + 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. + +## 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. | + +## Messages + +- [TextArea.Changed][textual.widgets._text_area.TextArea.Changed] +- [TextArea.SelectionChanged][textual.widgets._text_area.TextArea.SelectionChanged] + +## Bindings + +The `TextArea` widget defines the following bindings: + +::: textual.widgets._text_area.TextArea.BINDINGS + options: + show_root_heading: false + show_root_toc_entry: false + + +## Component classes + +The `TextArea` widget defines no component classes. + +Styling should be done exclusively via [`TextAreaTheme`][textual.widgets.text_area.TextAreaTheme]. + +## See also + +- [`Input`][textual.widgets.Input] - for single-line text input. +- [`TextAreaTheme`][textual.widgets.text_area.TextAreaTheme] - for theming the `TextArea`. +- 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). + +--- + +::: textual.widgets._text_area.TextArea + options: + heading_level: 2 + +--- + +::: textual.widgets.text_area + options: + heading_level: 2 diff --git a/docs/widgets/toast.md b/docs/widgets/toast.md index 647f730369..a324150e7f 100644 --- a/docs/widgets/toast.md +++ b/docs/widgets/toast.md @@ -7,9 +7,7 @@ A widget which displays a notification message. - [ ] Focusable - [ ] Container -Note that `Toast` isn't designed to be used directly in your applications, -but it is instead used by [`notify`][textual.app.App.notify] to -display a message when using Textual's built-in notification system. +!!! warning "Note that `Toast` isn't designed to be used directly in your applications, but it is instead used by [`notify`][textual.app.App.notify] to display a message when using Textual's built-in notification system." ## Styling @@ -71,9 +69,30 @@ Toast.-information .toast--title { --8<-- "docs/examples/widgets/toast.py" ``` +## Reactive Attributes + +This widget has no reactive attributes. + +## Messages + +This widget posts no messages. + +## Bindings + +This widget has no bindings. + +## Component Classes + +The toast widget provides the following component classes: + +::: textual.widgets._toast.Toast.COMPONENT_CLASSES + options: + show_root_heading: false + show_root_toc_entry: false + --- -::: textual.widgets._toast +::: textual.widgets._toast.Toast options: show_root_heading: true show_root_toc_entry: true diff --git a/docs/widgets/tree.md b/docs/widgets/tree.md index 70d2822321..e1c4f33d1e 100644 --- a/docs/widgets/tree.md +++ b/docs/widgets/tree.md @@ -69,6 +69,6 @@ The tree widget provides the following component classes: --- -::: textual.widgets.tree.TreeNode +::: textual.widgets.tree options: heading_level: 2 diff --git a/examples/dictionary.tcss b/examples/dictionary.tcss index 151fa019d0..79d7851490 100644 --- a/examples/dictionary.tcss +++ b/examples/dictionary.tcss @@ -9,8 +9,7 @@ Input { #results { width: 100%; - height: auto; - + height: auto; } #results-container { diff --git a/examples/merlin.py b/examples/merlin.py new file mode 100644 index 0000000000..0a0287a4ee --- /dev/null +++ b/examples/merlin.py @@ -0,0 +1,173 @@ +from __future__ import annotations + +import random +from datetime import timedelta +from time import monotonic + +from textual import events +from textual.app import App, ComposeResult +from textual.containers import Grid +from textual.reactive import var +from textual.renderables.gradient import LinearGradient +from textual.widget import Widget +from textual.widgets import Digits, Label, Switch + +# A nice rainbow of colors. +COLORS = [ + "#881177", + "#aa3355", + "#cc6666", + "#ee9944", + "#eedd00", + "#99dd55", + "#44dd88", + "#22ccbb", + "#00bbcc", + "#0099cc", + "#3366bb", + "#663399", +] + + +# Maps a switch number on to other switch numbers, which should be toggled. +TOGGLES: dict[int, tuple[int, ...]] = { + 1: (2, 4, 5), + 2: (1, 3), + 3: (2, 5, 6), + 4: (1, 7), + 5: (2, 4, 6, 8), + 6: (3, 9), + 7: (4, 5, 8), + 8: (7, 9), + 9: (5, 6, 8), +} + + +class LabelSwitch(Widget): + """Switch with a numeric label.""" + + DEFAULT_CSS = """ + LabelSwitch Label { + text-align: center; + width: 1fr; + text-style: bold; + } + + LabelSwitch Label#label-5 { + color: $text-disabled; + } + """ + + def __init__(self, switch_no: int) -> None: + self.switch_no = switch_no + super().__init__() + + def compose(self) -> ComposeResult: + """Compose the label and a switch.""" + yield Label(str(self.switch_no), id=f"label-{self.switch_no}") + yield Switch(id=f"switch-{self.switch_no}", name=str(self.switch_no)) + + +class Timer(Digits): + """Displays a timer that stops when you win.""" + + DEFAULT_CSS = """ + Timer { + text-align: center; + width: auto; + margin: 2 8; + color: $warning; + } + """ + start_time = var(0.0) + running = var(True) + + def on_mount(self) -> None: + """Start the timer on mount.""" + self.start_time = monotonic() + self.set_interval(1, self.tick) + self.tick() + + def tick(self) -> None: + """Called from `set_interval` to update the clock.""" + if self.start_time == 0 or not self.running: + return + time_elapsed = timedelta(seconds=int(monotonic() - self.start_time)) + self.update(str(time_elapsed)) + + +class MerlinApp(App): + """A simple reproduction of one game on the Merlin hand held console.""" + + CSS = """ + Screen { + align: center middle; + } + + Screen.-win { + background: transparent; + } + + Screen.-win Timer { + color: $success; + } + + Grid { + width: auto; + height: auto; + border: thick $primary; + padding: 1 2; + grid-size: 3 3; + grid-rows: auto; + grid-columns: auto; + grid-gutter: 1 1; + background: $surface; + } + """ + + def render(self) -> LinearGradient: + """Renders a gradient, when the background is transparent.""" + stops = [(i / (len(COLORS) - 1), c) for i, c in enumerate(COLORS)] + return LinearGradient(30.0, stops) + + def compose(self) -> ComposeResult: + """Compose a timer, and a grid of 9 switches.""" + yield Timer() + with Grid(): + for switch in (7, 8, 9, 4, 5, 6, 1, 2, 3): + yield LabelSwitch(switch) + + def on_mount(self) -> None: + """Randomize the switches on mount.""" + for switch_no in range(1, 10): + if random.randint(0, 1): + self.query_one(f"#switch-{switch_no}", Switch).toggle() + + def check_win(self) -> bool: + """Check for a win.""" + on_switches = { + int(switch.name or "0") for switch in self.query(Switch) if switch.value + } + return on_switches == {1, 2, 3, 4, 6, 7, 8, 9} + + def on_switch_changed(self, event: Switch.Changed) -> None: + """Called when a switch is toggled.""" + # The switch that was pressed + switch_no = int(event.switch.name or "0") + # Also toggle corresponding switches + with self.prevent(Switch.Changed): + for toggle_no in TOGGLES[switch_no]: + self.query_one(f"#switch-{toggle_no}", Switch).toggle() + # Check the win + if self.check_win(): + self.query_one("Screen").add_class("-win") + self.query_one(Timer).running = False + + def on_key(self, event: events.Key) -> None: + """Maps switches to keys, so we can use the keyboard as well.""" + if event.character and event.character.isdigit(): + self.query_one(f"#switch-{event.character}", Switch).toggle() + + +if __name__ == "__main__": + MerlinApp().run() diff --git a/examples/splash.py b/examples/splash.py new file mode 100644 index 0000000000..54b031c48a --- /dev/null +++ b/examples/splash.py @@ -0,0 +1,58 @@ +from time import time + +from textual.app import App, ComposeResult, RenderableType +from textual.containers import Container +from textual.renderables.gradient import LinearGradient +from textual.widgets import Static + +COLORS = [ + "#881177", + "#aa3355", + "#cc6666", + "#ee9944", + "#eedd00", + "#99dd55", + "#44dd88", + "#22ccbb", + "#00bbcc", + "#0099cc", + "#3366bb", + "#663399", +] +STOPS = [(i / (len(COLORS) - 1), color) for i, color in enumerate(COLORS)] + + +class Splash(Container): + """Custom widget that extends Container.""" + + DEFAULT_CSS = """ + Splash { + align: center middle; + } + Static { + width: 40; + padding: 2 4; + } + """ + + def on_mount(self) -> None: + self.auto_refresh = 1 / 30 + + def compose(self) -> ComposeResult: + yield Static("Making a splash with Textual!") + + def render(self) -> RenderableType: + return LinearGradient(time() * 90, STOPS) + + +class SplashApp(App): + """Simple app to show our custom widget.""" + + def compose(self) -> ComposeResult: + yield Splash() + + +if __name__ == "__main__": + app = SplashApp() + app.run() + print("https://textual.textualize.io/how-to/render-and-compose/") diff --git a/mkdocs-common.yml b/mkdocs-common.yml index 50c574073d..6834c0ff50 100644 --- a/mkdocs-common.yml +++ b/mkdocs-common.yml @@ -3,12 +3,13 @@ site_name: Textual markdown_extensions: - attr_list - pymdownx.emoji: - emoji_index: !!python/name:materialx.emoji.twemoji - emoji_generator: !!python/name:materialx.emoji.to_svg + emoji_index: !!python/name:material.extensions.emoji.twemoji + emoji_generator: !!python/name:material.extensions.emoji.to_svg - md_in_html - admonition - def_list - meta + - footnotes - toc: permalink: true @@ -34,10 +35,12 @@ markdown_extensions: alternate_style: true - pymdownx.snippets - markdown.extensions.attr_list + - pymdownx.details theme: name: material custom_dir: docs/custom_theme + logo: images/icons/logo light transparent.svg features: - navigation.tabs - navigation.indexes @@ -80,7 +83,8 @@ plugins: - "!^can_replace$" # Hide some methods that Widget subclasses implement but that we don't want # to be shown in the docs. - # This is then overridden in widget.md so that it shows in the base class. + # This is then overridden in widget.md and app.md so that it shows in the + # base class. - "!^compose$" - "!^render$" - "!^render_line$" diff --git a/mkdocs-nav.yml b/mkdocs-nav.yml index 2e688c3088..68f1723917 100644 --- a/mkdocs-nav.yml +++ b/mkdocs-nav.yml @@ -1,212 +1,222 @@ nav: - - Introduction: - - "index.md" - - "getting_started.md" - - "help.md" - - "tutorial.md" - - Guide: - - "guide/index.md" - - "guide/devtools.md" - - "guide/app.md" - - "guide/styles.md" - - "guide/CSS.md" - - "guide/design.md" - - "guide/queries.md" - - "guide/layout.md" - - "guide/events.md" - - "guide/input.md" - - "guide/actions.md" - - "guide/reactivity.md" - - "guide/widgets.md" - - "guide/animation.md" - - "guide/screens.md" - - "guide/workers.md" - - "guide/command_palette.md" - - "widget_gallery.md" - - Reference: - - "reference/index.md" - - CSS Types: - - "css_types/index.md" - - "css_types/border.md" - - "css_types/color.md" - - "css_types/horizontal.md" - - "css_types/integer.md" - - "css_types/name.md" - - "css_types/number.md" - - "css_types/overflow.md" - - "css_types/percentage.md" - - "css_types/scalar.md" - - "css_types/text_align.md" - - "css_types/text_style.md" - - "css_types/vertical.md" - - Events: - - "events/index.md" - - "events/blur.md" - - "events/descendant_blur.md" - - "events/descendant_focus.md" - - "events/enter.md" - - "events/focus.md" - - "events/hide.md" - - "events/key.md" - - "events/leave.md" - - "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" - - "events/mouse_scroll_down.md" - - "events/mouse_scroll_up.md" - - "events/mouse_up.md" - - "events/paste.md" - - "events/resize.md" - - "events/screen_resume.md" - - "events/screen_suspend.md" - - "events/show.md" - - Styles: - - "styles/align.md" - - "styles/background.md" - - "styles/border.md" - - "styles/border_subtitle_align.md" - - "styles/border_subtitle_background.md" - - "styles/border_subtitle_color.md" - - "styles/border_subtitle_style.md" - - "styles/border_title_align.md" - - "styles/border_title_background.md" - - "styles/border_title_color.md" - - "styles/border_title_style.md" - - "styles/box_sizing.md" - - "styles/color.md" - - "styles/content_align.md" - - "styles/display.md" - - "styles/dock.md" - - "styles/index.md" - - Grid: - - "styles/grid/index.md" - - "styles/grid/column_span.md" - - "styles/grid/grid_columns.md" - - "styles/grid/grid_gutter.md" - - "styles/grid/grid_rows.md" - - "styles/grid/grid_size.md" - - "styles/grid/row_span.md" - - "styles/height.md" - - "styles/layer.md" - - "styles/layers.md" - - "styles/layout.md" - - Links: - - "styles/links/index.md" - - "styles/links/link_background.md" - - "styles/links/link_color.md" - - "styles/links/link_hover_background.md" - - "styles/links/link_hover_color.md" - - "styles/links/link_hover_style.md" - - "styles/links/link_style.md" - - "styles/margin.md" - - "styles/max_height.md" - - "styles/max_width.md" - - "styles/min_height.md" - - "styles/min_width.md" - - "styles/offset.md" - - "styles/opacity.md" - - "styles/outline.md" - - "styles/overflow.md" - - "styles/padding.md" - - Scrollbar colors: - - "styles/scrollbar_colors/index.md" - - "styles/scrollbar_colors/scrollbar_background.md" - - "styles/scrollbar_colors/scrollbar_background_active.md" - - "styles/scrollbar_colors/scrollbar_background_hover.md" - - "styles/scrollbar_colors/scrollbar_color.md" - - "styles/scrollbar_colors/scrollbar_color_active.md" - - "styles/scrollbar_colors/scrollbar_color_hover.md" - - "styles/scrollbar_colors/scrollbar_corner_color.md" - - "styles/scrollbar_gutter.md" - - "styles/scrollbar_size.md" - - "styles/text_align.md" - - "styles/text_opacity.md" - - "styles/text_style.md" - - "styles/tint.md" - - "styles/visibility.md" - - "styles/width.md" - - Widgets: - - "widgets/button.md" - - "widgets/checkbox.md" - - "widgets/collapsible.md" - - "widgets/content_switcher.md" - - "widgets/data_table.md" - - "widgets/digits.md" - - "widgets/directory_tree.md" - - "widgets/footer.md" - - "widgets/header.md" - - "widgets/index.md" - - "widgets/input.md" - - "widgets/label.md" - - "widgets/list_item.md" - - "widgets/list_view.md" - - "widgets/loading_indicator.md" - - "widgets/log.md" - - "widgets/markdown_viewer.md" - - "widgets/markdown.md" - - "widgets/option_list.md" - - "widgets/placeholder.md" - - "widgets/pretty.md" - - "widgets/progress_bar.md" - - "widgets/radiobutton.md" - - "widgets/radioset.md" - - "widgets/rich_log.md" - - "widgets/rule.md" - - "widgets/select.md" - - "widgets/selection_list.md" - - "widgets/sparkline.md" - - "widgets/static.md" - - "widgets/switch.md" - - "widgets/tabbed_content.md" - - "widgets/tabs.md" - - "widgets/tree.md" - - API: - - "api/index.md" - - "api/app.md" - - "api/await_remove.md" - - "api/binding.md" - - "api/color.md" - - "api/command.md" - - "api/containers.md" - - "api/coordinate.md" - - "api/dom_node.md" - - "api/events.md" - - "api/errors.md" - - "api/filter.md" - - "api/fuzzy_matcher.md" - - "api/geometry.md" - - "api/logger.md" - - "api/logging.md" - - "api/map_geometry.md" - - "api/message_pump.md" - - "api/message.md" - - "api/on.md" - - "api/pilot.md" - - "api/query.md" - - "api/reactive.md" - - "api/screen.md" - - "api/scrollbar.md" - - "api/scroll_view.md" - - "api/strip.md" - - "api/suggester.md" - - "api/system_commands_source.md" - - "api/timer.md" - - "api/types.md" - - "api/validation.md" - - "api/walk.md" - - "api/widget.md" - - "api/work.md" - - "api/worker.md" - - "api/worker_manager.md" - - "How To": - - "how-to/index.md" - - "how-to/center-things.md" - - "how-to/design-a-layout.md" - - "FAQ.md" - - "roadmap.md" - - "Blog": - - blog/index.md + - "index.md" + - Introduction: + - "getting_started.md" + - "help.md" + - "tutorial.md" + - Guide: + - "guide/index.md" + - "guide/devtools.md" + - "guide/app.md" + - "guide/styles.md" + - "guide/CSS.md" + - "guide/design.md" + - "guide/queries.md" + - "guide/layout.md" + - "guide/events.md" + - "guide/input.md" + - "guide/actions.md" + - "guide/reactivity.md" + - "guide/widgets.md" + - "guide/animation.md" + - "guide/screens.md" + - "guide/workers.md" + - "guide/command_palette.md" + - "guide/testing.md" + - "widget_gallery.md" + - Reference: + - "reference/index.md" + - CSS Types: + - "css_types/index.md" + - "css_types/border.md" + - "css_types/color.md" + - "css_types/horizontal.md" + - "css_types/integer.md" + - "css_types/keyline.md" + - "css_types/name.md" + - "css_types/number.md" + - "css_types/overflow.md" + - "css_types/percentage.md" + - "css_types/scalar.md" + - "css_types/text_align.md" + - "css_types/text_style.md" + - "css_types/vertical.md" + - Events: + - "events/index.md" + - "events/blur.md" + - "events/descendant_blur.md" + - "events/descendant_focus.md" + - "events/enter.md" + - "events/focus.md" + - "events/hide.md" + - "events/key.md" + - "events/leave.md" + - "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" + - "events/mouse_scroll_down.md" + - "events/mouse_scroll_up.md" + - "events/mouse_up.md" + - "events/paste.md" + - "events/resize.md" + - "events/screen_resume.md" + - "events/screen_suspend.md" + - "events/show.md" + - Styles: + - "styles/align.md" + - "styles/background.md" + - "styles/border.md" + - "styles/border_subtitle_align.md" + - "styles/border_subtitle_background.md" + - "styles/border_subtitle_color.md" + - "styles/border_subtitle_style.md" + - "styles/border_title_align.md" + - "styles/border_title_background.md" + - "styles/border_title_color.md" + - "styles/border_title_style.md" + - "styles/box_sizing.md" + - "styles/color.md" + - "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" + - "styles/grid/grid_columns.md" + - "styles/grid/grid_gutter.md" + - "styles/grid/grid_rows.md" + - "styles/grid/grid_size.md" + - "styles/grid/row_span.md" + - "styles/height.md" + - "styles/layer.md" + - "styles/layers.md" + - "styles/layout.md" + - Links: + - "styles/links/index.md" + - "styles/links/link_background.md" + - "styles/links/link_background_hover.md" + - "styles/links/link_color.md" + - "styles/links/link_color_hover.md" + - "styles/links/link_style.md" + - "styles/links/link_style_hover.md" + - "styles/margin.md" + - "styles/max_height.md" + - "styles/max_width.md" + - "styles/min_height.md" + - "styles/min_width.md" + - "styles/offset.md" + - "styles/opacity.md" + - "styles/outline.md" + - "styles/overflow.md" + - "styles/padding.md" + - Scrollbar colors: + - "styles/scrollbar_colors/index.md" + - "styles/scrollbar_colors/scrollbar_background.md" + - "styles/scrollbar_colors/scrollbar_background_active.md" + - "styles/scrollbar_colors/scrollbar_background_hover.md" + - "styles/scrollbar_colors/scrollbar_color.md" + - "styles/scrollbar_colors/scrollbar_color_active.md" + - "styles/scrollbar_colors/scrollbar_color_hover.md" + - "styles/scrollbar_colors/scrollbar_corner_color.md" + - "styles/scrollbar_gutter.md" + - "styles/scrollbar_size.md" + - "styles/text_align.md" + - "styles/text_opacity.md" + - "styles/text_style.md" + - "styles/tint.md" + - "styles/visibility.md" + - "styles/width.md" + - Widgets: + - "widgets/button.md" + - "widgets/checkbox.md" + - "widgets/collapsible.md" + - "widgets/content_switcher.md" + - "widgets/data_table.md" + - "widgets/digits.md" + - "widgets/directory_tree.md" + - "widgets/footer.md" + - "widgets/header.md" + - "widgets/index.md" + - "widgets/input.md" + - "widgets/label.md" + - "widgets/list_item.md" + - "widgets/list_view.md" + - "widgets/loading_indicator.md" + - "widgets/log.md" + - "widgets/markdown_viewer.md" + - "widgets/markdown.md" + - "widgets/option_list.md" + - "widgets/placeholder.md" + - "widgets/pretty.md" + - "widgets/progress_bar.md" + - "widgets/radiobutton.md" + - "widgets/radioset.md" + - "widgets/rich_log.md" + - "widgets/rule.md" + - "widgets/select.md" + - "widgets/selection_list.md" + - "widgets/sparkline.md" + - "widgets/static.md" + - "widgets/switch.md" + - "widgets/tabbed_content.md" + - "widgets/tabs.md" + - "widgets/text_area.md" + - "widgets/toast.md" + - "widgets/tree.md" + - API: + - "api/index.md" + - "api/app.md" + - "api/await_complete.md" + - "api/await_remove.md" + - "api/binding.md" + - "api/color.md" + - "api/command.md" + - "api/containers.md" + - "api/content_switcher.md" + - "api/coordinate.md" + - "api/dom_node.md" + - "api/events.md" + - "api/errors.md" + - "api/filter.md" + - "api/fuzzy_matcher.md" + - "api/geometry.md" + - "api/lazy.md" + - "api/logger.md" + - "api/logging.md" + - "api/map_geometry.md" + - "api/message_pump.md" + - "api/message.md" + - "api/on.md" + - "api/pilot.md" + - "api/query.md" + - "api/reactive.md" + - "api/renderables.md" + - "api/screen.md" + - "api/scrollbar.md" + - "api/scroll_view.md" + - "api/strip.md" + - "api/suggester.md" + - "api/system_commands_source.md" + - "api/timer.md" + - "api/types.md" + - "api/validation.md" + - "api/walk.md" + - "api/widget.md" + - "api/work.md" + - "api/worker.md" + - "api/worker_manager.md" + - "How To": + - "how-to/index.md" + - "how-to/center-things.md" + - "how-to/design-a-layout.md" + - "how-to/render-and-compose.md" + - "FAQ.md" + - "roadmap.md" + - "Blog": + - blog/index.md diff --git a/poetry.lock b/poetry.lock index 85f0779436..3dfffadb4c 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,114 +1,100 @@ -# This file is automatically @generated by Poetry 1.6.1 and should not be changed by hand. +# This file is automatically @generated by Poetry 1.7.1 and should not be changed by hand. [[package]] name = "aiohttp" -version = "3.8.5" +version = "3.9.1" description = "Async http client/server framework (asyncio)" optional = false -python-versions = ">=3.6" -files = [ - {file = "aiohttp-3.8.5-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:a94159871304770da4dd371f4291b20cac04e8c94f11bdea1c3478e557fbe0d8"}, - {file = "aiohttp-3.8.5-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:13bf85afc99ce6f9ee3567b04501f18f9f8dbbb2ea11ed1a2e079670403a7c84"}, - {file = "aiohttp-3.8.5-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:2ce2ac5708501afc4847221a521f7e4b245abf5178cf5ddae9d5b3856ddb2f3a"}, - {file = "aiohttp-3.8.5-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:96943e5dcc37a6529d18766597c491798b7eb7a61d48878611298afc1fca946c"}, - {file = "aiohttp-3.8.5-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2ad5c3c4590bb3cc28b4382f031f3783f25ec223557124c68754a2231d989e2b"}, - {file = "aiohttp-3.8.5-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0c413c633d0512df4dc7fd2373ec06cc6a815b7b6d6c2f208ada7e9e93a5061d"}, - {file = "aiohttp-3.8.5-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:df72ac063b97837a80d80dec8d54c241af059cc9bb42c4de68bd5b61ceb37caa"}, - {file = "aiohttp-3.8.5-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c48c5c0271149cfe467c0ff8eb941279fd6e3f65c9a388c984e0e6cf57538e14"}, - {file = "aiohttp-3.8.5-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:368a42363c4d70ab52c2c6420a57f190ed3dfaca6a1b19afda8165ee16416a82"}, - {file = "aiohttp-3.8.5-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:7607ec3ce4993464368505888af5beb446845a014bc676d349efec0e05085905"}, - {file = "aiohttp-3.8.5-cp310-cp310-musllinux_1_1_ppc64le.whl", hash = "sha256:0d21c684808288a98914e5aaf2a7c6a3179d4df11d249799c32d1808e79503b5"}, - {file = "aiohttp-3.8.5-cp310-cp310-musllinux_1_1_s390x.whl", hash = "sha256:312fcfbacc7880a8da0ae8b6abc6cc7d752e9caa0051a53d217a650b25e9a691"}, - {file = "aiohttp-3.8.5-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:ad093e823df03bb3fd37e7dec9d4670c34f9e24aeace76808fc20a507cace825"}, - {file = "aiohttp-3.8.5-cp310-cp310-win32.whl", hash = "sha256:33279701c04351a2914e1100b62b2a7fdb9a25995c4a104259f9a5ead7ed4802"}, - {file = "aiohttp-3.8.5-cp310-cp310-win_amd64.whl", hash = "sha256:6e4a280e4b975a2e7745573e3fc9c9ba0d1194a3738ce1cbaa80626cc9b4f4df"}, - {file = "aiohttp-3.8.5-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:ae871a964e1987a943d83d6709d20ec6103ca1eaf52f7e0d36ee1b5bebb8b9b9"}, - {file = "aiohttp-3.8.5-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:461908b2578955045efde733719d62f2b649c404189a09a632d245b445c9c975"}, - {file = "aiohttp-3.8.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:72a860c215e26192379f57cae5ab12b168b75db8271f111019509a1196dfc780"}, - {file = "aiohttp-3.8.5-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cc14be025665dba6202b6a71cfcdb53210cc498e50068bc088076624471f8bb9"}, - {file = "aiohttp-3.8.5-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8af740fc2711ad85f1a5c034a435782fbd5b5f8314c9a3ef071424a8158d7f6b"}, - {file = "aiohttp-3.8.5-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:841cd8233cbd2111a0ef0a522ce016357c5e3aff8a8ce92bcfa14cef890d698f"}, - {file = "aiohttp-3.8.5-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5ed1c46fb119f1b59304b5ec89f834f07124cd23ae5b74288e364477641060ff"}, - {file = "aiohttp-3.8.5-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:84f8ae3e09a34f35c18fa57f015cc394bd1389bce02503fb30c394d04ee6b938"}, - {file = "aiohttp-3.8.5-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:62360cb771707cb70a6fd114b9871d20d7dd2163a0feafe43fd115cfe4fe845e"}, - {file = "aiohttp-3.8.5-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:23fb25a9f0a1ca1f24c0a371523546366bb642397c94ab45ad3aedf2941cec6a"}, - {file = "aiohttp-3.8.5-cp311-cp311-musllinux_1_1_ppc64le.whl", hash = "sha256:b0ba0d15164eae3d878260d4c4df859bbdc6466e9e6689c344a13334f988bb53"}, - {file = "aiohttp-3.8.5-cp311-cp311-musllinux_1_1_s390x.whl", hash = "sha256:5d20003b635fc6ae3f96d7260281dfaf1894fc3aa24d1888a9b2628e97c241e5"}, - {file = "aiohttp-3.8.5-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:0175d745d9e85c40dcc51c8f88c74bfbaef9e7afeeeb9d03c37977270303064c"}, - {file = "aiohttp-3.8.5-cp311-cp311-win32.whl", hash = "sha256:2e1b1e51b0774408f091d268648e3d57f7260c1682e7d3a63cb00d22d71bb945"}, - {file = "aiohttp-3.8.5-cp311-cp311-win_amd64.whl", hash = "sha256:043d2299f6dfdc92f0ac5e995dfc56668e1587cea7f9aa9d8a78a1b6554e5755"}, - {file = "aiohttp-3.8.5-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:cae533195e8122584ec87531d6df000ad07737eaa3c81209e85c928854d2195c"}, - {file = "aiohttp-3.8.5-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4f21e83f355643c345177a5d1d8079f9f28b5133bcd154193b799d380331d5d3"}, - {file = "aiohttp-3.8.5-cp36-cp36m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a7a75ef35f2df54ad55dbf4b73fe1da96f370e51b10c91f08b19603c64004acc"}, - {file = "aiohttp-3.8.5-cp36-cp36m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2e2e9839e14dd5308ee773c97115f1e0a1cb1d75cbeeee9f33824fa5144c7634"}, - {file = "aiohttp-3.8.5-cp36-cp36m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c44e65da1de4403d0576473e2344828ef9c4c6244d65cf4b75549bb46d40b8dd"}, - {file = "aiohttp-3.8.5-cp36-cp36m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:78d847e4cde6ecc19125ccbc9bfac4a7ab37c234dd88fbb3c5c524e8e14da543"}, - {file = "aiohttp-3.8.5-cp36-cp36m-musllinux_1_1_aarch64.whl", hash = "sha256:c7a815258e5895d8900aec4454f38dca9aed71085f227537208057853f9d13f2"}, - {file = "aiohttp-3.8.5-cp36-cp36m-musllinux_1_1_i686.whl", hash = "sha256:8b929b9bd7cd7c3939f8bcfffa92fae7480bd1aa425279d51a89327d600c704d"}, - {file = "aiohttp-3.8.5-cp36-cp36m-musllinux_1_1_ppc64le.whl", hash = "sha256:5db3a5b833764280ed7618393832e0853e40f3d3e9aa128ac0ba0f8278d08649"}, - {file = "aiohttp-3.8.5-cp36-cp36m-musllinux_1_1_s390x.whl", hash = "sha256:a0215ce6041d501f3155dc219712bc41252d0ab76474615b9700d63d4d9292af"}, - {file = "aiohttp-3.8.5-cp36-cp36m-musllinux_1_1_x86_64.whl", hash = "sha256:fd1ed388ea7fbed22c4968dd64bab0198de60750a25fe8c0c9d4bef5abe13824"}, - {file = "aiohttp-3.8.5-cp36-cp36m-win32.whl", hash = "sha256:6e6783bcc45f397fdebc118d772103d751b54cddf5b60fbcc958382d7dd64f3e"}, - {file = "aiohttp-3.8.5-cp36-cp36m-win_amd64.whl", hash = "sha256:b5411d82cddd212644cf9360879eb5080f0d5f7d809d03262c50dad02f01421a"}, - {file = "aiohttp-3.8.5-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:01d4c0c874aa4ddfb8098e85d10b5e875a70adc63db91f1ae65a4b04d3344cda"}, - {file = "aiohttp-3.8.5-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e5980a746d547a6ba173fd5ee85ce9077e72d118758db05d229044b469d9029a"}, - {file = "aiohttp-3.8.5-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2a482e6da906d5e6e653be079b29bc173a48e381600161c9932d89dfae5942ef"}, - {file = "aiohttp-3.8.5-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:80bd372b8d0715c66c974cf57fe363621a02f359f1ec81cba97366948c7fc873"}, - {file = "aiohttp-3.8.5-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c1161b345c0a444ebcf46bf0a740ba5dcf50612fd3d0528883fdc0eff578006a"}, - {file = "aiohttp-3.8.5-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:cd56db019015b6acfaaf92e1ac40eb8434847d9bf88b4be4efe5bfd260aee692"}, - {file = "aiohttp-3.8.5-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:153c2549f6c004d2754cc60603d4668899c9895b8a89397444a9c4efa282aaf4"}, - {file = "aiohttp-3.8.5-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:4a01951fabc4ce26ab791da5f3f24dca6d9a6f24121746eb19756416ff2d881b"}, - {file = "aiohttp-3.8.5-cp37-cp37m-musllinux_1_1_ppc64le.whl", hash = "sha256:bfb9162dcf01f615462b995a516ba03e769de0789de1cadc0f916265c257e5d8"}, - {file = "aiohttp-3.8.5-cp37-cp37m-musllinux_1_1_s390x.whl", hash = "sha256:7dde0009408969a43b04c16cbbe252c4f5ef4574ac226bc8815cd7342d2028b6"}, - {file = "aiohttp-3.8.5-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:4149d34c32f9638f38f544b3977a4c24052042affa895352d3636fa8bffd030a"}, - {file = "aiohttp-3.8.5-cp37-cp37m-win32.whl", hash = "sha256:68c5a82c8779bdfc6367c967a4a1b2aa52cd3595388bf5961a62158ee8a59e22"}, - {file = "aiohttp-3.8.5-cp37-cp37m-win_amd64.whl", hash = "sha256:2cf57fb50be5f52bda004b8893e63b48530ed9f0d6c96c84620dc92fe3cd9b9d"}, - {file = "aiohttp-3.8.5-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:eca4bf3734c541dc4f374ad6010a68ff6c6748f00451707f39857f429ca36ced"}, - {file = "aiohttp-3.8.5-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:1274477e4c71ce8cfe6c1ec2f806d57c015ebf84d83373676036e256bc55d690"}, - {file = "aiohttp-3.8.5-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:28c543e54710d6158fc6f439296c7865b29e0b616629767e685a7185fab4a6b9"}, - {file = "aiohttp-3.8.5-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:910bec0c49637d213f5d9877105d26e0c4a4de2f8b1b29405ff37e9fc0ad52b8"}, - {file = "aiohttp-3.8.5-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5443910d662db951b2e58eb70b0fbe6b6e2ae613477129a5805d0b66c54b6cb7"}, - {file = "aiohttp-3.8.5-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2e460be6978fc24e3df83193dc0cc4de46c9909ed92dd47d349a452ef49325b7"}, - {file = "aiohttp-3.8.5-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fb1558def481d84f03b45888473fc5a1f35747b5f334ef4e7a571bc0dfcb11f8"}, - {file = "aiohttp-3.8.5-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:34dd0c107799dcbbf7d48b53be761a013c0adf5571bf50c4ecad5643fe9cfcd0"}, - {file = "aiohttp-3.8.5-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:aa1990247f02a54185dc0dff92a6904521172a22664c863a03ff64c42f9b5410"}, - {file = "aiohttp-3.8.5-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:0e584a10f204a617d71d359fe383406305a4b595b333721fa50b867b4a0a1548"}, - {file = "aiohttp-3.8.5-cp38-cp38-musllinux_1_1_ppc64le.whl", hash = "sha256:a3cf433f127efa43fee6b90ea4c6edf6c4a17109d1d037d1a52abec84d8f2e42"}, - {file = "aiohttp-3.8.5-cp38-cp38-musllinux_1_1_s390x.whl", hash = "sha256:c11f5b099adafb18e65c2c997d57108b5bbeaa9eeee64a84302c0978b1ec948b"}, - {file = "aiohttp-3.8.5-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:84de26ddf621d7ac4c975dbea4c945860e08cccde492269db4e1538a6a6f3c35"}, - {file = "aiohttp-3.8.5-cp38-cp38-win32.whl", hash = "sha256:ab88bafedc57dd0aab55fa728ea10c1911f7e4d8b43e1d838a1739f33712921c"}, - {file = "aiohttp-3.8.5-cp38-cp38-win_amd64.whl", hash = "sha256:5798a9aad1879f626589f3df0f8b79b3608a92e9beab10e5fda02c8a2c60db2e"}, - {file = "aiohttp-3.8.5-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:a6ce61195c6a19c785df04e71a4537e29eaa2c50fe745b732aa937c0c77169f3"}, - {file = "aiohttp-3.8.5-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:773dd01706d4db536335fcfae6ea2440a70ceb03dd3e7378f3e815b03c97ab51"}, - {file = "aiohttp-3.8.5-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:f83a552443a526ea38d064588613aca983d0ee0038801bc93c0c916428310c28"}, - {file = "aiohttp-3.8.5-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1f7372f7341fcc16f57b2caded43e81ddd18df53320b6f9f042acad41f8e049a"}, - {file = "aiohttp-3.8.5-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ea353162f249c8097ea63c2169dd1aa55de1e8fecbe63412a9bc50816e87b761"}, - {file = "aiohttp-3.8.5-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e5d47ae48db0b2dcf70bc8a3bc72b3de86e2a590fc299fdbbb15af320d2659de"}, - {file = "aiohttp-3.8.5-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d827176898a2b0b09694fbd1088c7a31836d1a505c243811c87ae53a3f6273c1"}, - {file = "aiohttp-3.8.5-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3562b06567c06439d8b447037bb655ef69786c590b1de86c7ab81efe1c9c15d8"}, - {file = "aiohttp-3.8.5-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:4e874cbf8caf8959d2adf572a78bba17cb0e9d7e51bb83d86a3697b686a0ab4d"}, - {file = "aiohttp-3.8.5-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:6809a00deaf3810e38c628e9a33271892f815b853605a936e2e9e5129762356c"}, - {file = "aiohttp-3.8.5-cp39-cp39-musllinux_1_1_ppc64le.whl", hash = "sha256:33776e945d89b29251b33a7e7d006ce86447b2cfd66db5e5ded4e5cd0340585c"}, - {file = "aiohttp-3.8.5-cp39-cp39-musllinux_1_1_s390x.whl", hash = "sha256:eaeed7abfb5d64c539e2db173f63631455f1196c37d9d8d873fc316470dfbacd"}, - {file = "aiohttp-3.8.5-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:e91d635961bec2d8f19dfeb41a539eb94bd073f075ca6dae6c8dc0ee89ad6f91"}, - {file = "aiohttp-3.8.5-cp39-cp39-win32.whl", hash = "sha256:00ad4b6f185ec67f3e6562e8a1d2b69660be43070bd0ef6fcec5211154c7df67"}, - {file = "aiohttp-3.8.5-cp39-cp39-win_amd64.whl", hash = "sha256:c0a9034379a37ae42dea7ac1e048352d96286626251862e448933c0f59cbd79c"}, - {file = "aiohttp-3.8.5.tar.gz", hash = "sha256:b9552ec52cc147dbf1944ac7ac98af7602e51ea2dcd076ed194ca3c0d1c7d0bc"}, +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"}, ] [package.dependencies] aiosignal = ">=1.1.2" -async-timeout = ">=4.0.0a3,<5.0" -asynctest = {version = "0.13.0", markers = "python_version < \"3.8\""} +async-timeout = {version = ">=4.0,<5.0", markers = "python_version < \"3.11\""} attrs = ">=17.3.0" -charset-normalizer = ">=2.0,<4.0" frozenlist = ">=1.1.1" multidict = ">=4.5,<7.0" -typing-extensions = {version = ">=3.7.4", markers = "python_version < \"3.8\""} yarl = ">=1.0,<2.0" [package.extras] -speedups = ["Brotli", "aiodns", "cchardet"] +speedups = ["Brotli", "aiodns", "brotlicffi"] [[package]] name = "aiosignal" @@ -126,25 +112,24 @@ frozenlist = ">=1.1.0" [[package]] name = "anyio" -version = "3.7.1" +version = "4.1.0" description = "High level compatibility layer for multiple asynchronous event loop implementations" optional = false -python-versions = ">=3.7" +python-versions = ">=3.8" files = [ - {file = "anyio-3.7.1-py3-none-any.whl", hash = "sha256:91dee416e570e92c64041bd18b900d1d6fa78dff7048769ce5ac5ddad004fbb5"}, - {file = "anyio-3.7.1.tar.gz", hash = "sha256:44a3c9aba0f5defa43261a8b3efb97891f2bd7d804e0e1f56419befa1adfc780"}, + {file = "anyio-4.1.0-py3-none-any.whl", hash = "sha256:56a415fbc462291813a94528a779597226619c8e78af7de0507333f700011e5f"}, + {file = "anyio-4.1.0.tar.gz", hash = "sha256:5a0bec7085176715be77df87fc66d6c9d70626bd752fcc85f57cdbee5b3760da"}, ] [package.dependencies] -exceptiongroup = {version = "*", markers = "python_version < \"3.11\""} +exceptiongroup = {version = ">=1.0.2", markers = "python_version < \"3.11\""} idna = ">=2.8" sniffio = ">=1.1" -typing-extensions = {version = "*", markers = "python_version < \"3.8\""} [package.extras] -doc = ["Sphinx", "packaging", "sphinx-autodoc-typehints (>=1.2.0)", "sphinx-rtd-theme (>=1.2.2)", "sphinxcontrib-jquery"] -test = ["anyio[trio]", "coverage[toml] (>=4.5)", "hypothesis (>=4.0)", "mock (>=4)", "psutil (>=5.9)", "pytest (>=7.0)", "pytest-mock (>=3.6.1)", "trustme", "uvloop (>=0.17)"] -trio = ["trio (<0.22)"] +doc = ["Sphinx (>=7)", "packaging", "sphinx-autodoc-typehints (>=1.2.0)", "sphinx-rtd-theme"] +test = ["anyio[trio]", "coverage[toml] (>=7)", "exceptiongroup (>=1.2.0)", "hypothesis (>=4.0)", "psutil (>=5.9)", "pytest (>=7.0)", "pytest-mock (>=3.6.1)", "trustme", "uvloop (>=0.17)"] +trio = ["trio (>=0.23)"] [[package]] name = "async-timeout" @@ -157,20 +142,6 @@ files = [ {file = "async_timeout-4.0.3-py3-none-any.whl", hash = "sha256:7405140ff1230c310e51dc27b3145b9092d659ce68ff733fb0cefe3ee42be028"}, ] -[package.dependencies] -typing-extensions = {version = ">=3.6.5", markers = "python_version < \"3.8\""} - -[[package]] -name = "asynctest" -version = "0.13.0" -description = "Enhance the standard unittest package with features for testing asyncio libraries" -optional = false -python-versions = ">=3.5" -files = [ - {file = "asynctest-0.13.0-py3-none-any.whl", hash = "sha256:5da6118a7e6d6b54d83a8f7197769d046922a44d2a99c21382f0a6e4fadae676"}, - {file = "asynctest-0.13.0.tar.gz", hash = "sha256:c27862842d15d83e6a34eb0b2866c323880eb3a75e4485b079ea11748fd77fac"}, -] - [[package]] name = "attrs" version = "23.1.0" @@ -182,9 +153,6 @@ files = [ {file = "attrs-23.1.0.tar.gz", hash = "sha256:6279836d581513a26f1bf235f9acd333bc9115683f14f7e8fae46c98fc50e015"}, ] -[package.dependencies] -importlib-metadata = {version = "*", markers = "python_version < \"3.8\""} - [package.extras] cov = ["attrs[tests]", "coverage[toml] (>=5.3)"] dev = ["attrs[docs,tests]", "pre-commit"] @@ -194,50 +162,47 @@ tests-no-zope = ["cloudpickle", "hypothesis", "mypy (>=1.1.1)", "pympler", "pyte [[package]] name = "babel" -version = "2.12.1" +version = "2.13.1" description = "Internationalization utilities" optional = false python-versions = ">=3.7" files = [ - {file = "Babel-2.12.1-py3-none-any.whl", hash = "sha256:b4246fb7677d3b98f501a39d43396d3cafdc8eadb045f4a31be01863f655c610"}, - {file = "Babel-2.12.1.tar.gz", hash = "sha256:cc2d99999cd01d44420ae725a21c9e3711b3aadc7976d6147f622d8581963455"}, + {file = "Babel-2.13.1-py3-none-any.whl", hash = "sha256:7077a4984b02b6727ac10f1f7294484f737443d7e2e66c5e4380e41a3ae0b4ed"}, + {file = "Babel-2.13.1.tar.gz", hash = "sha256:33e0952d7dd6374af8dbf6768cc4ddf3ccfefc244f9986d4074704f2fbd18900"}, ] [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.3.0" +version = "23.11.0" description = "The uncompromising code formatter." optional = false -python-versions = ">=3.7" -files = [ - {file = "black-23.3.0-cp310-cp310-macosx_10_16_arm64.whl", hash = "sha256:0945e13506be58bf7db93ee5853243eb368ace1c08a24c65ce108986eac65915"}, - {file = "black-23.3.0-cp310-cp310-macosx_10_16_universal2.whl", hash = "sha256:67de8d0c209eb5b330cce2469503de11bca4085880d62f1628bd9972cc3366b9"}, - {file = "black-23.3.0-cp310-cp310-macosx_10_16_x86_64.whl", hash = "sha256:7c3eb7cea23904399866c55826b31c1f55bbcd3890ce22ff70466b907b6775c2"}, - {file = "black-23.3.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:32daa9783106c28815d05b724238e30718f34155653d4d6e125dc7daec8e260c"}, - {file = "black-23.3.0-cp310-cp310-win_amd64.whl", hash = "sha256:35d1381d7a22cc5b2be2f72c7dfdae4072a3336060635718cc7e1ede24221d6c"}, - {file = "black-23.3.0-cp311-cp311-macosx_10_16_arm64.whl", hash = "sha256:a8a968125d0a6a404842fa1bf0b349a568634f856aa08ffaff40ae0dfa52e7c6"}, - {file = "black-23.3.0-cp311-cp311-macosx_10_16_universal2.whl", hash = "sha256:c7ab5790333c448903c4b721b59c0d80b11fe5e9803d8703e84dcb8da56fec1b"}, - {file = "black-23.3.0-cp311-cp311-macosx_10_16_x86_64.whl", hash = "sha256:a6f6886c9869d4daae2d1715ce34a19bbc4b95006d20ed785ca00fa03cba312d"}, - {file = "black-23.3.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6f3c333ea1dd6771b2d3777482429864f8e258899f6ff05826c3a4fcc5ce3f70"}, - {file = "black-23.3.0-cp311-cp311-win_amd64.whl", hash = "sha256:11c410f71b876f961d1de77b9699ad19f939094c3a677323f43d7a29855fe326"}, - {file = "black-23.3.0-cp37-cp37m-macosx_10_16_x86_64.whl", hash = "sha256:1d06691f1eb8de91cd1b322f21e3bfc9efe0c7ca1f0e1eb1db44ea367dff656b"}, - {file = "black-23.3.0-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:50cb33cac881766a5cd9913e10ff75b1e8eb71babf4c7104f2e9c52da1fb7de2"}, - {file = "black-23.3.0-cp37-cp37m-win_amd64.whl", hash = "sha256:e114420bf26b90d4b9daa597351337762b63039752bdf72bf361364c1aa05925"}, - {file = "black-23.3.0-cp38-cp38-macosx_10_16_arm64.whl", hash = "sha256:48f9d345675bb7fbc3dd85821b12487e1b9a75242028adad0333ce36ed2a6d27"}, - {file = "black-23.3.0-cp38-cp38-macosx_10_16_universal2.whl", hash = "sha256:714290490c18fb0126baa0fca0a54ee795f7502b44177e1ce7624ba1c00f2331"}, - {file = "black-23.3.0-cp38-cp38-macosx_10_16_x86_64.whl", hash = "sha256:064101748afa12ad2291c2b91c960be28b817c0c7eaa35bec09cc63aa56493c5"}, - {file = "black-23.3.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:562bd3a70495facf56814293149e51aa1be9931567474993c7942ff7d3533961"}, - {file = "black-23.3.0-cp38-cp38-win_amd64.whl", hash = "sha256:e198cf27888ad6f4ff331ca1c48ffc038848ea9f031a3b40ba36aced7e22f2c8"}, - {file = "black-23.3.0-cp39-cp39-macosx_10_16_arm64.whl", hash = "sha256:3238f2aacf827d18d26db07524e44741233ae09a584273aa059066d644ca7b30"}, - {file = "black-23.3.0-cp39-cp39-macosx_10_16_universal2.whl", hash = "sha256:f0bd2f4a58d6666500542b26354978218a9babcdc972722f4bf90779524515f3"}, - {file = "black-23.3.0-cp39-cp39-macosx_10_16_x86_64.whl", hash = "sha256:92c543f6854c28a3c7f39f4d9b7694f9a6eb9d3c5e2ece488c327b6e7ea9b266"}, - {file = "black-23.3.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3a150542a204124ed00683f0db1f5cf1c2aaaa9cc3495b7a3b5976fb136090ab"}, - {file = "black-23.3.0-cp39-cp39-win_amd64.whl", hash = "sha256:6b39abdfb402002b8a7d030ccc85cf5afff64ee90fa4c5aebc531e3ad0175ddb"}, - {file = "black-23.3.0-py3-none-any.whl", hash = "sha256:ec751418022185b0c1bb7d7736e6933d40bbb14c14a0abcf9123d1b159f98dd4"}, - {file = "black-23.3.0.tar.gz", hash = "sha256:1c7b8d606e728a41ea1ccbd7264677e494e87cf630e399262ced92d4a8dac940"}, +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"}, ] [package.dependencies] @@ -247,8 +212,7 @@ packaging = ">=22.0" pathspec = ">=0.9.0" platformdirs = ">=2" tomli = {version = ">=1.1.0", markers = "python_version < \"3.11\""} -typed-ast = {version = ">=1.4.2", markers = "python_version < \"3.8\" and implementation_name == \"cpython\""} -typing-extensions = {version = ">=3.10.0.0", markers = "python_version < \"3.10\""} +typing-extensions = {version = ">=4.0.1", markers = "python_version < \"3.11\""} [package.extras] colorama = ["colorama (>=0.4.3)"] @@ -256,121 +220,125 @@ d = ["aiohttp (>=3.7.4)"] jupyter = ["ipython (>=7.8.0)", "tokenize-rt (>=3.2.0)"] uvloop = ["uvloop (>=0.15.2)"] -[[package]] -name = "cached-property" -version = "1.5.2" -description = "A decorator for caching properties in classes." -optional = false -python-versions = "*" -files = [ - {file = "cached-property-1.5.2.tar.gz", hash = "sha256:9fa5755838eecbb2d234c3aa390bd80fbd3ac6b6869109bfc1b499f7bd89a130"}, - {file = "cached_property-1.5.2-py2.py3-none-any.whl", hash = "sha256:df4f613cf7ad9a588cc381aaf4a512d26265ecebd5eb9e1ba12f1319eb85a6a0"}, -] - [[package]] name = "certifi" -version = "2023.7.22" +version = "2023.11.17" description = "Python package for providing Mozilla's CA Bundle." optional = false python-versions = ">=3.6" files = [ - {file = "certifi-2023.7.22-py3-none-any.whl", hash = "sha256:92d6037539857d8206b8f6ae472e8b77db8058fec5937a1ef3f54304089edbb9"}, - {file = "certifi-2023.7.22.tar.gz", hash = "sha256:539cc1d13202e33ca466e88b2807e29f4c13049d6d87031a3c110744495cb082"}, + {file = "certifi-2023.11.17-py3-none-any.whl", hash = "sha256:e036ab49d5b79556f99cfc2d9320b34cfbe5be05c5871b51de9329f0603b0474"}, + {file = "certifi-2023.11.17.tar.gz", hash = "sha256:9b469f3a900bf28dc19b8cfbf8019bf47f7fdd1a65a1d4ffb98fc14166beb4d1"}, ] [[package]] name = "cfgv" -version = "3.3.1" +version = "3.4.0" description = "Validate configuration and produce human readable error messages." optional = false -python-versions = ">=3.6.1" +python-versions = ">=3.8" files = [ - {file = "cfgv-3.3.1-py2.py3-none-any.whl", hash = "sha256:c6a0883f3917a037485059700b9e75da2464e6c27051014ad85ba6aaa5884426"}, - {file = "cfgv-3.3.1.tar.gz", hash = "sha256:f5a830efb9ce7a445376bb66ec94c638a9787422f96264c98edc6bdeed8ab736"}, + {file = "cfgv-3.4.0-py2.py3-none-any.whl", hash = "sha256:b7265b1f29fd3316bfcd2b330d63d024f2bfd8bcb8b0272f8e19a504856c48f9"}, + {file = "cfgv-3.4.0.tar.gz", hash = "sha256:e52591d4c5f5dead8e0f673fb16db7949d2cfb3f7da4582893288f0ded8fe560"}, ] [[package]] name = "charset-normalizer" -version = "3.2.0" +version = "3.3.2" description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet." optional = false python-versions = ">=3.7.0" files = [ - {file = "charset-normalizer-3.2.0.tar.gz", hash = "sha256:3bb3d25a8e6c0aedd251753a79ae98a093c7e7b471faa3aa9a93a81431987ace"}, - {file = "charset_normalizer-3.2.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:0b87549028f680ca955556e3bd57013ab47474c3124dc069faa0b6545b6c9710"}, - {file = "charset_normalizer-3.2.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:7c70087bfee18a42b4040bb9ec1ca15a08242cf5867c58726530bdf3945672ed"}, - {file = "charset_normalizer-3.2.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:a103b3a7069b62f5d4890ae1b8f0597618f628b286b03d4bc9195230b154bfa9"}, - {file = "charset_normalizer-3.2.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:94aea8eff76ee6d1cdacb07dd2123a68283cb5569e0250feab1240058f53b623"}, - {file = "charset_normalizer-3.2.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:db901e2ac34c931d73054d9797383d0f8009991e723dab15109740a63e7f902a"}, - {file = "charset_normalizer-3.2.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b0dac0ff919ba34d4df1b6131f59ce95b08b9065233446be7e459f95554c0dc8"}, - {file = "charset_normalizer-3.2.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:193cbc708ea3aca45e7221ae58f0fd63f933753a9bfb498a3b474878f12caaad"}, - {file = "charset_normalizer-3.2.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:09393e1b2a9461950b1c9a45d5fd251dc7c6f228acab64da1c9c0165d9c7765c"}, - {file = "charset_normalizer-3.2.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:baacc6aee0b2ef6f3d308e197b5d7a81c0e70b06beae1f1fcacffdbd124fe0e3"}, - {file = "charset_normalizer-3.2.0-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:bf420121d4c8dce6b889f0e8e4ec0ca34b7f40186203f06a946fa0276ba54029"}, - {file = "charset_normalizer-3.2.0-cp310-cp310-musllinux_1_1_ppc64le.whl", hash = "sha256:c04a46716adde8d927adb9457bbe39cf473e1e2c2f5d0a16ceb837e5d841ad4f"}, - {file = "charset_normalizer-3.2.0-cp310-cp310-musllinux_1_1_s390x.whl", hash = "sha256:aaf63899c94de41fe3cf934601b0f7ccb6b428c6e4eeb80da72c58eab077b19a"}, - {file = "charset_normalizer-3.2.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:d62e51710986674142526ab9f78663ca2b0726066ae26b78b22e0f5e571238dd"}, - {file = "charset_normalizer-3.2.0-cp310-cp310-win32.whl", hash = "sha256:04e57ab9fbf9607b77f7d057974694b4f6b142da9ed4a199859d9d4d5c63fe96"}, - {file = "charset_normalizer-3.2.0-cp310-cp310-win_amd64.whl", hash = "sha256:48021783bdf96e3d6de03a6e39a1171ed5bd7e8bb93fc84cc649d11490f87cea"}, - {file = "charset_normalizer-3.2.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:4957669ef390f0e6719db3613ab3a7631e68424604a7b448f079bee145da6e09"}, - {file = "charset_normalizer-3.2.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:46fb8c61d794b78ec7134a715a3e564aafc8f6b5e338417cb19fe9f57a5a9bf2"}, - {file = "charset_normalizer-3.2.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:f779d3ad205f108d14e99bb3859aa7dd8e9c68874617c72354d7ecaec2a054ac"}, - {file = "charset_normalizer-3.2.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f25c229a6ba38a35ae6e25ca1264621cc25d4d38dca2942a7fce0b67a4efe918"}, - {file = "charset_normalizer-3.2.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2efb1bd13885392adfda4614c33d3b68dee4921fd0ac1d3988f8cbb7d589e72a"}, - {file = "charset_normalizer-3.2.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1f30b48dd7fa1474554b0b0f3fdfdd4c13b5c737a3c6284d3cdc424ec0ffff3a"}, - {file = "charset_normalizer-3.2.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:246de67b99b6851627d945db38147d1b209a899311b1305dd84916f2b88526c6"}, - {file = "charset_normalizer-3.2.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9bd9b3b31adcb054116447ea22caa61a285d92e94d710aa5ec97992ff5eb7cf3"}, - {file = "charset_normalizer-3.2.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:8c2f5e83493748286002f9369f3e6607c565a6a90425a3a1fef5ae32a36d749d"}, - {file = "charset_normalizer-3.2.0-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:3170c9399da12c9dc66366e9d14da8bf7147e1e9d9ea566067bbce7bb74bd9c2"}, - {file = "charset_normalizer-3.2.0-cp311-cp311-musllinux_1_1_ppc64le.whl", hash = "sha256:7a4826ad2bd6b07ca615c74ab91f32f6c96d08f6fcc3902ceeedaec8cdc3bcd6"}, - {file = "charset_normalizer-3.2.0-cp311-cp311-musllinux_1_1_s390x.whl", hash = "sha256:3b1613dd5aee995ec6d4c69f00378bbd07614702a315a2cf6c1d21461fe17c23"}, - {file = "charset_normalizer-3.2.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:9e608aafdb55eb9f255034709e20d5a83b6d60c054df0802fa9c9883d0a937aa"}, - {file = "charset_normalizer-3.2.0-cp311-cp311-win32.whl", hash = "sha256:f2a1d0fd4242bd8643ce6f98927cf9c04540af6efa92323e9d3124f57727bfc1"}, - {file = "charset_normalizer-3.2.0-cp311-cp311-win_amd64.whl", hash = "sha256:681eb3d7e02e3c3655d1b16059fbfb605ac464c834a0c629048a30fad2b27489"}, - {file = "charset_normalizer-3.2.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:c57921cda3a80d0f2b8aec7e25c8aa14479ea92b5b51b6876d975d925a2ea346"}, - {file = "charset_normalizer-3.2.0-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:41b25eaa7d15909cf3ac4c96088c1f266a9a93ec44f87f1d13d4a0e86c81b982"}, - {file = "charset_normalizer-3.2.0-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f058f6963fd82eb143c692cecdc89e075fa0828db2e5b291070485390b2f1c9c"}, - {file = "charset_normalizer-3.2.0-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a7647ebdfb9682b7bb97e2a5e7cb6ae735b1c25008a70b906aecca294ee96cf4"}, - {file = "charset_normalizer-3.2.0-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eef9df1eefada2c09a5e7a40991b9fc6ac6ef20b1372abd48d2794a316dc0449"}, - {file = "charset_normalizer-3.2.0-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e03b8895a6990c9ab2cdcd0f2fe44088ca1c65ae592b8f795c3294af00a461c3"}, - {file = "charset_normalizer-3.2.0-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:ee4006268ed33370957f55bf2e6f4d263eaf4dc3cfc473d1d90baff6ed36ce4a"}, - {file = "charset_normalizer-3.2.0-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:c4983bf937209c57240cff65906b18bb35e64ae872da6a0db937d7b4af845dd7"}, - {file = "charset_normalizer-3.2.0-cp37-cp37m-musllinux_1_1_ppc64le.whl", hash = "sha256:3bb7fda7260735efe66d5107fb7e6af6a7c04c7fce9b2514e04b7a74b06bf5dd"}, - {file = "charset_normalizer-3.2.0-cp37-cp37m-musllinux_1_1_s390x.whl", hash = "sha256:72814c01533f51d68702802d74f77ea026b5ec52793c791e2da806a3844a46c3"}, - {file = "charset_normalizer-3.2.0-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:70c610f6cbe4b9fce272c407dd9d07e33e6bf7b4aa1b7ffb6f6ded8e634e3592"}, - {file = "charset_normalizer-3.2.0-cp37-cp37m-win32.whl", hash = "sha256:a401b4598e5d3f4a9a811f3daf42ee2291790c7f9d74b18d75d6e21dda98a1a1"}, - {file = "charset_normalizer-3.2.0-cp37-cp37m-win_amd64.whl", hash = "sha256:c0b21078a4b56965e2b12f247467b234734491897e99c1d51cee628da9786959"}, - {file = "charset_normalizer-3.2.0-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:95eb302ff792e12aba9a8b8f8474ab229a83c103d74a750ec0bd1c1eea32e669"}, - {file = "charset_normalizer-3.2.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:1a100c6d595a7f316f1b6f01d20815d916e75ff98c27a01ae817439ea7726329"}, - {file = "charset_normalizer-3.2.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:6339d047dab2780cc6220f46306628e04d9750f02f983ddb37439ca47ced7149"}, - {file = "charset_normalizer-3.2.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e4b749b9cc6ee664a3300bb3a273c1ca8068c46be705b6c31cf5d276f8628a94"}, - {file = "charset_normalizer-3.2.0-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a38856a971c602f98472050165cea2cdc97709240373041b69030be15047691f"}, - {file = "charset_normalizer-3.2.0-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f87f746ee241d30d6ed93969de31e5ffd09a2961a051e60ae6bddde9ec3583aa"}, - {file = "charset_normalizer-3.2.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:89f1b185a01fe560bc8ae5f619e924407efca2191b56ce749ec84982fc59a32a"}, - {file = "charset_normalizer-3.2.0-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e1c8a2f4c69e08e89632defbfabec2feb8a8d99edc9f89ce33c4b9e36ab63037"}, - {file = "charset_normalizer-3.2.0-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:2f4ac36d8e2b4cc1aa71df3dd84ff8efbe3bfb97ac41242fbcfc053c67434f46"}, - {file = "charset_normalizer-3.2.0-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:a386ebe437176aab38c041de1260cd3ea459c6ce5263594399880bbc398225b2"}, - {file = "charset_normalizer-3.2.0-cp38-cp38-musllinux_1_1_ppc64le.whl", hash = "sha256:ccd16eb18a849fd8dcb23e23380e2f0a354e8daa0c984b8a732d9cfaba3a776d"}, - {file = "charset_normalizer-3.2.0-cp38-cp38-musllinux_1_1_s390x.whl", hash = "sha256:e6a5bf2cba5ae1bb80b154ed68a3cfa2fa00fde979a7f50d6598d3e17d9ac20c"}, - {file = "charset_normalizer-3.2.0-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:45de3f87179c1823e6d9e32156fb14c1927fcc9aba21433f088fdfb555b77c10"}, - {file = "charset_normalizer-3.2.0-cp38-cp38-win32.whl", hash = "sha256:1000fba1057b92a65daec275aec30586c3de2401ccdcd41f8a5c1e2c87078706"}, - {file = "charset_normalizer-3.2.0-cp38-cp38-win_amd64.whl", hash = "sha256:8b2c760cfc7042b27ebdb4a43a4453bd829a5742503599144d54a032c5dc7e9e"}, - {file = "charset_normalizer-3.2.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:855eafa5d5a2034b4621c74925d89c5efef61418570e5ef9b37717d9c796419c"}, - {file = "charset_normalizer-3.2.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:203f0c8871d5a7987be20c72442488a0b8cfd0f43b7973771640fc593f56321f"}, - {file = "charset_normalizer-3.2.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:e857a2232ba53ae940d3456f7533ce6ca98b81917d47adc3c7fd55dad8fab858"}, - {file = "charset_normalizer-3.2.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5e86d77b090dbddbe78867a0275cb4df08ea195e660f1f7f13435a4649e954e5"}, - {file = "charset_normalizer-3.2.0-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c4fb39a81950ec280984b3a44f5bd12819953dc5fa3a7e6fa7a80db5ee853952"}, - {file = "charset_normalizer-3.2.0-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2dee8e57f052ef5353cf608e0b4c871aee320dd1b87d351c28764fc0ca55f9f4"}, - {file = "charset_normalizer-3.2.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8700f06d0ce6f128de3ccdbc1acaea1ee264d2caa9ca05daaf492fde7c2a7200"}, - {file = "charset_normalizer-3.2.0-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1920d4ff15ce893210c1f0c0e9d19bfbecb7983c76b33f046c13a8ffbd570252"}, - {file = "charset_normalizer-3.2.0-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:c1c76a1743432b4b60ab3358c937a3fe1341c828ae6194108a94c69028247f22"}, - {file = "charset_normalizer-3.2.0-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:f7560358a6811e52e9c4d142d497f1a6e10103d3a6881f18d04dbce3729c0e2c"}, - {file = "charset_normalizer-3.2.0-cp39-cp39-musllinux_1_1_ppc64le.whl", hash = "sha256:c8063cf17b19661471ecbdb3df1c84f24ad2e389e326ccaf89e3fb2484d8dd7e"}, - {file = "charset_normalizer-3.2.0-cp39-cp39-musllinux_1_1_s390x.whl", hash = "sha256:cd6dbe0238f7743d0efe563ab46294f54f9bc8f4b9bcf57c3c666cc5bc9d1299"}, - {file = "charset_normalizer-3.2.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:1249cbbf3d3b04902ff081ffbb33ce3377fa6e4c7356f759f3cd076cc138d020"}, - {file = "charset_normalizer-3.2.0-cp39-cp39-win32.whl", hash = "sha256:6c409c0deba34f147f77efaa67b8e4bb83d2f11c8806405f76397ae5b8c0d1c9"}, - {file = "charset_normalizer-3.2.0-cp39-cp39-win_amd64.whl", hash = "sha256:7095f6fbfaa55defb6b733cfeb14efaae7a29f0b59d8cf213be4e7ca0b857b80"}, - {file = "charset_normalizer-3.2.0-py3-none-any.whl", hash = "sha256:8e098148dd37b4ce3baca71fb394c81dc5d9c7728c95df695d2dca218edf40e6"}, + {file = "charset-normalizer-3.3.2.tar.gz", hash = "sha256:f30c3cb33b24454a82faecaf01b19c18562b1e89558fb6c56de4d9118a032fd5"}, + {file = "charset_normalizer-3.3.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:25baf083bf6f6b341f4121c2f3c548875ee6f5339300e08be3f2b2ba1721cdd3"}, + {file = "charset_normalizer-3.3.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:06435b539f889b1f6f4ac1758871aae42dc3a8c0e24ac9e60c2384973ad73027"}, + {file = "charset_normalizer-3.3.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:9063e24fdb1e498ab71cb7419e24622516c4a04476b17a2dab57e8baa30d6e03"}, + {file = "charset_normalizer-3.3.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6897af51655e3691ff853668779c7bad41579facacf5fd7253b0133308cf000d"}, + {file = "charset_normalizer-3.3.2-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1d3193f4a680c64b4b6a9115943538edb896edc190f0b222e73761716519268e"}, + {file = "charset_normalizer-3.3.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cd70574b12bb8a4d2aaa0094515df2463cb429d8536cfb6c7ce983246983e5a6"}, + {file = "charset_normalizer-3.3.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8465322196c8b4d7ab6d1e049e4c5cb460d0394da4a27d23cc242fbf0034b6b5"}, + {file = "charset_normalizer-3.3.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a9a8e9031d613fd2009c182b69c7b2c1ef8239a0efb1df3f7c8da66d5dd3d537"}, + {file = "charset_normalizer-3.3.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:beb58fe5cdb101e3a055192ac291b7a21e3b7ef4f67fa1d74e331a7f2124341c"}, + {file = "charset_normalizer-3.3.2-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:e06ed3eb3218bc64786f7db41917d4e686cc4856944f53d5bdf83a6884432e12"}, + {file = "charset_normalizer-3.3.2-cp310-cp310-musllinux_1_1_ppc64le.whl", hash = "sha256:2e81c7b9c8979ce92ed306c249d46894776a909505d8f5a4ba55b14206e3222f"}, + {file = "charset_normalizer-3.3.2-cp310-cp310-musllinux_1_1_s390x.whl", hash = "sha256:572c3763a264ba47b3cf708a44ce965d98555f618ca42c926a9c1616d8f34269"}, + {file = "charset_normalizer-3.3.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:fd1abc0d89e30cc4e02e4064dc67fcc51bd941eb395c502aac3ec19fab46b519"}, + {file = "charset_normalizer-3.3.2-cp310-cp310-win32.whl", hash = "sha256:3d47fa203a7bd9c5b6cee4736ee84ca03b8ef23193c0d1ca99b5089f72645c73"}, + {file = "charset_normalizer-3.3.2-cp310-cp310-win_amd64.whl", hash = "sha256:10955842570876604d404661fbccbc9c7e684caf432c09c715ec38fbae45ae09"}, + {file = "charset_normalizer-3.3.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:802fe99cca7457642125a8a88a084cef28ff0cf9407060f7b93dca5aa25480db"}, + {file = "charset_normalizer-3.3.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:573f6eac48f4769d667c4442081b1794f52919e7edada77495aaed9236d13a96"}, + {file = "charset_normalizer-3.3.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:549a3a73da901d5bc3ce8d24e0600d1fa85524c10287f6004fbab87672bf3e1e"}, + {file = "charset_normalizer-3.3.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f27273b60488abe721a075bcca6d7f3964f9f6f067c8c4c605743023d7d3944f"}, + {file = "charset_normalizer-3.3.2-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1ceae2f17a9c33cb48e3263960dc5fc8005351ee19db217e9b1bb15d28c02574"}, + {file = "charset_normalizer-3.3.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:65f6f63034100ead094b8744b3b97965785388f308a64cf8d7c34f2f2e5be0c4"}, + {file = "charset_normalizer-3.3.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:753f10e867343b4511128c6ed8c82f7bec3bd026875576dfd88483c5c73b2fd8"}, + {file = "charset_normalizer-3.3.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4a78b2b446bd7c934f5dcedc588903fb2f5eec172f3d29e52a9096a43722adfc"}, + {file = "charset_normalizer-3.3.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:e537484df0d8f426ce2afb2d0f8e1c3d0b114b83f8850e5f2fbea0e797bd82ae"}, + {file = "charset_normalizer-3.3.2-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:eb6904c354526e758fda7167b33005998fb68c46fbc10e013ca97f21ca5c8887"}, + {file = "charset_normalizer-3.3.2-cp311-cp311-musllinux_1_1_ppc64le.whl", hash = "sha256:deb6be0ac38ece9ba87dea880e438f25ca3eddfac8b002a2ec3d9183a454e8ae"}, + {file = "charset_normalizer-3.3.2-cp311-cp311-musllinux_1_1_s390x.whl", hash = "sha256:4ab2fe47fae9e0f9dee8c04187ce5d09f48eabe611be8259444906793ab7cbce"}, + {file = "charset_normalizer-3.3.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:80402cd6ee291dcb72644d6eac93785fe2c8b9cb30893c1af5b8fdd753b9d40f"}, + {file = "charset_normalizer-3.3.2-cp311-cp311-win32.whl", hash = "sha256:7cd13a2e3ddeed6913a65e66e94b51d80a041145a026c27e6bb76c31a853c6ab"}, + {file = "charset_normalizer-3.3.2-cp311-cp311-win_amd64.whl", hash = "sha256:663946639d296df6a2bb2aa51b60a2454ca1cb29835324c640dafb5ff2131a77"}, + {file = "charset_normalizer-3.3.2-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:0b2b64d2bb6d3fb9112bafa732def486049e63de9618b5843bcdd081d8144cd8"}, + {file = "charset_normalizer-3.3.2-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:ddbb2551d7e0102e7252db79ba445cdab71b26640817ab1e3e3648dad515003b"}, + {file = "charset_normalizer-3.3.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:55086ee1064215781fff39a1af09518bc9255b50d6333f2e4c74ca09fac6a8f6"}, + {file = "charset_normalizer-3.3.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8f4a014bc36d3c57402e2977dada34f9c12300af536839dc38c0beab8878f38a"}, + {file = "charset_normalizer-3.3.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a10af20b82360ab00827f916a6058451b723b4e65030c5a18577c8b2de5b3389"}, + {file = "charset_normalizer-3.3.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8d756e44e94489e49571086ef83b2bb8ce311e730092d2c34ca8f7d925cb20aa"}, + {file = "charset_normalizer-3.3.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:90d558489962fd4918143277a773316e56c72da56ec7aa3dc3dbbe20fdfed15b"}, + {file = "charset_normalizer-3.3.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6ac7ffc7ad6d040517be39eb591cac5ff87416c2537df6ba3cba3bae290c0fed"}, + {file = "charset_normalizer-3.3.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:7ed9e526742851e8d5cc9e6cf41427dfc6068d4f5a3bb03659444b4cabf6bc26"}, + {file = "charset_normalizer-3.3.2-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:8bdb58ff7ba23002a4c5808d608e4e6c687175724f54a5dade5fa8c67b604e4d"}, + {file = "charset_normalizer-3.3.2-cp312-cp312-musllinux_1_1_ppc64le.whl", hash = "sha256:6b3251890fff30ee142c44144871185dbe13b11bab478a88887a639655be1068"}, + {file = "charset_normalizer-3.3.2-cp312-cp312-musllinux_1_1_s390x.whl", hash = "sha256:b4a23f61ce87adf89be746c8a8974fe1c823c891d8f86eb218bb957c924bb143"}, + {file = "charset_normalizer-3.3.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:efcb3f6676480691518c177e3b465bcddf57cea040302f9f4e6e191af91174d4"}, + {file = "charset_normalizer-3.3.2-cp312-cp312-win32.whl", hash = "sha256:d965bba47ddeec8cd560687584e88cf699fd28f192ceb452d1d7ee807c5597b7"}, + {file = "charset_normalizer-3.3.2-cp312-cp312-win_amd64.whl", hash = "sha256:96b02a3dc4381e5494fad39be677abcb5e6634bf7b4fa83a6dd3112607547001"}, + {file = "charset_normalizer-3.3.2-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:95f2a5796329323b8f0512e09dbb7a1860c46a39da62ecb2324f116fa8fdc85c"}, + {file = "charset_normalizer-3.3.2-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c002b4ffc0be611f0d9da932eb0f704fe2602a9a949d1f738e4c34c75b0863d5"}, + {file = "charset_normalizer-3.3.2-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a981a536974bbc7a512cf44ed14938cf01030a99e9b3a06dd59578882f06f985"}, + {file = "charset_normalizer-3.3.2-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3287761bc4ee9e33561a7e058c72ac0938c4f57fe49a09eae428fd88aafe7bb6"}, + {file = "charset_normalizer-3.3.2-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:42cb296636fcc8b0644486d15c12376cb9fa75443e00fb25de0b8602e64c1714"}, + {file = "charset_normalizer-3.3.2-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0a55554a2fa0d408816b3b5cedf0045f4b8e1a6065aec45849de2d6f3f8e9786"}, + {file = "charset_normalizer-3.3.2-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:c083af607d2515612056a31f0a8d9e0fcb5876b7bfc0abad3ecd275bc4ebc2d5"}, + {file = "charset_normalizer-3.3.2-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:87d1351268731db79e0f8e745d92493ee2841c974128ef629dc518b937d9194c"}, + {file = "charset_normalizer-3.3.2-cp37-cp37m-musllinux_1_1_ppc64le.whl", hash = "sha256:bd8f7df7d12c2db9fab40bdd87a7c09b1530128315d047a086fa3ae3435cb3a8"}, + {file = "charset_normalizer-3.3.2-cp37-cp37m-musllinux_1_1_s390x.whl", hash = "sha256:c180f51afb394e165eafe4ac2936a14bee3eb10debc9d9e4db8958fe36afe711"}, + {file = "charset_normalizer-3.3.2-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:8c622a5fe39a48f78944a87d4fb8a53ee07344641b0562c540d840748571b811"}, + {file = "charset_normalizer-3.3.2-cp37-cp37m-win32.whl", hash = "sha256:db364eca23f876da6f9e16c9da0df51aa4f104a972735574842618b8c6d999d4"}, + {file = "charset_normalizer-3.3.2-cp37-cp37m-win_amd64.whl", hash = "sha256:86216b5cee4b06df986d214f664305142d9c76df9b6512be2738aa72a2048f99"}, + {file = "charset_normalizer-3.3.2-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:6463effa3186ea09411d50efc7d85360b38d5f09b870c48e4600f63af490e56a"}, + {file = "charset_normalizer-3.3.2-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:6c4caeef8fa63d06bd437cd4bdcf3ffefe6738fb1b25951440d80dc7df8c03ac"}, + {file = "charset_normalizer-3.3.2-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:37e55c8e51c236f95b033f6fb391d7d7970ba5fe7ff453dad675e88cf303377a"}, + {file = "charset_normalizer-3.3.2-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fb69256e180cb6c8a894fee62b3afebae785babc1ee98b81cdf68bbca1987f33"}, + {file = "charset_normalizer-3.3.2-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ae5f4161f18c61806f411a13b0310bea87f987c7d2ecdbdaad0e94eb2e404238"}, + {file = "charset_normalizer-3.3.2-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b2b0a0c0517616b6869869f8c581d4eb2dd83a4d79e0ebcb7d373ef9956aeb0a"}, + {file = "charset_normalizer-3.3.2-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:45485e01ff4d3630ec0d9617310448a8702f70e9c01906b0d0118bdf9d124cf2"}, + {file = "charset_normalizer-3.3.2-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:eb00ed941194665c332bf8e078baf037d6c35d7c4f3102ea2d4f16ca94a26dc8"}, + {file = "charset_normalizer-3.3.2-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:2127566c664442652f024c837091890cb1942c30937add288223dc895793f898"}, + {file = "charset_normalizer-3.3.2-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:a50aebfa173e157099939b17f18600f72f84eed3049e743b68ad15bd69b6bf99"}, + {file = "charset_normalizer-3.3.2-cp38-cp38-musllinux_1_1_ppc64le.whl", hash = "sha256:4d0d1650369165a14e14e1e47b372cfcb31d6ab44e6e33cb2d4e57265290044d"}, + {file = "charset_normalizer-3.3.2-cp38-cp38-musllinux_1_1_s390x.whl", hash = "sha256:923c0c831b7cfcb071580d3f46c4baf50f174be571576556269530f4bbd79d04"}, + {file = "charset_normalizer-3.3.2-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:06a81e93cd441c56a9b65d8e1d043daeb97a3d0856d177d5c90ba85acb3db087"}, + {file = "charset_normalizer-3.3.2-cp38-cp38-win32.whl", hash = "sha256:6ef1d82a3af9d3eecdba2321dc1b3c238245d890843e040e41e470ffa64c3e25"}, + {file = "charset_normalizer-3.3.2-cp38-cp38-win_amd64.whl", hash = "sha256:eb8821e09e916165e160797a6c17edda0679379a4be5c716c260e836e122f54b"}, + {file = "charset_normalizer-3.3.2-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:c235ebd9baae02f1b77bcea61bce332cb4331dc3617d254df3323aa01ab47bd4"}, + {file = "charset_normalizer-3.3.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:5b4c145409bef602a690e7cfad0a15a55c13320ff7a3ad7ca59c13bb8ba4d45d"}, + {file = "charset_normalizer-3.3.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:68d1f8a9e9e37c1223b656399be5d6b448dea850bed7d0f87a8311f1ff3dabb0"}, + {file = "charset_normalizer-3.3.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:22afcb9f253dac0696b5a4be4a1c0f8762f8239e21b99680099abd9b2b1b2269"}, + {file = "charset_normalizer-3.3.2-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e27ad930a842b4c5eb8ac0016b0a54f5aebbe679340c26101df33424142c143c"}, + {file = "charset_normalizer-3.3.2-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1f79682fbe303db92bc2b1136016a38a42e835d932bab5b3b1bfcfbf0640e519"}, + {file = "charset_normalizer-3.3.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b261ccdec7821281dade748d088bb6e9b69e6d15b30652b74cbbac25e280b796"}, + {file = "charset_normalizer-3.3.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:122c7fa62b130ed55f8f285bfd56d5f4b4a5b503609d181f9ad85e55c89f4185"}, + {file = "charset_normalizer-3.3.2-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:d0eccceffcb53201b5bfebb52600a5fb483a20b61da9dbc885f8b103cbe7598c"}, + {file = "charset_normalizer-3.3.2-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:9f96df6923e21816da7e0ad3fd47dd8f94b2a5ce594e00677c0013018b813458"}, + {file = "charset_normalizer-3.3.2-cp39-cp39-musllinux_1_1_ppc64le.whl", hash = "sha256:7f04c839ed0b6b98b1a7501a002144b76c18fb1c1850c8b98d458ac269e26ed2"}, + {file = "charset_normalizer-3.3.2-cp39-cp39-musllinux_1_1_s390x.whl", hash = "sha256:34d1c8da1e78d2e001f363791c98a272bb734000fcef47a491c1e3b0505657a8"}, + {file = "charset_normalizer-3.3.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:ff8fa367d09b717b2a17a052544193ad76cd49979c805768879cb63d9ca50561"}, + {file = "charset_normalizer-3.3.2-cp39-cp39-win32.whl", hash = "sha256:aed38f6e4fb3f5d6bf81bfa990a07806be9d83cf7bacef998ab1a9bd660a581f"}, + {file = "charset_normalizer-3.3.2-cp39-cp39-win_amd64.whl", hash = "sha256:b01b88d45a6fcb69667cd6d2f7a9aeb4bf53760d7fc536bf679ec94fe9f3ff3d"}, + {file = "charset_normalizer-3.3.2-py3-none-any.whl", hash = "sha256:3e4d1f6587322d2788836a99c69062fbb091331ec940e02d12d179c1d53e25fc"}, ] [[package]] @@ -386,7 +354,6 @@ files = [ [package.dependencies] colorama = {version = "*", markers = "platform_system == \"Windows\""} -importlib-metadata = {version = "*", markers = "python_version < \"3.8\""} [[package]] name = "colorama" @@ -411,71 +378,63 @@ files = [ [[package]] name = "coverage" -version = "7.2.7" +version = "7.3.2" description = "Code coverage measurement for Python" optional = false -python-versions = ">=3.7" -files = [ - {file = "coverage-7.2.7-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:d39b5b4f2a66ccae8b7263ac3c8170994b65266797fb96cbbfd3fb5b23921db8"}, - {file = "coverage-7.2.7-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:6d040ef7c9859bb11dfeb056ff5b3872436e3b5e401817d87a31e1750b9ae2fb"}, - {file = "coverage-7.2.7-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ba90a9563ba44a72fda2e85302c3abc71c5589cea608ca16c22b9804262aaeb6"}, - {file = "coverage-7.2.7-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e7d9405291c6928619403db1d10bd07888888ec1abcbd9748fdaa971d7d661b2"}, - {file = "coverage-7.2.7-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:31563e97dae5598556600466ad9beea39fb04e0229e61c12eaa206e0aa202063"}, - {file = "coverage-7.2.7-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:ebba1cd308ef115925421d3e6a586e655ca5a77b5bf41e02eb0e4562a111f2d1"}, - {file = "coverage-7.2.7-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:cb017fd1b2603ef59e374ba2063f593abe0fc45f2ad9abdde5b4d83bd922a353"}, - {file = "coverage-7.2.7-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:d62a5c7dad11015c66fbb9d881bc4caa5b12f16292f857842d9d1871595f4495"}, - {file = "coverage-7.2.7-cp310-cp310-win32.whl", hash = "sha256:ee57190f24fba796e36bb6d3aa8a8783c643d8fa9760c89f7a98ab5455fbf818"}, - {file = "coverage-7.2.7-cp310-cp310-win_amd64.whl", hash = "sha256:f75f7168ab25dd93110c8a8117a22450c19976afbc44234cbf71481094c1b850"}, - {file = "coverage-7.2.7-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:06a9a2be0b5b576c3f18f1a241f0473575c4a26021b52b2a85263a00f034d51f"}, - {file = "coverage-7.2.7-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:5baa06420f837184130752b7c5ea0808762083bf3487b5038d68b012e5937dbe"}, - {file = "coverage-7.2.7-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fdec9e8cbf13a5bf63290fc6013d216a4c7232efb51548594ca3631a7f13c3a3"}, - {file = "coverage-7.2.7-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:52edc1a60c0d34afa421c9c37078817b2e67a392cab17d97283b64c5833f427f"}, - {file = "coverage-7.2.7-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:63426706118b7f5cf6bb6c895dc215d8a418d5952544042c8a2d9fe87fcf09cb"}, - {file = "coverage-7.2.7-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:afb17f84d56068a7c29f5fa37bfd38d5aba69e3304af08ee94da8ed5b0865833"}, - {file = "coverage-7.2.7-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:48c19d2159d433ccc99e729ceae7d5293fbffa0bdb94952d3579983d1c8c9d97"}, - {file = "coverage-7.2.7-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:0e1f928eaf5469c11e886fe0885ad2bf1ec606434e79842a879277895a50942a"}, - {file = "coverage-7.2.7-cp311-cp311-win32.whl", hash = "sha256:33d6d3ea29d5b3a1a632b3c4e4f4ecae24ef170b0b9ee493883f2df10039959a"}, - {file = "coverage-7.2.7-cp311-cp311-win_amd64.whl", hash = "sha256:5b7540161790b2f28143191f5f8ec02fb132660ff175b7747b95dcb77ac26562"}, - {file = "coverage-7.2.7-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:f2f67fe12b22cd130d34d0ef79206061bfb5eda52feb6ce0dba0644e20a03cf4"}, - {file = "coverage-7.2.7-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a342242fe22407f3c17f4b499276a02b01e80f861f1682ad1d95b04018e0c0d4"}, - {file = "coverage-7.2.7-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:171717c7cb6b453aebac9a2ef603699da237f341b38eebfee9be75d27dc38e01"}, - {file = "coverage-7.2.7-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:49969a9f7ffa086d973d91cec8d2e31080436ef0fb4a359cae927e742abfaaa6"}, - {file = "coverage-7.2.7-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:b46517c02ccd08092f4fa99f24c3b83d8f92f739b4657b0f146246a0ca6a831d"}, - {file = "coverage-7.2.7-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:a3d33a6b3eae87ceaefa91ffdc130b5e8536182cd6dfdbfc1aa56b46ff8c86de"}, - {file = "coverage-7.2.7-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:976b9c42fb2a43ebf304fa7d4a310e5f16cc99992f33eced91ef6f908bd8f33d"}, - {file = "coverage-7.2.7-cp312-cp312-win32.whl", hash = "sha256:8de8bb0e5ad103888d65abef8bca41ab93721647590a3f740100cd65c3b00511"}, - {file = "coverage-7.2.7-cp312-cp312-win_amd64.whl", hash = "sha256:9e31cb64d7de6b6f09702bb27c02d1904b3aebfca610c12772452c4e6c21a0d3"}, - {file = "coverage-7.2.7-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:58c2ccc2f00ecb51253cbe5d8d7122a34590fac9646a960d1430d5b15321d95f"}, - {file = "coverage-7.2.7-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d22656368f0e6189e24722214ed8d66b8022db19d182927b9a248a2a8a2f67eb"}, - {file = "coverage-7.2.7-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a895fcc7b15c3fc72beb43cdcbdf0ddb7d2ebc959edac9cef390b0d14f39f8a9"}, - {file = "coverage-7.2.7-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e84606b74eb7de6ff581a7915e2dab7a28a0517fbe1c9239eb227e1354064dcd"}, - {file = "coverage-7.2.7-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:0a5f9e1dbd7fbe30196578ca36f3fba75376fb99888c395c5880b355e2875f8a"}, - {file = "coverage-7.2.7-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:419bfd2caae268623dd469eff96d510a920c90928b60f2073d79f8fe2bbc5959"}, - {file = "coverage-7.2.7-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:2aee274c46590717f38ae5e4650988d1af340fe06167546cc32fe2f58ed05b02"}, - {file = "coverage-7.2.7-cp37-cp37m-win32.whl", hash = "sha256:61b9a528fb348373c433e8966535074b802c7a5d7f23c4f421e6c6e2f1697a6f"}, - {file = "coverage-7.2.7-cp37-cp37m-win_amd64.whl", hash = "sha256:b1c546aca0ca4d028901d825015dc8e4d56aac4b541877690eb76490f1dc8ed0"}, - {file = "coverage-7.2.7-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:54b896376ab563bd38453cecb813c295cf347cf5906e8b41d340b0321a5433e5"}, - {file = "coverage-7.2.7-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:3d376df58cc111dc8e21e3b6e24606b5bb5dee6024f46a5abca99124b2229ef5"}, - {file = "coverage-7.2.7-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5e330fc79bd7207e46c7d7fd2bb4af2963f5f635703925543a70b99574b0fea9"}, - {file = "coverage-7.2.7-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1e9d683426464e4a252bf70c3498756055016f99ddaec3774bf368e76bbe02b6"}, - {file = "coverage-7.2.7-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8d13c64ee2d33eccf7437961b6ea7ad8673e2be040b4f7fd4fd4d4d28d9ccb1e"}, - {file = "coverage-7.2.7-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:b7aa5f8a41217360e600da646004f878250a0d6738bcdc11a0a39928d7dc2050"}, - {file = "coverage-7.2.7-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:8fa03bce9bfbeeef9f3b160a8bed39a221d82308b4152b27d82d8daa7041fee5"}, - {file = "coverage-7.2.7-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:245167dd26180ab4c91d5e1496a30be4cd721a5cf2abf52974f965f10f11419f"}, - {file = "coverage-7.2.7-cp38-cp38-win32.whl", hash = "sha256:d2c2db7fd82e9b72937969bceac4d6ca89660db0a0967614ce2481e81a0b771e"}, - {file = "coverage-7.2.7-cp38-cp38-win_amd64.whl", hash = "sha256:2e07b54284e381531c87f785f613b833569c14ecacdcb85d56b25c4622c16c3c"}, - {file = "coverage-7.2.7-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:537891ae8ce59ef63d0123f7ac9e2ae0fc8b72c7ccbe5296fec45fd68967b6c9"}, - {file = "coverage-7.2.7-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:06fb182e69f33f6cd1d39a6c597294cff3143554b64b9825d1dc69d18cc2fff2"}, - {file = "coverage-7.2.7-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:201e7389591af40950a6480bd9edfa8ed04346ff80002cec1a66cac4549c1ad7"}, - {file = "coverage-7.2.7-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f6951407391b639504e3b3be51b7ba5f3528adbf1a8ac3302b687ecababf929e"}, - {file = "coverage-7.2.7-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6f48351d66575f535669306aa7d6d6f71bc43372473b54a832222803eb956fd1"}, - {file = "coverage-7.2.7-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:b29019c76039dc3c0fd815c41392a044ce555d9bcdd38b0fb60fb4cd8e475ba9"}, - {file = "coverage-7.2.7-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:81c13a1fc7468c40f13420732805a4c38a105d89848b7c10af65a90beff25250"}, - {file = "coverage-7.2.7-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:975d70ab7e3c80a3fe86001d8751f6778905ec723f5b110aed1e450da9d4b7f2"}, - {file = "coverage-7.2.7-cp39-cp39-win32.whl", hash = "sha256:7ee7d9d4822c8acc74a5e26c50604dff824710bc8de424904c0982e25c39c6cb"}, - {file = "coverage-7.2.7-cp39-cp39-win_amd64.whl", hash = "sha256:eb393e5ebc85245347950143969b241d08b52b88a3dc39479822e073a1a8eb27"}, - {file = "coverage-7.2.7-pp37.pp38.pp39-none-any.whl", hash = "sha256:b7b4c971f05e6ae490fef852c218b0e79d4e52f79ef0c8475566584a8fb3e01d"}, - {file = "coverage-7.2.7.tar.gz", hash = "sha256:924d94291ca674905fe9481f12294eb11f2d3d3fd1adb20314ba89e94f44ed59"}, +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"}, ] [package.extras] @@ -494,13 +453,13 @@ files = [ [[package]] name = "exceptiongroup" -version = "1.1.3" +version = "1.2.0" description = "Backport of PEP 654 (exception groups)" optional = false python-versions = ">=3.7" files = [ - {file = "exceptiongroup-1.1.3-py3-none-any.whl", hash = "sha256:343280667a4585d195ca1cf9cef84a4e178c4b6cf2274caef9859782b567d5e3"}, - {file = "exceptiongroup-1.1.3.tar.gz", hash = "sha256:097acd85d473d75af5bb98e41b61ff7fe35efe6675e4f9370ec6ec5126d160e9"}, + {file = "exceptiongroup-1.2.0-py3-none-any.whl", hash = "sha256:4bfd3996ac73b41e9b9628b04e079f193850720ea5945fc96a08633c66912f14"}, + {file = "exceptiongroup-1.2.0.tar.gz", hash = "sha256:91f5c769735f051a4290d52edd0858999b57e5876e9f85937691bd4c9fa3ed68"}, ] [package.extras] @@ -508,100 +467,88 @@ test = ["pytest (>=6)"] [[package]] name = "filelock" -version = "3.12.2" +version = "3.13.1" description = "A platform independent file lock." optional = false -python-versions = ">=3.7" +python-versions = ">=3.8" files = [ - {file = "filelock-3.12.2-py3-none-any.whl", hash = "sha256:cbb791cdea2a72f23da6ac5b5269ab0a0d161e9ef0100e653b69049a7706d1ec"}, - {file = "filelock-3.12.2.tar.gz", hash = "sha256:002740518d8aa59a26b0c76e10fb8c6e15eae825d34b6fdf670333fd7b938d81"}, + {file = "filelock-3.13.1-py3-none-any.whl", hash = "sha256:57dbda9b35157b05fb3e58ee91448612eb674172fab98ee235ccb0b5bee19a1c"}, + {file = "filelock-3.13.1.tar.gz", hash = "sha256:521f5f56c50f8426f5e03ad3b281b490a87ef15bc6c526f168290f0c7148d44e"}, ] [package.extras] -docs = ["furo (>=2023.5.20)", "sphinx (>=7.0.1)", "sphinx-autodoc-typehints (>=1.23,!=1.23.4)"] -testing = ["covdefaults (>=2.3)", "coverage (>=7.2.7)", "diff-cover (>=7.5)", "pytest (>=7.3.1)", "pytest-cov (>=4.1)", "pytest-mock (>=3.10)", "pytest-timeout (>=2.1)"] +docs = ["furo (>=2023.9.10)", "sphinx (>=7.2.6)", "sphinx-autodoc-typehints (>=1.24)"] +testing = ["covdefaults (>=2.3)", "coverage (>=7.3.2)", "diff-cover (>=8)", "pytest (>=7.4.3)", "pytest-cov (>=4.1)", "pytest-mock (>=3.12)", "pytest-timeout (>=2.2)"] +typing = ["typing-extensions (>=4.8)"] [[package]] name = "frozenlist" -version = "1.3.3" +version = "1.4.0" description = "A list-like structure which implements collections.abc.MutableSequence" optional = false -python-versions = ">=3.7" -files = [ - {file = "frozenlist-1.3.3-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:ff8bf625fe85e119553b5383ba0fb6aa3d0ec2ae980295aaefa552374926b3f4"}, - {file = "frozenlist-1.3.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:dfbac4c2dfcc082fcf8d942d1e49b6aa0766c19d3358bd86e2000bf0fa4a9cf0"}, - {file = "frozenlist-1.3.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:b1c63e8d377d039ac769cd0926558bb7068a1f7abb0f003e3717ee003ad85530"}, - {file = "frozenlist-1.3.3-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7fdfc24dcfce5b48109867c13b4cb15e4660e7bd7661741a391f821f23dfdca7"}, - {file = "frozenlist-1.3.3-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2c926450857408e42f0bbc295e84395722ce74bae69a3b2aa2a65fe22cb14b99"}, - {file = "frozenlist-1.3.3-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1841e200fdafc3d51f974d9d377c079a0694a8f06de2e67b48150328d66d5483"}, - {file = "frozenlist-1.3.3-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f470c92737afa7d4c3aacc001e335062d582053d4dbe73cda126f2d7031068dd"}, - {file = "frozenlist-1.3.3-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:783263a4eaad7c49983fe4b2e7b53fa9770c136c270d2d4bbb6d2192bf4d9caf"}, - {file = "frozenlist-1.3.3-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:924620eef691990dfb56dc4709f280f40baee568c794b5c1885800c3ecc69816"}, - {file = "frozenlist-1.3.3-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:ae4dc05c465a08a866b7a1baf360747078b362e6a6dbeb0c57f234db0ef88ae0"}, - {file = "frozenlist-1.3.3-cp310-cp310-musllinux_1_1_ppc64le.whl", hash = "sha256:bed331fe18f58d844d39ceb398b77d6ac0b010d571cba8267c2e7165806b00ce"}, - {file = "frozenlist-1.3.3-cp310-cp310-musllinux_1_1_s390x.whl", hash = "sha256:02c9ac843e3390826a265e331105efeab489ffaf4dd86384595ee8ce6d35ae7f"}, - {file = "frozenlist-1.3.3-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:9545a33965d0d377b0bc823dcabf26980e77f1b6a7caa368a365a9497fb09420"}, - {file = "frozenlist-1.3.3-cp310-cp310-win32.whl", hash = "sha256:d5cd3ab21acbdb414bb6c31958d7b06b85eeb40f66463c264a9b343a4e238642"}, - {file = "frozenlist-1.3.3-cp310-cp310-win_amd64.whl", hash = "sha256:b756072364347cb6aa5b60f9bc18e94b2f79632de3b0190253ad770c5df17db1"}, - {file = "frozenlist-1.3.3-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:b4395e2f8d83fbe0c627b2b696acce67868793d7d9750e90e39592b3626691b7"}, - {file = "frozenlist-1.3.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:14143ae966a6229350021384870458e4777d1eae4c28d1a7aa47f24d030e6678"}, - {file = "frozenlist-1.3.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:5d8860749e813a6f65bad8285a0520607c9500caa23fea6ee407e63debcdbef6"}, - {file = "frozenlist-1.3.3-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:23d16d9f477bb55b6154654e0e74557040575d9d19fe78a161bd33d7d76808e8"}, - {file = "frozenlist-1.3.3-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:eb82dbba47a8318e75f679690190c10a5e1f447fbf9df41cbc4c3afd726d88cb"}, - {file = "frozenlist-1.3.3-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9309869032abb23d196cb4e4db574232abe8b8be1339026f489eeb34a4acfd91"}, - {file = "frozenlist-1.3.3-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a97b4fe50b5890d36300820abd305694cb865ddb7885049587a5678215782a6b"}, - {file = "frozenlist-1.3.3-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c188512b43542b1e91cadc3c6c915a82a5eb95929134faf7fd109f14f9892ce4"}, - {file = "frozenlist-1.3.3-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:303e04d422e9b911a09ad499b0368dc551e8c3cd15293c99160c7f1f07b59a48"}, - {file = "frozenlist-1.3.3-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:0771aed7f596c7d73444c847a1c16288937ef988dc04fb9f7be4b2aa91db609d"}, - {file = "frozenlist-1.3.3-cp311-cp311-musllinux_1_1_ppc64le.whl", hash = "sha256:66080ec69883597e4d026f2f71a231a1ee9887835902dbe6b6467d5a89216cf6"}, - {file = "frozenlist-1.3.3-cp311-cp311-musllinux_1_1_s390x.whl", hash = "sha256:41fe21dc74ad3a779c3d73a2786bdf622ea81234bdd4faf90b8b03cad0c2c0b4"}, - {file = "frozenlist-1.3.3-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:f20380df709d91525e4bee04746ba612a4df0972c1b8f8e1e8af997e678c7b81"}, - {file = "frozenlist-1.3.3-cp311-cp311-win32.whl", hash = "sha256:f30f1928162e189091cf4d9da2eac617bfe78ef907a761614ff577ef4edfb3c8"}, - {file = "frozenlist-1.3.3-cp311-cp311-win_amd64.whl", hash = "sha256:a6394d7dadd3cfe3f4b3b186e54d5d8504d44f2d58dcc89d693698e8b7132b32"}, - {file = "frozenlist-1.3.3-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:8df3de3a9ab8325f94f646609a66cbeeede263910c5c0de0101079ad541af332"}, - {file = "frozenlist-1.3.3-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0693c609e9742c66ba4870bcee1ad5ff35462d5ffec18710b4ac89337ff16e27"}, - {file = "frozenlist-1.3.3-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:cd4210baef299717db0a600d7a3cac81d46ef0e007f88c9335db79f8979c0d3d"}, - {file = "frozenlist-1.3.3-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:394c9c242113bfb4b9aa36e2b80a05ffa163a30691c7b5a29eba82e937895d5e"}, - {file = "frozenlist-1.3.3-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6327eb8e419f7d9c38f333cde41b9ae348bec26d840927332f17e887a8dcb70d"}, - {file = "frozenlist-1.3.3-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2e24900aa13212e75e5b366cb9065e78bbf3893d4baab6052d1aca10d46d944c"}, - {file = "frozenlist-1.3.3-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:3843f84a6c465a36559161e6c59dce2f2ac10943040c2fd021cfb70d58c4ad56"}, - {file = "frozenlist-1.3.3-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:84610c1502b2461255b4c9b7d5e9c48052601a8957cd0aea6ec7a7a1e1fb9420"}, - {file = "frozenlist-1.3.3-cp37-cp37m-musllinux_1_1_ppc64le.whl", hash = "sha256:c21b9aa40e08e4f63a2f92ff3748e6b6c84d717d033c7b3438dd3123ee18f70e"}, - {file = "frozenlist-1.3.3-cp37-cp37m-musllinux_1_1_s390x.whl", hash = "sha256:efce6ae830831ab6a22b9b4091d411698145cb9b8fc869e1397ccf4b4b6455cb"}, - {file = "frozenlist-1.3.3-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:40de71985e9042ca00b7953c4f41eabc3dc514a2d1ff534027f091bc74416401"}, - {file = "frozenlist-1.3.3-cp37-cp37m-win32.whl", hash = "sha256:180c00c66bde6146a860cbb81b54ee0df350d2daf13ca85b275123bbf85de18a"}, - {file = "frozenlist-1.3.3-cp37-cp37m-win_amd64.whl", hash = "sha256:9bbbcedd75acdfecf2159663b87f1bb5cfc80e7cd99f7ddd9d66eb98b14a8411"}, - {file = "frozenlist-1.3.3-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:034a5c08d36649591be1cbb10e09da9f531034acfe29275fc5454a3b101ce41a"}, - {file = "frozenlist-1.3.3-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:ba64dc2b3b7b158c6660d49cdb1d872d1d0bf4e42043ad8d5006099479a194e5"}, - {file = "frozenlist-1.3.3-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:47df36a9fe24054b950bbc2db630d508cca3aa27ed0566c0baf661225e52c18e"}, - {file = "frozenlist-1.3.3-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:008a054b75d77c995ea26629ab3a0c0d7281341f2fa7e1e85fa6153ae29ae99c"}, - {file = "frozenlist-1.3.3-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:841ea19b43d438a80b4de62ac6ab21cfe6827bb8a9dc62b896acc88eaf9cecba"}, - {file = "frozenlist-1.3.3-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e235688f42b36be2b6b06fc37ac2126a73b75fb8d6bc66dd632aa35286238703"}, - {file = "frozenlist-1.3.3-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ca713d4af15bae6e5d79b15c10c8522859a9a89d3b361a50b817c98c2fb402a2"}, - {file = "frozenlist-1.3.3-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9ac5995f2b408017b0be26d4a1d7c61bce106ff3d9e3324374d66b5964325448"}, - {file = "frozenlist-1.3.3-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:a4ae8135b11652b08a8baf07631d3ebfe65a4c87909dbef5fa0cdde440444ee4"}, - {file = "frozenlist-1.3.3-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:4ea42116ceb6bb16dbb7d526e242cb6747b08b7710d9782aa3d6732bd8d27649"}, - {file = "frozenlist-1.3.3-cp38-cp38-musllinux_1_1_ppc64le.whl", hash = "sha256:810860bb4bdce7557bc0febb84bbd88198b9dbc2022d8eebe5b3590b2ad6c842"}, - {file = "frozenlist-1.3.3-cp38-cp38-musllinux_1_1_s390x.whl", hash = "sha256:ee78feb9d293c323b59a6f2dd441b63339a30edf35abcb51187d2fc26e696d13"}, - {file = "frozenlist-1.3.3-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:0af2e7c87d35b38732e810befb9d797a99279cbb85374d42ea61c1e9d23094b3"}, - {file = "frozenlist-1.3.3-cp38-cp38-win32.whl", hash = "sha256:899c5e1928eec13fd6f6d8dc51be23f0d09c5281e40d9cf4273d188d9feeaf9b"}, - {file = "frozenlist-1.3.3-cp38-cp38-win_amd64.whl", hash = "sha256:7f44e24fa70f6fbc74aeec3e971f60a14dde85da364aa87f15d1be94ae75aeef"}, - {file = "frozenlist-1.3.3-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:2b07ae0c1edaa0a36339ec6cce700f51b14a3fc6545fdd32930d2c83917332cf"}, - {file = "frozenlist-1.3.3-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:ebb86518203e12e96af765ee89034a1dbb0c3c65052d1b0c19bbbd6af8a145e1"}, - {file = "frozenlist-1.3.3-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:5cf820485f1b4c91e0417ea0afd41ce5cf5965011b3c22c400f6d144296ccbc0"}, - {file = "frozenlist-1.3.3-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5c11e43016b9024240212d2a65043b70ed8dfd3b52678a1271972702d990ac6d"}, - {file = "frozenlist-1.3.3-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8fa3c6e3305aa1146b59a09b32b2e04074945ffcfb2f0931836d103a2c38f936"}, - {file = "frozenlist-1.3.3-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:352bd4c8c72d508778cf05ab491f6ef36149f4d0cb3c56b1b4302852255d05d5"}, - {file = "frozenlist-1.3.3-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:65a5e4d3aa679610ac6e3569e865425b23b372277f89b5ef06cf2cdaf1ebf22b"}, - {file = "frozenlist-1.3.3-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b1e2c1185858d7e10ff045c496bbf90ae752c28b365fef2c09cf0fa309291669"}, - {file = "frozenlist-1.3.3-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:f163d2fd041c630fed01bc48d28c3ed4a3b003c00acd396900e11ee5316b56bb"}, - {file = "frozenlist-1.3.3-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:05cdb16d09a0832eedf770cb7bd1fe57d8cf4eaf5aced29c4e41e3f20b30a784"}, - {file = "frozenlist-1.3.3-cp39-cp39-musllinux_1_1_ppc64le.whl", hash = "sha256:8bae29d60768bfa8fb92244b74502b18fae55a80eac13c88eb0b496d4268fd2d"}, - {file = "frozenlist-1.3.3-cp39-cp39-musllinux_1_1_s390x.whl", hash = "sha256:eedab4c310c0299961ac285591acd53dc6723a1ebd90a57207c71f6e0c2153ab"}, - {file = "frozenlist-1.3.3-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:3bbdf44855ed8f0fbcd102ef05ec3012d6a4fd7c7562403f76ce6a52aeffb2b1"}, - {file = "frozenlist-1.3.3-cp39-cp39-win32.whl", hash = "sha256:efa568b885bca461f7c7b9e032655c0c143d305bf01c30caf6db2854a4532b38"}, - {file = "frozenlist-1.3.3-cp39-cp39-win_amd64.whl", hash = "sha256:cfe33efc9cb900a4c46f91a5ceba26d6df370ffddd9ca386eb1d4f0ad97b9ea9"}, - {file = "frozenlist-1.3.3.tar.gz", hash = "sha256:58bcc55721e8a90b88332d6cd441261ebb22342e238296bb330968952fbb3a6a"}, +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"}, ] [[package]] @@ -623,13 +570,13 @@ dev = ["flake8", "markdown", "twine", "wheel"] [[package]] name = "gitdb" -version = "4.0.10" +version = "4.0.11" description = "Git Object Database" optional = false python-versions = ">=3.7" files = [ - {file = "gitdb-4.0.10-py3-none-any.whl", hash = "sha256:c286cf298426064079ed96a9e4a9d39e7f3e9bf15ba60701e95f5492f28415c7"}, - {file = "gitdb-4.0.10.tar.gz", hash = "sha256:6eb990b69df4e15bad899ea868dc46572c3f75339735663b81de79b06f17eb9a"}, + {file = "gitdb-4.0.11-py3-none-any.whl", hash = "sha256:81a3407ddd2ee8df444cbacea00e2d038e40150acfa3001696fe0dcf1d3adfa4"}, + {file = "gitdb-4.0.11.tar.gz", hash = "sha256:bf5421126136d6d0af55bc1e7c1af1c397a34f5b7bd79e776cd3e89785c2b04b"}, ] [package.dependencies] @@ -637,32 +584,33 @@ smmap = ">=3.0.1,<6" [[package]] name = "gitpython" -version = "3.1.34" +version = "3.1.40" description = "GitPython is a Python library used to interact with Git repositories" optional = false python-versions = ">=3.7" files = [ - {file = "GitPython-3.1.34-py3-none-any.whl", hash = "sha256:5d3802b98a3bae1c2b8ae0e1ff2e4aa16bcdf02c145da34d092324f599f01395"}, - {file = "GitPython-3.1.34.tar.gz", hash = "sha256:85f7d365d1f6bf677ae51039c1ef67ca59091c7ebd5a3509aa399d4eda02d6dd"}, + {file = "GitPython-3.1.40-py3-none-any.whl", hash = "sha256:cf14627d5a8049ffbf49915732e5eddbe8134c3bdb9d476e6182b676fc573f8a"}, + {file = "GitPython-3.1.40.tar.gz", hash = "sha256:22b126e9ffb671fdd0c129796343a02bf67bf2994b35449ffc9321aa755e18a4"}, ] [package.dependencies] gitdb = ">=4.0.1,<5" -typing-extensions = {version = ">=3.7.4.3", markers = "python_version < \"3.8\""} + +[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"] [[package]] name = "griffe" -version = "0.30.1" +version = "0.32.3" description = "Signatures for entire Python programs. Extract the structure, the frame, the skeleton of your project, to generate API documentation or find breaking changes in your API." optional = false -python-versions = ">=3.7" +python-versions = ">=3.8" files = [ - {file = "griffe-0.30.1-py3-none-any.whl", hash = "sha256:b2f3df6952995a6bebe19f797189d67aba7c860755d3d21cc80f64d076d0154c"}, - {file = "griffe-0.30.1.tar.gz", hash = "sha256:007cc11acd20becf1bb8f826419a52b9d403bbad9d8c8535699f5440ddc0a109"}, + {file = "griffe-0.32.3-py3-none-any.whl", hash = "sha256:d9471934225818bf8f309822f70451cc6abb4b24e59e0bb27402a45f9412510f"}, + {file = "griffe-0.32.3.tar.gz", hash = "sha256:14983896ad581f59d5ad7b6c9261ff12bdaa905acccc1129341d13e545da8521"}, ] [package.dependencies] -cached-property = {version = "*", markers = "python_version < \"3.8\""} colorama = ">=0.4" [[package]] @@ -676,9 +624,6 @@ files = [ {file = "h11-0.14.0.tar.gz", hash = "sha256:8f19fbbe99e72420ff35c00b27a34cb9937e902a8b810e2c88300c6f0a3b699d"}, ] -[package.dependencies] -typing-extensions = {version = "*", markers = "python_version < \"3.8\""} - [[package]] name = "httpcore" version = "0.16.3" @@ -725,13 +670,13 @@ socks = ["socksio (==1.*)"] [[package]] name = "identify" -version = "2.5.24" +version = "2.5.33" description = "File identification library for Python" optional = false -python-versions = ">=3.7" +python-versions = ">=3.8" files = [ - {file = "identify-2.5.24-py2.py3-none-any.whl", hash = "sha256:986dbfb38b1140e763e413e6feb44cd731faf72d1909543178aa79b0e258265d"}, - {file = "identify-2.5.24.tar.gz", hash = "sha256:0aac67d5b4812498056d28a9a512a483f5085cc28640b02b258a59dac34301d4"}, + {file = "identify-2.5.33-py2.py3-none-any.whl", hash = "sha256:d40ce5fcd762817627670da8a7d8d8e65f24342d14539c59488dc603bf662e34"}, + {file = "identify-2.5.33.tar.gz", hash = "sha256:161558f9fe4559e1557e1bff323e8631f6a0e4837f7497767c1782832f16b62d"}, ] [package.extras] @@ -739,34 +684,33 @@ license = ["ukkonen"] [[package]] name = "idna" -version = "3.4" +version = "3.6" description = "Internationalized Domain Names in Applications (IDNA)" optional = false python-versions = ">=3.5" files = [ - {file = "idna-3.4-py3-none-any.whl", hash = "sha256:90b77e79eaa3eba6de819a0c442c0b4ceefc341a7a2ab77d7562bf49f425c5c2"}, - {file = "idna-3.4.tar.gz", hash = "sha256:814f528e8dead7d329833b91c5faa87d60bf71824cd12a7530b5526063d02cb4"}, + {file = "idna-3.6-py3-none-any.whl", hash = "sha256:c05567e9c24a6b9faaa835c4821bad0590fbb9d5779e7caa6e1cc4978e7eb24f"}, + {file = "idna-3.6.tar.gz", hash = "sha256:9ecdbbd083b06798ae1e86adcbfe8ab1479cf864e4ee30fe4e46a003d12491ca"}, ] [[package]] name = "importlib-metadata" -version = "6.7.0" +version = "7.0.0" description = "Read metadata from Python packages" optional = false -python-versions = ">=3.7" +python-versions = ">=3.8" files = [ - {file = "importlib_metadata-6.7.0-py3-none-any.whl", hash = "sha256:cb52082e659e97afc5dac71e79de97d8681de3aa07ff18578330904a9d18e5b5"}, - {file = "importlib_metadata-6.7.0.tar.gz", hash = "sha256:1aaf550d4f73e5d6783e7acb77aec43d49da8017410afae93822cc9cca98c4d4"}, + {file = "importlib_metadata-7.0.0-py3-none-any.whl", hash = "sha256:d97503976bb81f40a193d41ee6570868479c69d5068651eb039c40d850c59d67"}, + {file = "importlib_metadata-7.0.0.tar.gz", hash = "sha256:7fc841f8b8332803464e5dc1c63a2e59121f46ca186c0e2e182e80bf8c1319f7"}, ] [package.dependencies] -typing-extensions = {version = ">=3.6.4", markers = "python_version < \"3.8\""} zipp = ">=0.5" [package.extras] -docs = ["furo", "jaraco.packaging (>=9)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"] +docs = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (<7.2.5)", "sphinx (>=3.5)", "sphinx-lint"] perf = ["ipython"] -testing = ["flufl.flake8", "importlib-resources (>=1.3)", "packaging", "pyfakefs", "pytest (>=6)", "pytest-black (>=0.3.7)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=1.3)", "pytest-mypy (>=0.9.1)", "pytest-perf (>=0.9.2)", "pytest-ruff"] +testing = ["flufl.flake8", "importlib-resources (>=1.3)", "packaging", "pyfakefs", "pytest (>=6)", "pytest-black (>=0.3.7)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=2.2)", "pytest-mypy (>=0.9.1)", "pytest-perf (>=0.9.2)", "pytest-ruff"] [[package]] name = "iniconfig" @@ -818,38 +762,37 @@ test = ["coverage", "pytest", "pytest-cov"] [[package]] name = "markdown" -version = "3.4.4" +version = "3.5.1" description = "Python implementation of John Gruber's Markdown." optional = false -python-versions = ">=3.7" +python-versions = ">=3.8" files = [ - {file = "Markdown-3.4.4-py3-none-any.whl", hash = "sha256:a4c1b65c0957b4bd9e7d86ddc7b3c9868fb9670660f6f99f6d1bca8954d5a941"}, - {file = "Markdown-3.4.4.tar.gz", hash = "sha256:225c6123522495d4119a90b3a3ba31a1e87a70369e03f14799ea9c0d7183a3d6"}, + {file = "Markdown-3.5.1-py3-none-any.whl", hash = "sha256:5874b47d4ee3f0b14d764324d2c94c03ea66bee56f2d929da9f2508d65e722dc"}, + {file = "Markdown-3.5.1.tar.gz", hash = "sha256:b65d7beb248dc22f2e8a31fb706d93798093c308dc1aba295aedeb9d41a813bd"}, ] [package.dependencies] importlib-metadata = {version = ">=4.4", markers = "python_version < \"3.10\""} [package.extras] -docs = ["mdx-gh-links (>=0.2)", "mkdocs (>=1.0)", "mkdocs-nature (>=0.4)"] +docs = ["mdx-gh-links (>=0.2)", "mkdocs (>=1.5)", "mkdocs-gen-files", "mkdocs-literate-nav", "mkdocs-nature (>=0.6)", "mkdocs-section-index", "mkdocstrings[python]"] testing = ["coverage", "pyyaml"] [[package]] name = "markdown-it-py" -version = "2.2.0" +version = "3.0.0" description = "Python port of markdown-it. Markdown parsing, done right!" optional = false -python-versions = ">=3.7" +python-versions = ">=3.8" files = [ - {file = "markdown-it-py-2.2.0.tar.gz", hash = "sha256:7c9a5e412688bc771c67432cbfebcdd686c93ce6484913dccf06cb5a0bea35a1"}, - {file = "markdown_it_py-2.2.0-py3-none-any.whl", hash = "sha256:5a35f8d1870171d9acc47b99612dc146129b631baf04970128b568f190d0cc30"}, + {file = "markdown-it-py-3.0.0.tar.gz", hash = "sha256:e3f60a94fa066dc52ec76661e37c851cb232d92f9886b15cb560aaada2df8feb"}, + {file = "markdown_it_py-3.0.0-py3-none-any.whl", hash = "sha256:355216845c60bd96232cd8d8c40e8f9765cc86f46880e43a8fd22dc1a1a8cab1"}, ] [package.dependencies] linkify-it-py = {version = ">=1,<3", optional = true, markers = "extra == \"linkify\""} mdit-py-plugins = {version = "*", optional = true, markers = "extra == \"plugins\""} mdurl = ">=0.1,<1.0" -typing_extensions = {version = ">=3.7.4", markers = "python_version < \"3.8\""} [package.extras] benchmarking = ["psutil", "pytest", "pytest-benchmark"] @@ -858,7 +801,7 @@ compare = ["commonmark (>=0.9,<1.0)", "markdown (>=3.4,<4.0)", "mistletoe (>=1.0 linkify = ["linkify-it-py (>=1,<3)"] plugins = ["mdit-py-plugins"] profiling = ["gprof2dot"] -rtd = ["attrs", "myst-parser", "pyyaml", "sphinx", "sphinx-copybutton", "sphinx-design", "sphinx_book_theme"] +rtd = ["jupyter_sphinx", "mdit-py-plugins", "myst-parser", "pyyaml", "sphinx", "sphinx-copybutton", "sphinx-design", "sphinx_book_theme"] testing = ["coverage", "pytest", "pytest-cov", "pytest-regressions"] [[package]] @@ -888,6 +831,16 @@ files = [ {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"}, @@ -922,21 +875,21 @@ files = [ [[package]] name = "mdit-py-plugins" -version = "0.3.5" +version = "0.4.0" description = "Collection of plugins for markdown-it-py" optional = false -python-versions = ">=3.7" +python-versions = ">=3.8" files = [ - {file = "mdit-py-plugins-0.3.5.tar.gz", hash = "sha256:eee0adc7195e5827e17e02d2a258a2ba159944a0748f59c5099a4a27f78fcf6a"}, - {file = "mdit_py_plugins-0.3.5-py3-none-any.whl", hash = "sha256:ca9a0714ea59a24b2b044a1831f48d817dd0c817e84339f20e7889f392d77c4e"}, + {file = "mdit_py_plugins-0.4.0-py3-none-any.whl", hash = "sha256:b51b3bb70691f57f974e257e367107857a93b36f322a9e6d44ca5bf28ec2def9"}, + {file = "mdit_py_plugins-0.4.0.tar.gz", hash = "sha256:d8ab27e9aed6c38aa716819fedfde15ca275715955f8a185a8e1cf90fb1d2c1b"}, ] [package.dependencies] -markdown-it-py = ">=1.0.0,<3.0.0" +markdown-it-py = ">=1.0.0,<4.0.0" [package.extras] code-style = ["pre-commit"] -rtd = ["attrs", "myst-parser (>=0.16.1,<0.17.0)", "sphinx-book-theme (>=0.1.0,<0.2.0)"] +rtd = ["myst-parser", "sphinx-book-theme"] testing = ["coverage", "pytest", "pytest-cov", "pytest-regressions"] [[package]] @@ -963,13 +916,13 @@ files = [ [[package]] name = "mkdocs" -version = "1.5.2" +version = "1.5.3" description = "Project documentation with Markdown." optional = false python-versions = ">=3.7" files = [ - {file = "mkdocs-1.5.2-py3-none-any.whl", hash = "sha256:60a62538519c2e96fe8426654a67ee177350451616118a41596ae7c876bb7eac"}, - {file = "mkdocs-1.5.2.tar.gz", hash = "sha256:70d0da09c26cff288852471be03c23f0f521fc15cf16ac89c7a3bfb9ae8d24f9"}, + {file = "mkdocs-1.5.3-py3-none-any.whl", hash = "sha256:3b3a78e736b31158d64dbb2f8ba29bd46a379d0c6e324c2246c3bc3d2189cfc1"}, + {file = "mkdocs-1.5.3.tar.gz", hash = "sha256:eb7c99214dcb945313ba30426c2451b735992c73c2e10838f76d09e39ff4d0e2"}, ] [package.dependencies] @@ -986,7 +939,6 @@ pathspec = ">=0.11.1" platformdirs = ">=2.2.0" pyyaml = ">=5.1" pyyaml-env-tag = ">=0.1" -typing-extensions = {version = ">=3.10", markers = "python_version < \"3.8\""} watchdog = ">=2.0" [package.extras] @@ -995,13 +947,13 @@ min-versions = ["babel (==2.9.0)", "click (==7.0)", "colorama (==0.4)", "ghp-imp [[package]] name = "mkdocs-autorefs" -version = "0.4.1" +version = "0.5.0" description = "Automatically link across pages in MkDocs." optional = false -python-versions = ">=3.7" +python-versions = ">=3.8" files = [ - {file = "mkdocs-autorefs-0.4.1.tar.gz", hash = "sha256:70748a7bd025f9ecd6d6feeba8ba63f8e891a1af55f48e366d6d6e78493aba84"}, - {file = "mkdocs_autorefs-0.4.1-py3-none-any.whl", hash = "sha256:a2248a9501b29dc0cc8ba4c09f4f47ff121945f6ce33d760f145d6f89d313f5b"}, + {file = "mkdocs_autorefs-0.5.0-py3-none-any.whl", hash = "sha256:7930fcb8ac1249f10e683967aeaddc0af49d90702af111a5e390e8b20b3d97ff"}, + {file = "mkdocs_autorefs-0.5.0.tar.gz", hash = "sha256:9a5054a94c08d28855cfab967ada10ed5be76e2bfad642302a610b252c3274c0"}, ] [package.dependencies] @@ -1023,13 +975,13 @@ mkdocs = "*" [[package]] name = "mkdocs-material" -version = "9.2.7" +version = "9.5.1" description = "Documentation that simply works" optional = false -python-versions = ">=3.7" +python-versions = ">=3.8" files = [ - {file = "mkdocs_material-9.2.7-py3-none-any.whl", hash = "sha256:92e4160d191cc76121fed14ab9f14638e43a6da0f2e9d7a9194d377f0a4e7f18"}, - {file = "mkdocs_material-9.2.7.tar.gz", hash = "sha256:b44da35b0d98cd762d09ef74f1ddce5b6d6e35c13f13beb0c9d82a629e5f229e"}, + {file = "mkdocs_material-9.5.1-py3-none-any.whl", hash = "sha256:2e01249bc41813afe2479a4a659f8ba899c3355ccaf9310b5b782952df9c1dea"}, + {file = "mkdocs_material-9.5.1.tar.gz", hash = "sha256:7ec5d20ed5eee97bb090823a33b33e177ad0704d74bad5937b53acca571ddb3d"}, ] [package.dependencies] @@ -1037,45 +989,51 @@ babel = ">=2.10,<3.0" colorama = ">=0.4,<1.0" jinja2 = ">=3.0,<4.0" markdown = ">=3.2,<4.0" -mkdocs = ">=1.5,<2.0" -mkdocs-material-extensions = ">=1.1,<2.0" +mkdocs = ">=1.5.3,<2.0" +mkdocs-material-extensions = ">=1.3,<2.0" paginate = ">=0.5,<1.0" pygments = ">=2.16,<3.0" pymdown-extensions = ">=10.2,<11.0" -regex = ">=2022.4,<2023.0" +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)"] +recommended = ["mkdocs-minify-plugin (>=0.7,<1.0)", "mkdocs-redirects (>=1.2,<2.0)", "mkdocs-rss-plugin (>=1.6,<2.0)"] + [[package]] name = "mkdocs-material-extensions" -version = "1.1.1" +version = "1.3.1" description = "Extension pack for Python Markdown and MkDocs Material." optional = false -python-versions = ">=3.7" +python-versions = ">=3.8" files = [ - {file = "mkdocs_material_extensions-1.1.1-py3-none-any.whl", hash = "sha256:e41d9f38e4798b6617ad98ca8f7f1157b1e4385ac1459ca1e4ea219b556df945"}, - {file = "mkdocs_material_extensions-1.1.1.tar.gz", hash = "sha256:9c003da71e2cc2493d910237448c672e00cefc800d3d6ae93d2fc69979e3bd93"}, + {file = "mkdocs_material_extensions-1.3.1-py3-none-any.whl", hash = "sha256:adff8b62700b25cb77b53358dad940f3ef973dd6db797907c49e3c2ef3ab4e31"}, + {file = "mkdocs_material_extensions-1.3.1.tar.gz", hash = "sha256:10c9511cea88f568257f960358a467d12b970e1f7b2c0e5fb2bb48cab1928443"}, ] [[package]] name = "mkdocs-rss-plugin" -version = "1.5.0" +version = "1.9.0" description = "MkDocs plugin which generates a static RSS feed using git log and page.meta." optional = false -python-versions = ">=3.7, <4" +python-versions = ">=3.8, <4" files = [ - {file = "mkdocs-rss-plugin-1.5.0.tar.gz", hash = "sha256:4178b3830dcbad9b53b12459e315b1aad6b37d1e7e5c56c686866a10f99878a4"}, - {file = "mkdocs_rss_plugin-1.5.0-py2.py3-none-any.whl", hash = "sha256:2ab14c20bf6b7983acbe50181e7e4a0778731d9c2d5c38107ca7047a7abd2165"}, + {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"}, ] [package.dependencies] GitPython = ">=3.1,<3.2" -mkdocs = ">=1.1,<2" +mkdocs = ">=1.4,<2" pytz = {version = "==2022.*", markers = "python_version < \"3.9\""} -tzdata = {version = "==2022.*", markers = "python_version >= \"3.9\" and sys_platform == \"win32\""} +tzdata = {version = "==2023.*", markers = "python_version >= \"3.9\" and sys_platform == \"win32\""} [package.extras] -dev = ["black", "feedparser (>=6.0,<6.1)", "flake8 (>=4,<5.1)", "pre-commit (>=2.10,<2.21)", "pytest-cov (==4.0.*)", "validator-collection (>=1.5,<1.6)"] -doc = ["mkdocs-bootswatch (>=1,<2)", "mkdocs-minify-plugin (==0.5.*)", "pygments (>=2.5,<3)", "pymdown-extensions (>=7,<10)"] +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)"] [[package]] name = "mkdocstrings" @@ -1119,74 +1077,67 @@ mkdocstrings = ">=0.20" [[package]] name = "msgpack" -version = "1.0.5" +version = "1.0.7" description = "MessagePack serializer" optional = false -python-versions = "*" -files = [ - {file = "msgpack-1.0.5-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:525228efd79bb831cf6830a732e2e80bc1b05436b086d4264814b4b2955b2fa9"}, - {file = "msgpack-1.0.5-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:4f8d8b3bf1ff2672567d6b5c725a1b347fe838b912772aa8ae2bf70338d5a198"}, - {file = "msgpack-1.0.5-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:cdc793c50be3f01106245a61b739328f7dccc2c648b501e237f0699fe1395b81"}, - {file = "msgpack-1.0.5-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5cb47c21a8a65b165ce29f2bec852790cbc04936f502966768e4aae9fa763cb7"}, - {file = "msgpack-1.0.5-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e42b9594cc3bf4d838d67d6ed62b9e59e201862a25e9a157019e171fbe672dd3"}, - {file = "msgpack-1.0.5-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:55b56a24893105dc52c1253649b60f475f36b3aa0fc66115bffafb624d7cb30b"}, - {file = "msgpack-1.0.5-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:1967f6129fc50a43bfe0951c35acbb729be89a55d849fab7686004da85103f1c"}, - {file = "msgpack-1.0.5-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:20a97bf595a232c3ee6d57ddaadd5453d174a52594bf9c21d10407e2a2d9b3bd"}, - {file = "msgpack-1.0.5-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:d25dd59bbbbb996eacf7be6b4ad082ed7eacc4e8f3d2df1ba43822da9bfa122a"}, - {file = "msgpack-1.0.5-cp310-cp310-win32.whl", hash = "sha256:382b2c77589331f2cb80b67cc058c00f225e19827dbc818d700f61513ab47bea"}, - {file = "msgpack-1.0.5-cp310-cp310-win_amd64.whl", hash = "sha256:4867aa2df9e2a5fa5f76d7d5565d25ec76e84c106b55509e78c1ede0f152659a"}, - {file = "msgpack-1.0.5-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:9f5ae84c5c8a857ec44dc180a8b0cc08238e021f57abdf51a8182e915e6299f0"}, - {file = "msgpack-1.0.5-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:9e6ca5d5699bcd89ae605c150aee83b5321f2115695e741b99618f4856c50898"}, - {file = "msgpack-1.0.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:5494ea30d517a3576749cad32fa27f7585c65f5f38309c88c6d137877fa28a5a"}, - {file = "msgpack-1.0.5-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1ab2f3331cb1b54165976a9d976cb251a83183631c88076613c6c780f0d6e45a"}, - {file = "msgpack-1.0.5-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:28592e20bbb1620848256ebc105fc420436af59515793ed27d5c77a217477705"}, - {file = "msgpack-1.0.5-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:fe5c63197c55bce6385d9aee16c4d0641684628f63ace85f73571e65ad1c1e8d"}, - {file = "msgpack-1.0.5-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:ed40e926fa2f297e8a653c954b732f125ef97bdd4c889f243182299de27e2aa9"}, - {file = "msgpack-1.0.5-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:b2de4c1c0538dcb7010902a2b97f4e00fc4ddf2c8cda9749af0e594d3b7fa3d7"}, - {file = "msgpack-1.0.5-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:bf22a83f973b50f9d38e55c6aade04c41ddda19b00c4ebc558930d78eecc64ed"}, - {file = "msgpack-1.0.5-cp311-cp311-win32.whl", hash = "sha256:c396e2cc213d12ce017b686e0f53497f94f8ba2b24799c25d913d46c08ec422c"}, - {file = "msgpack-1.0.5-cp311-cp311-win_amd64.whl", hash = "sha256:6c4c68d87497f66f96d50142a2b73b97972130d93677ce930718f68828b382e2"}, - {file = "msgpack-1.0.5-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:a2b031c2e9b9af485d5e3c4520f4220d74f4d222a5b8dc8c1a3ab9448ca79c57"}, - {file = "msgpack-1.0.5-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4f837b93669ce4336e24d08286c38761132bc7ab29782727f8557e1eb21b2080"}, - {file = "msgpack-1.0.5-cp36-cp36m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b1d46dfe3832660f53b13b925d4e0fa1432b00f5f7210eb3ad3bb9a13c6204a6"}, - {file = "msgpack-1.0.5-cp36-cp36m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:366c9a7b9057e1547f4ad51d8facad8b406bab69c7d72c0eb6f529cf76d4b85f"}, - {file = "msgpack-1.0.5-cp36-cp36m-musllinux_1_1_aarch64.whl", hash = "sha256:4c075728a1095efd0634a7dccb06204919a2f67d1893b6aa8e00497258bf926c"}, - {file = "msgpack-1.0.5-cp36-cp36m-musllinux_1_1_i686.whl", hash = "sha256:f933bbda5a3ee63b8834179096923b094b76f0c7a73c1cfe8f07ad608c58844b"}, - {file = "msgpack-1.0.5-cp36-cp36m-musllinux_1_1_x86_64.whl", hash = "sha256:36961b0568c36027c76e2ae3ca1132e35123dcec0706c4b7992683cc26c1320c"}, - {file = "msgpack-1.0.5-cp36-cp36m-win32.whl", hash = "sha256:b5ef2f015b95f912c2fcab19c36814963b5463f1fb9049846994b007962743e9"}, - {file = "msgpack-1.0.5-cp36-cp36m-win_amd64.whl", hash = "sha256:288e32b47e67f7b171f86b030e527e302c91bd3f40fd9033483f2cacc37f327a"}, - {file = "msgpack-1.0.5-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:137850656634abddfb88236008339fdaba3178f4751b28f270d2ebe77a563b6c"}, - {file = "msgpack-1.0.5-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0c05a4a96585525916b109bb85f8cb6511db1c6f5b9d9cbcbc940dc6b4be944b"}, - {file = "msgpack-1.0.5-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:56a62ec00b636583e5cb6ad313bbed36bb7ead5fa3a3e38938503142c72cba4f"}, - {file = "msgpack-1.0.5-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ef8108f8dedf204bb7b42994abf93882da1159728a2d4c5e82012edd92c9da9f"}, - {file = "msgpack-1.0.5-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:1835c84d65f46900920b3708f5ba829fb19b1096c1800ad60bae8418652a951d"}, - {file = "msgpack-1.0.5-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:e57916ef1bd0fee4f21c4600e9d1da352d8816b52a599c46460e93a6e9f17086"}, - {file = "msgpack-1.0.5-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:17358523b85973e5f242ad74aa4712b7ee560715562554aa2134d96e7aa4cbbf"}, - {file = "msgpack-1.0.5-cp37-cp37m-win32.whl", hash = "sha256:cb5aaa8c17760909ec6cb15e744c3ebc2ca8918e727216e79607b7bbce9c8f77"}, - {file = "msgpack-1.0.5-cp37-cp37m-win_amd64.whl", hash = "sha256:ab31e908d8424d55601ad7075e471b7d0140d4d3dd3272daf39c5c19d936bd82"}, - {file = "msgpack-1.0.5-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:b72d0698f86e8d9ddf9442bdedec15b71df3598199ba33322d9711a19f08145c"}, - {file = "msgpack-1.0.5-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:379026812e49258016dd84ad79ac8446922234d498058ae1d415f04b522d5b2d"}, - {file = "msgpack-1.0.5-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:332360ff25469c346a1c5e47cbe2a725517919892eda5cfaffe6046656f0b7bb"}, - {file = "msgpack-1.0.5-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:476a8fe8fae289fdf273d6d2a6cb6e35b5a58541693e8f9f019bfe990a51e4ba"}, - {file = "msgpack-1.0.5-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a9985b214f33311df47e274eb788a5893a761d025e2b92c723ba4c63936b69b1"}, - {file = "msgpack-1.0.5-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:48296af57cdb1d885843afd73c4656be5c76c0c6328db3440c9601a98f303d87"}, - {file = "msgpack-1.0.5-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:addab7e2e1fcc04bd08e4eb631c2a90960c340e40dfc4a5e24d2ff0d5a3b3edb"}, - {file = "msgpack-1.0.5-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:916723458c25dfb77ff07f4c66aed34e47503b2eb3188b3adbec8d8aa6e00f48"}, - {file = "msgpack-1.0.5-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:821c7e677cc6acf0fd3f7ac664c98803827ae6de594a9f99563e48c5a2f27eb0"}, - {file = "msgpack-1.0.5-cp38-cp38-win32.whl", hash = "sha256:1c0f7c47f0087ffda62961d425e4407961a7ffd2aa004c81b9c07d9269512f6e"}, - {file = "msgpack-1.0.5-cp38-cp38-win_amd64.whl", hash = "sha256:bae7de2026cbfe3782c8b78b0db9cbfc5455e079f1937cb0ab8d133496ac55e1"}, - {file = "msgpack-1.0.5-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:20c784e66b613c7f16f632e7b5e8a1651aa5702463d61394671ba07b2fc9e025"}, - {file = "msgpack-1.0.5-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:266fa4202c0eb94d26822d9bfd7af25d1e2c088927fe8de9033d929dd5ba24c5"}, - {file = "msgpack-1.0.5-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:18334484eafc2b1aa47a6d42427da7fa8f2ab3d60b674120bce7a895a0a85bdd"}, - {file = "msgpack-1.0.5-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:57e1f3528bd95cc44684beda696f74d3aaa8a5e58c816214b9046512240ef437"}, - {file = "msgpack-1.0.5-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:586d0d636f9a628ddc6a17bfd45aa5b5efaf1606d2b60fa5d87b8986326e933f"}, - {file = "msgpack-1.0.5-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a740fa0e4087a734455f0fc3abf5e746004c9da72fbd541e9b113013c8dc3282"}, - {file = "msgpack-1.0.5-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:3055b0455e45810820db1f29d900bf39466df96ddca11dfa6d074fa47054376d"}, - {file = "msgpack-1.0.5-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:a61215eac016f391129a013c9e46f3ab308db5f5ec9f25811e811f96962599a8"}, - {file = "msgpack-1.0.5-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:362d9655cd369b08fda06b6657a303eb7172d5279997abe094512e919cf74b11"}, - {file = "msgpack-1.0.5-cp39-cp39-win32.whl", hash = "sha256:ac9dd47af78cae935901a9a500104e2dea2e253207c924cc95de149606dc43cc"}, - {file = "msgpack-1.0.5-cp39-cp39-win_amd64.whl", hash = "sha256:06f5174b5f8ed0ed919da0e62cbd4ffde676a374aba4020034da05fab67b9164"}, - {file = "msgpack-1.0.5.tar.gz", hash = "sha256:c075544284eadc5cddc70f4757331d99dcbc16b2bbd4849d15f8aae4cf36d31c"}, +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"}, ] [[package]] @@ -1274,49 +1225,49 @@ files = [ [[package]] name = "mypy" -version = "1.4.1" +version = "1.7.1" description = "Optional static typing for Python" optional = false -python-versions = ">=3.7" -files = [ - {file = "mypy-1.4.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:566e72b0cd6598503e48ea610e0052d1b8168e60a46e0bfd34b3acf2d57f96a8"}, - {file = "mypy-1.4.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:ca637024ca67ab24a7fd6f65d280572c3794665eaf5edcc7e90a866544076878"}, - {file = "mypy-1.4.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0dde1d180cd84f0624c5dcaaa89c89775550a675aff96b5848de78fb11adabcd"}, - {file = "mypy-1.4.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:8c4d8e89aa7de683e2056a581ce63c46a0c41e31bd2b6d34144e2c80f5ea53dc"}, - {file = "mypy-1.4.1-cp310-cp310-win_amd64.whl", hash = "sha256:bfdca17c36ae01a21274a3c387a63aa1aafe72bff976522886869ef131b937f1"}, - {file = "mypy-1.4.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:7549fbf655e5825d787bbc9ecf6028731973f78088fbca3a1f4145c39ef09462"}, - {file = "mypy-1.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:98324ec3ecf12296e6422939e54763faedbfcc502ea4a4c38502082711867258"}, - {file = "mypy-1.4.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:141dedfdbfe8a04142881ff30ce6e6653c9685b354876b12e4fe6c78598b45e2"}, - {file = "mypy-1.4.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:8207b7105829eca6f3d774f64a904190bb2231de91b8b186d21ffd98005f14a7"}, - {file = "mypy-1.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:16f0db5b641ba159eff72cff08edc3875f2b62b2fa2bc24f68c1e7a4e8232d01"}, - {file = "mypy-1.4.1-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:470c969bb3f9a9efcedbadcd19a74ffb34a25f8e6b0e02dae7c0e71f8372f97b"}, - {file = "mypy-1.4.1-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e5952d2d18b79f7dc25e62e014fe5a23eb1a3d2bc66318df8988a01b1a037c5b"}, - {file = "mypy-1.4.1-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:190b6bab0302cec4e9e6767d3eb66085aef2a1cc98fe04936d8a42ed2ba77bb7"}, - {file = "mypy-1.4.1-cp37-cp37m-win_amd64.whl", hash = "sha256:9d40652cc4fe33871ad3338581dca3297ff5f2213d0df345bcfbde5162abf0c9"}, - {file = "mypy-1.4.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:01fd2e9f85622d981fd9063bfaef1aed6e336eaacca00892cd2d82801ab7c042"}, - {file = "mypy-1.4.1-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:2460a58faeea905aeb1b9b36f5065f2dc9a9c6e4c992a6499a2360c6c74ceca3"}, - {file = "mypy-1.4.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a2746d69a8196698146a3dbe29104f9eb6a2a4d8a27878d92169a6c0b74435b6"}, - {file = "mypy-1.4.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:ae704dcfaa180ff7c4cfbad23e74321a2b774f92ca77fd94ce1049175a21c97f"}, - {file = "mypy-1.4.1-cp38-cp38-win_amd64.whl", hash = "sha256:43d24f6437925ce50139a310a64b2ab048cb2d3694c84c71c3f2a1626d8101dc"}, - {file = "mypy-1.4.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:c482e1246726616088532b5e964e39765b6d1520791348e6c9dc3af25b233828"}, - {file = "mypy-1.4.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:43b592511672017f5b1a483527fd2684347fdffc041c9ef53428c8dc530f79a3"}, - {file = "mypy-1.4.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:34a9239d5b3502c17f07fd7c0b2ae6b7dd7d7f6af35fbb5072c6208e76295816"}, - {file = "mypy-1.4.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:5703097c4936bbb9e9bce41478c8d08edd2865e177dc4c52be759f81ee4dd26c"}, - {file = "mypy-1.4.1-cp39-cp39-win_amd64.whl", hash = "sha256:e02d700ec8d9b1859790c0475df4e4092c7bf3272a4fd2c9f33d87fac4427b8f"}, - {file = "mypy-1.4.1-py3-none-any.whl", hash = "sha256:45d32cec14e7b97af848bddd97d85ea4f0db4d5a149ed9676caa4eb2f7402bb4"}, - {file = "mypy-1.4.1.tar.gz", hash = "sha256:9bbcd9ab8ea1f2e1c8031c21445b511442cc45c89951e49bbf852cbb70755b1b"}, +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"}, ] [package.dependencies] mypy-extensions = ">=1.0.0" tomli = {version = ">=1.1.0", markers = "python_version < \"3.11\""} -typed-ast = {version = ">=1.4.0,<2", markers = "python_version < \"3.8\""} typing-extensions = ">=4.1.0" [package.extras] dmypy = ["psutil (>=4.0)"] install-types = ["pip"] -python2 = ["typed-ast (>=1.4.0,<2)"] +mypyc = ["setuptools (>=50)"] reports = ["lxml"] [[package]] @@ -1346,13 +1297,13 @@ setuptools = "*" [[package]] name = "packaging" -version = "23.1" +version = "23.2" description = "Core utilities for Python packages" optional = false python-versions = ">=3.7" files = [ - {file = "packaging-23.1-py3-none-any.whl", hash = "sha256:994793af429502c4ea2ebf6bf664629d07c1a9fe974af92966e4b8d2df7edc61"}, - {file = "packaging-23.1.tar.gz", hash = "sha256:a392980d2b6cffa644431898be54b0045151319d1e7ec34f0cfed48767dd334f"}, + {file = "packaging-23.2-py3-none-any.whl", hash = "sha256:8c491190033a9af7e1d931d0b5dacc2ef47509b34dd0de67ed209b5203fc88c7"}, + {file = "packaging-23.2.tar.gz", hash = "sha256:048fb0e9405036518eaaf48a55953c750c11e1a1b68e0dd1a9d62ed0c092cfc5"}, ] [[package]] @@ -1378,36 +1329,30 @@ files = [ [[package]] name = "platformdirs" -version = "3.10.0" +version = "4.1.0" description = "A small Python package for determining appropriate platform-specific dirs, e.g. a \"user data dir\"." optional = false -python-versions = ">=3.7" +python-versions = ">=3.8" files = [ - {file = "platformdirs-3.10.0-py3-none-any.whl", hash = "sha256:d7c24979f292f916dc9cbf8648319032f551ea8c49a4c9bf2fb556a02070ec1d"}, - {file = "platformdirs-3.10.0.tar.gz", hash = "sha256:b45696dab2d7cc691a3226759c0d3b00c47c8b6e293d96f6436f733303f77f6d"}, + {file = "platformdirs-4.1.0-py3-none-any.whl", hash = "sha256:11c8f37bcca40db96d8144522d925583bdb7a31f7b0e37e3ed4318400a8e2380"}, + {file = "platformdirs-4.1.0.tar.gz", hash = "sha256:906d548203468492d432bcb294d4bc2fff751bf84971fbb2c10918cc206ee420"}, ] -[package.dependencies] -typing-extensions = {version = ">=4.7.1", markers = "python_version < \"3.8\""} - [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)"] [[package]] name = "pluggy" -version = "1.2.0" +version = "1.3.0" description = "plugin and hook calling mechanisms for python" optional = false -python-versions = ">=3.7" +python-versions = ">=3.8" files = [ - {file = "pluggy-1.2.0-py3-none-any.whl", hash = "sha256:c2fd55a7d7a3863cba1a013e4e2414658b1d07b6bc57b3919e0c63c9abb99849"}, - {file = "pluggy-1.2.0.tar.gz", hash = "sha256:d12f0c4b579b15f5e054301bb226ee85eeeba08ffec228092f8defbaa3a4c4b3"}, + {file = "pluggy-1.3.0-py3-none-any.whl", hash = "sha256:d89c696a773f8bd377d18e5ecda92b7a3793cbe66c87060a6fb58c7b6e1061f7"}, + {file = "pluggy-1.3.0.tar.gz", hash = "sha256:cf61ae8f126ac6f7c451172cf30e3e43d3ca77615509771b3a984a0730651e12"}, ] -[package.dependencies] -importlib-metadata = {version = ">=0.12", markers = "python_version < \"3.8\""} - [package.extras] dev = ["pre-commit", "tox"] testing = ["pytest", "pytest-benchmark"] @@ -1426,38 +1371,38 @@ files = [ [package.dependencies] cfgv = ">=2.0.0" identify = ">=1.0.0" -importlib-metadata = {version = "*", markers = "python_version < \"3.8\""} nodeenv = ">=0.11.1" pyyaml = ">=5.1" virtualenv = ">=20.10.0" [[package]] name = "pygments" -version = "2.16.1" +version = "2.17.2" description = "Pygments is a syntax highlighting package written in Python." optional = false python-versions = ">=3.7" files = [ - {file = "Pygments-2.16.1-py3-none-any.whl", hash = "sha256:13fc09fa63bc8d8671a6d247e1eb303c4b343eaee81d861f3404db2935653692"}, - {file = "Pygments-2.16.1.tar.gz", hash = "sha256:1daff0494820c69bc8941e407aa20f577374ee88364ee10a98fdbe0aece96e29"}, + {file = "pygments-2.17.2-py3-none-any.whl", hash = "sha256:b27c2826c47d0f3219f29554824c30c5e8945175d888647acd804ddd04af846c"}, + {file = "pygments-2.17.2.tar.gz", hash = "sha256:da46cec9fd2de5be3a8a784f434e4c4ab670b4ff54d605c4c2717e9d49c4c367"}, ] [package.extras] plugins = ["importlib-metadata"] +windows-terminal = ["colorama (>=0.4.6)"] [[package]] name = "pymdown-extensions" -version = "10.2.1" +version = "10.5" description = "Extension pack for Python Markdown." optional = false -python-versions = ">=3.7" +python-versions = ">=3.8" files = [ - {file = "pymdown_extensions-10.2.1-py3-none-any.whl", hash = "sha256:bded105eb8d93f88f2f821f00108cb70cef1269db6a40128c09c5f48bfc60ea4"}, - {file = "pymdown_extensions-10.2.1.tar.gz", hash = "sha256:d0c534b4a5725a4be7ccef25d65a4c97dba58b54ad7c813babf0eb5ba9c81591"}, + {file = "pymdown_extensions-10.5-py3-none-any.whl", hash = "sha256:1f0ca8bb5beff091315f793ee17683bc1390731f6ac4c5eb01e27464b80fe879"}, + {file = "pymdown_extensions-10.5.tar.gz", hash = "sha256:1b60f1e462adbec5a1ed79dac91f666c9c0d241fa294de1989f29d20096cfd0b"}, ] [package.dependencies] -markdown = ">=3.2" +markdown = ">=3.5" pyyaml = "*" [package.extras] @@ -1465,19 +1410,18 @@ extra = ["pygments (>=2.12)"] [[package]] name = "pytest" -version = "7.4.1" +version = "7.4.3" description = "pytest: simple powerful testing with Python" optional = false python-versions = ">=3.7" files = [ - {file = "pytest-7.4.1-py3-none-any.whl", hash = "sha256:460c9a59b14e27c602eb5ece2e47bec99dc5fc5f6513cf924a7d03a578991b1f"}, - {file = "pytest-7.4.1.tar.gz", hash = "sha256:2f2301e797521b23e4d2585a0a3d7b5e50fdddaaf7e7d6773ea26ddb17c213ab"}, + {file = "pytest-7.4.3-py3-none-any.whl", hash = "sha256:0d009c083ea859a71b76adf7c1d502e4bc170b80a8ef002da5806527b9591fac"}, + {file = "pytest-7.4.3.tar.gz", hash = "sha256:d989d136982de4e3b29dabcc838ad581c64e8ed52c11fbe86ddebd9da0818cd5"}, ] [package.dependencies] colorama = {version = "*", markers = "sys_platform == \"win32\""} exceptiongroup = {version = ">=1.0.0rc8", markers = "python_version < \"3.11\""} -importlib-metadata = {version = ">=0.12", markers = "python_version < \"3.8\""} iniconfig = "*" packaging = "*" pluggy = ">=0.12,<2.0" @@ -1486,43 +1430,23 @@ tomli = {version = ">=1.0.0", markers = "python_version < \"3.11\""} [package.extras] testing = ["argcomplete", "attrs (>=19.2.0)", "hypothesis (>=3.56)", "mock", "nose", "pygments (>=2.7.2)", "requests", "setuptools", "xmlschema"] -[[package]] -name = "pytest-aiohttp" -version = "1.0.4" -description = "Pytest plugin for aiohttp support" -optional = false -python-versions = ">=3.7" -files = [ - {file = "pytest-aiohttp-1.0.4.tar.gz", hash = "sha256:39ff3a0d15484c01d1436cbedad575c6eafbf0f57cdf76fb94994c97b5b8c5a4"}, - {file = "pytest_aiohttp-1.0.4-py3-none-any.whl", hash = "sha256:1d2dc3a304c2be1fd496c0c2fb6b31ab60cd9fc33984f761f951f8ea1eb4ca95"}, -] - -[package.dependencies] -aiohttp = ">=3.8.1" -pytest = ">=6.1.0" -pytest-asyncio = ">=0.17.2" - -[package.extras] -testing = ["coverage (==6.2)", "mypy (==0.931)"] - [[package]] name = "pytest-asyncio" -version = "0.21.1" +version = "0.23.2" description = "Pytest support for asyncio" optional = false -python-versions = ">=3.7" +python-versions = ">=3.8" files = [ - {file = "pytest-asyncio-0.21.1.tar.gz", hash = "sha256:40a7eae6dded22c7b604986855ea48400ab15b069ae38116e8c01238e9eeb64d"}, - {file = "pytest_asyncio-0.21.1-py3-none-any.whl", hash = "sha256:8666c1c8ac02631d7c51ba282e0c69a8a452b211ffedf2599099845da5c5c37b"}, + {file = "pytest-asyncio-0.23.2.tar.gz", hash = "sha256:c16052382554c7b22d48782ab3438d5b10f8cf7a4bdcae7f0f67f097d95beecc"}, + {file = "pytest_asyncio-0.23.2-py3-none-any.whl", hash = "sha256:ea9021364e32d58f0be43b91c6233fb8d2224ccef2398d6837559e587682808f"}, ] [package.dependencies] pytest = ">=7.0.0" -typing-extensions = {version = ">=3.7.2", markers = "python_version < \"3.8\""} [package.extras] docs = ["sphinx (>=5.3)", "sphinx-rtd-theme (>=1.0)"] -testing = ["coverage (>=6.2)", "flaky (>=3.5.0)", "hypothesis (>=5.7.1)", "mypy (>=0.931)", "pytest-trio (>=0.7.0)"] +testing = ["coverage (>=6.2)", "hypothesis (>=5.7.1)"] [[package]] name = "pytest-cov" @@ -1598,6 +1522,7 @@ files = [ {file = "PyYAML-6.0.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:69b023b2b4daa7548bcfbd4aa3da05b3a74b772db9e23b982788168117739938"}, {file = "PyYAML-6.0.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:81e0b275a9ecc9c0c0c07b4b90ba548307583c125f54d5b6946cfee6360c733d"}, {file = "PyYAML-6.0.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ba336e390cd8e4d1739f42dfe9bb83a3cc2e80f567d8805e11b46f4a943f5515"}, + {file = "PyYAML-6.0.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:326c013efe8048858a6d312ddd31d56e468118ad4cdeda36c719bf5bb6192290"}, {file = "PyYAML-6.0.1-cp310-cp310-win32.whl", hash = "sha256:bd4af7373a854424dabd882decdc5579653d7868b8fb26dc7d0e99f823aa5924"}, {file = "PyYAML-6.0.1-cp310-cp310-win_amd64.whl", hash = "sha256:fd1592b3fdf65fff2ad0004b5e363300ef59ced41c2e6b3a99d4089fa8c5435d"}, {file = "PyYAML-6.0.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:6965a7bc3cf88e5a1c3bd2e0b5c22f8d677dc88a455344035f03399034eb3007"}, @@ -1605,8 +1530,15 @@ files = [ {file = "PyYAML-6.0.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:42f8152b8dbc4fe7d96729ec2b99c7097d656dc1213a3229ca5383f973a5ed6d"}, {file = "PyYAML-6.0.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:062582fca9fabdd2c8b54a3ef1c978d786e0f6b3a1510e0ac93ef59e0ddae2bc"}, {file = "PyYAML-6.0.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d2b04aac4d386b172d5b9692e2d2da8de7bfb6c387fa4f801fbf6fb2e6ba4673"}, + {file = "PyYAML-6.0.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:e7d73685e87afe9f3b36c799222440d6cf362062f78be1013661b00c5c6f678b"}, {file = "PyYAML-6.0.1-cp311-cp311-win32.whl", hash = "sha256:1635fd110e8d85d55237ab316b5b011de701ea0f29d07611174a1b42f1444741"}, {file = "PyYAML-6.0.1-cp311-cp311-win_amd64.whl", hash = "sha256:bf07ee2fef7014951eeb99f56f39c9bb4af143d8aa3c21b1677805985307da34"}, + {file = "PyYAML-6.0.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:855fb52b0dc35af121542a76b9a84f8d1cd886ea97c84703eaa6d88e37a2ad28"}, + {file = "PyYAML-6.0.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:40df9b996c2b73138957fe23a16a4f0ba614f4c0efce1e9406a184b6d07fa3a9"}, + {file = "PyYAML-6.0.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6c22bec3fbe2524cde73d7ada88f6566758a8f7227bfbf93a408a9d86bcc12a0"}, + {file = "PyYAML-6.0.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:8d4e9c88387b0f5c7d5f281e55304de64cf7f9c0021a3525bd3b1c542da3b0e4"}, + {file = "PyYAML-6.0.1-cp312-cp312-win32.whl", hash = "sha256:d483d2cdf104e7c9fa60c544d92981f12ad66a457afae824d146093b8c294c54"}, + {file = "PyYAML-6.0.1-cp312-cp312-win_amd64.whl", hash = "sha256:0d3304d8c0adc42be59c5f8a4d9e3d7379e6955ad754aa9d6ab7a398b59dd1df"}, {file = "PyYAML-6.0.1-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:50550eb667afee136e9a77d6dc71ae76a44df8b3e51e41b77f6de2932bfe0f47"}, {file = "PyYAML-6.0.1-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1fe35611261b29bd1de0070f0b2f47cb6ff71fa6595c077e42bd0c419fa27b98"}, {file = "PyYAML-6.0.1-cp36-cp36m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:704219a11b772aea0d8ecd7058d0082713c3562b4e271b849ad7dc4a5c90c13c"}, @@ -1623,6 +1555,7 @@ files = [ {file = "PyYAML-6.0.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a0cd17c15d3bb3fa06978b4e8958dcdc6e0174ccea823003a106c7d4d7899ac5"}, {file = "PyYAML-6.0.1-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:28c119d996beec18c05208a8bd78cbe4007878c6dd15091efb73a30e90539696"}, {file = "PyYAML-6.0.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7e07cbde391ba96ab58e532ff4803f79c4129397514e1413a7dc761ccd755735"}, + {file = "PyYAML-6.0.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:49a183be227561de579b4a36efbb21b3eab9651dd81b1858589f796549873dd6"}, {file = "PyYAML-6.0.1-cp38-cp38-win32.whl", hash = "sha256:184c5108a2aca3c5b3d3bf9395d50893a7ab82a38004c8f61c258d4428e80206"}, {file = "PyYAML-6.0.1-cp38-cp38-win_amd64.whl", hash = "sha256:1e2722cc9fbb45d9b87631ac70924c11d3a401b2d7f410cc0e3bbf249f2dca62"}, {file = "PyYAML-6.0.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:9eb6caa9a297fc2c2fb8862bc5370d0303ddba53ba97e71f08023b6cd73d16a8"}, @@ -1630,6 +1563,7 @@ files = [ {file = "PyYAML-6.0.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5773183b6446b2c99bb77e77595dd486303b4faab2b086e7b17bc6bef28865f6"}, {file = "PyYAML-6.0.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b786eecbdf8499b9ca1d697215862083bd6d2a99965554781d0d8d1ad31e13a0"}, {file = "PyYAML-6.0.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bc1bf2925a1ecd43da378f4db9e4f799775d6367bdb94671027b73b393a7c42c"}, + {file = "PyYAML-6.0.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:04ac92ad1925b2cff1db0cfebffb6ffc43457495c9b3c39d3fcae417d7125dc5"}, {file = "PyYAML-6.0.1-cp39-cp39-win32.whl", hash = "sha256:faca3bdcf85b2fc05d06ff3fbc1f83e1391b3e724afa3feba7d13eeab355484c"}, {file = "PyYAML-6.0.1-cp39-cp39-win_amd64.whl", hash = "sha256:510c9deebc5c0225e8c96813043e62b680ba2f9c50a08d3724c7f28a747d1486"}, {file = "PyYAML-6.0.1.tar.gz", hash = "sha256:bfdf460b1736c775f2ba9f6a92bca30bc2095067b8a9d77876d1fad6cc3b4a43"}, @@ -1651,99 +1585,99 @@ pyyaml = "*" [[package]] name = "regex" -version = "2022.10.31" +version = "2023.10.3" description = "Alternative regular expression module, to replace re." optional = false -python-versions = ">=3.6" +python-versions = ">=3.7" files = [ - {file = "regex-2022.10.31-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:a8ff454ef0bb061e37df03557afda9d785c905dab15584860f982e88be73015f"}, - {file = "regex-2022.10.31-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:1eba476b1b242620c266edf6325b443a2e22b633217a9835a52d8da2b5c051f9"}, - {file = "regex-2022.10.31-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d0e5af9a9effb88535a472e19169e09ce750c3d442fb222254a276d77808620b"}, - {file = "regex-2022.10.31-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d03fe67b2325cb3f09be029fd5da8df9e6974f0cde2c2ac6a79d2634e791dd57"}, - {file = "regex-2022.10.31-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a9d0b68ac1743964755ae2d89772c7e6fb0118acd4d0b7464eaf3921c6b49dd4"}, - {file = "regex-2022.10.31-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8a45b6514861916c429e6059a55cf7db74670eaed2052a648e3e4d04f070e001"}, - {file = "regex-2022.10.31-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8b0886885f7323beea6f552c28bff62cbe0983b9fbb94126531693ea6c5ebb90"}, - {file = "regex-2022.10.31-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:5aefb84a301327ad115e9d346c8e2760009131d9d4b4c6b213648d02e2abe144"}, - {file = "regex-2022.10.31-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:702d8fc6f25bbf412ee706bd73019da5e44a8400861dfff7ff31eb5b4a1276dc"}, - {file = "regex-2022.10.31-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:a3c1ebd4ed8e76e886507c9eddb1a891673686c813adf889b864a17fafcf6d66"}, - {file = "regex-2022.10.31-cp310-cp310-musllinux_1_1_ppc64le.whl", hash = "sha256:50921c140561d3db2ab9f5b11c5184846cde686bb5a9dc64cae442926e86f3af"}, - {file = "regex-2022.10.31-cp310-cp310-musllinux_1_1_s390x.whl", hash = "sha256:7db345956ecce0c99b97b042b4ca7326feeec6b75facd8390af73b18e2650ffc"}, - {file = "regex-2022.10.31-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:763b64853b0a8f4f9cfb41a76a4a85a9bcda7fdda5cb057016e7706fde928e66"}, - {file = "regex-2022.10.31-cp310-cp310-win32.whl", hash = "sha256:44136355e2f5e06bf6b23d337a75386371ba742ffa771440b85bed367c1318d1"}, - {file = "regex-2022.10.31-cp310-cp310-win_amd64.whl", hash = "sha256:bfff48c7bd23c6e2aec6454aaf6edc44444b229e94743b34bdcdda2e35126cf5"}, - {file = "regex-2022.10.31-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:4b4b1fe58cd102d75ef0552cf17242705ce0759f9695334a56644ad2d83903fe"}, - {file = "regex-2022.10.31-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:542e3e306d1669b25936b64917285cdffcd4f5c6f0247636fec037187bd93542"}, - {file = "regex-2022.10.31-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c27cc1e4b197092e50ddbf0118c788d9977f3f8f35bfbbd3e76c1846a3443df7"}, - {file = "regex-2022.10.31-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b8e38472739028e5f2c3a4aded0ab7eadc447f0d84f310c7a8bb697ec417229e"}, - {file = "regex-2022.10.31-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:76c598ca73ec73a2f568e2a72ba46c3b6c8690ad9a07092b18e48ceb936e9f0c"}, - {file = "regex-2022.10.31-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c28d3309ebd6d6b2cf82969b5179bed5fefe6142c70f354ece94324fa11bf6a1"}, - {file = "regex-2022.10.31-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9af69f6746120998cd9c355e9c3c6aec7dff70d47247188feb4f829502be8ab4"}, - {file = "regex-2022.10.31-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:a5f9505efd574d1e5b4a76ac9dd92a12acb2b309551e9aa874c13c11caefbe4f"}, - {file = "regex-2022.10.31-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:5ff525698de226c0ca743bfa71fc6b378cda2ddcf0d22d7c37b1cc925c9650a5"}, - {file = "regex-2022.10.31-cp311-cp311-musllinux_1_1_ppc64le.whl", hash = "sha256:4fe7fda2fe7c8890d454f2cbc91d6c01baf206fbc96d89a80241a02985118c0c"}, - {file = "regex-2022.10.31-cp311-cp311-musllinux_1_1_s390x.whl", hash = "sha256:2cdc55ca07b4e70dda898d2ab7150ecf17c990076d3acd7a5f3b25cb23a69f1c"}, - {file = "regex-2022.10.31-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:44a6c2f6374e0033873e9ed577a54a3602b4f609867794c1a3ebba65e4c93ee7"}, - {file = "regex-2022.10.31-cp311-cp311-win32.whl", hash = "sha256:d8716f82502997b3d0895d1c64c3b834181b1eaca28f3f6336a71777e437c2af"}, - {file = "regex-2022.10.31-cp311-cp311-win_amd64.whl", hash = "sha256:61edbca89aa3f5ef7ecac8c23d975fe7261c12665f1d90a6b1af527bba86ce61"}, - {file = "regex-2022.10.31-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:0a069c8483466806ab94ea9068c34b200b8bfc66b6762f45a831c4baaa9e8cdd"}, - {file = "regex-2022.10.31-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d26166acf62f731f50bdd885b04b38828436d74e8e362bfcb8df221d868b5d9b"}, - {file = "regex-2022.10.31-cp36-cp36m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ac741bf78b9bb432e2d314439275235f41656e189856b11fb4e774d9f7246d81"}, - {file = "regex-2022.10.31-cp36-cp36m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:75f591b2055523fc02a4bbe598aa867df9e953255f0b7f7715d2a36a9c30065c"}, - {file = "regex-2022.10.31-cp36-cp36m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6b30bddd61d2a3261f025ad0f9ee2586988c6a00c780a2fb0a92cea2aa702c54"}, - {file = "regex-2022.10.31-cp36-cp36m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ef4163770525257876f10e8ece1cf25b71468316f61451ded1a6f44273eedeb5"}, - {file = "regex-2022.10.31-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:7b280948d00bd3973c1998f92e22aa3ecb76682e3a4255f33e1020bd32adf443"}, - {file = "regex-2022.10.31-cp36-cp36m-musllinux_1_1_aarch64.whl", hash = "sha256:d0213671691e341f6849bf33cd9fad21f7b1cb88b89e024f33370733fec58742"}, - {file = "regex-2022.10.31-cp36-cp36m-musllinux_1_1_i686.whl", hash = "sha256:22e7ebc231d28393dfdc19b185d97e14a0f178bedd78e85aad660e93b646604e"}, - {file = "regex-2022.10.31-cp36-cp36m-musllinux_1_1_ppc64le.whl", hash = "sha256:8ad241da7fac963d7573cc67a064c57c58766b62a9a20c452ca1f21050868dfa"}, - {file = "regex-2022.10.31-cp36-cp36m-musllinux_1_1_s390x.whl", hash = "sha256:586b36ebda81e6c1a9c5a5d0bfdc236399ba6595e1397842fd4a45648c30f35e"}, - {file = "regex-2022.10.31-cp36-cp36m-musllinux_1_1_x86_64.whl", hash = "sha256:0653d012b3bf45f194e5e6a41df9258811ac8fc395579fa82958a8b76286bea4"}, - {file = "regex-2022.10.31-cp36-cp36m-win32.whl", hash = "sha256:144486e029793a733e43b2e37df16a16df4ceb62102636ff3db6033994711066"}, - {file = "regex-2022.10.31-cp36-cp36m-win_amd64.whl", hash = "sha256:c14b63c9d7bab795d17392c7c1f9aaabbffd4cf4387725a0ac69109fb3b550c6"}, - {file = "regex-2022.10.31-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:4cac3405d8dda8bc6ed499557625585544dd5cbf32072dcc72b5a176cb1271c8"}, - {file = "regex-2022.10.31-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:23cbb932cc53a86ebde0fb72e7e645f9a5eec1a5af7aa9ce333e46286caef783"}, - {file = "regex-2022.10.31-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:74bcab50a13960f2a610cdcd066e25f1fd59e23b69637c92ad470784a51b1347"}, - {file = "regex-2022.10.31-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:78d680ef3e4d405f36f0d6d1ea54e740366f061645930072d39bca16a10d8c93"}, - {file = "regex-2022.10.31-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ce6910b56b700bea7be82c54ddf2e0ed792a577dfaa4a76b9af07d550af435c6"}, - {file = "regex-2022.10.31-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:659175b2144d199560d99a8d13b2228b85e6019b6e09e556209dfb8c37b78a11"}, - {file = "regex-2022.10.31-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:1ddf14031a3882f684b8642cb74eea3af93a2be68893901b2b387c5fd92a03ec"}, - {file = "regex-2022.10.31-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:b683e5fd7f74fb66e89a1ed16076dbab3f8e9f34c18b1979ded614fe10cdc4d9"}, - {file = "regex-2022.10.31-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:2bde29cc44fa81c0a0c8686992c3080b37c488df167a371500b2a43ce9f026d1"}, - {file = "regex-2022.10.31-cp37-cp37m-musllinux_1_1_ppc64le.whl", hash = "sha256:4919899577ba37f505aaebdf6e7dc812d55e8f097331312db7f1aab18767cce8"}, - {file = "regex-2022.10.31-cp37-cp37m-musllinux_1_1_s390x.whl", hash = "sha256:9c94f7cc91ab16b36ba5ce476f1904c91d6c92441f01cd61a8e2729442d6fcf5"}, - {file = "regex-2022.10.31-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:ae1e96785696b543394a4e3f15f3f225d44f3c55dafe3f206493031419fedf95"}, - {file = "regex-2022.10.31-cp37-cp37m-win32.whl", hash = "sha256:c670f4773f2f6f1957ff8a3962c7dd12e4be54d05839b216cb7fd70b5a1df394"}, - {file = "regex-2022.10.31-cp37-cp37m-win_amd64.whl", hash = "sha256:8e0caeff18b96ea90fc0eb6e3bdb2b10ab5b01a95128dfeccb64a7238decf5f0"}, - {file = "regex-2022.10.31-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:131d4be09bea7ce2577f9623e415cab287a3c8e0624f778c1d955ec7c281bd4d"}, - {file = "regex-2022.10.31-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:e613a98ead2005c4ce037c7b061f2409a1a4e45099edb0ef3200ee26ed2a69a8"}, - {file = "regex-2022.10.31-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:052b670fafbe30966bbe5d025e90b2a491f85dfe5b2583a163b5e60a85a321ad"}, - {file = "regex-2022.10.31-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:aa62a07ac93b7cb6b7d0389d8ef57ffc321d78f60c037b19dfa78d6b17c928ee"}, - {file = "regex-2022.10.31-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5352bea8a8f84b89d45ccc503f390a6be77917932b1c98c4cdc3565137acc714"}, - {file = "regex-2022.10.31-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:20f61c9944f0be2dc2b75689ba409938c14876c19d02f7585af4460b6a21403e"}, - {file = "regex-2022.10.31-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:29c04741b9ae13d1e94cf93fca257730b97ce6ea64cfe1eba11cf9ac4e85afb6"}, - {file = "regex-2022.10.31-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:543883e3496c8b6d58bd036c99486c3c8387c2fc01f7a342b760c1ea3158a318"}, - {file = "regex-2022.10.31-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:b7a8b43ee64ca8f4befa2bea4083f7c52c92864d8518244bfa6e88c751fa8fff"}, - {file = "regex-2022.10.31-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:6a9a19bea8495bb419dc5d38c4519567781cd8d571c72efc6aa959473d10221a"}, - {file = "regex-2022.10.31-cp38-cp38-musllinux_1_1_ppc64le.whl", hash = "sha256:6ffd55b5aedc6f25fd8d9f905c9376ca44fcf768673ffb9d160dd6f409bfda73"}, - {file = "regex-2022.10.31-cp38-cp38-musllinux_1_1_s390x.whl", hash = "sha256:4bdd56ee719a8f751cf5a593476a441c4e56c9b64dc1f0f30902858c4ef8771d"}, - {file = "regex-2022.10.31-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:8ca88da1bd78990b536c4a7765f719803eb4f8f9971cc22d6ca965c10a7f2c4c"}, - {file = "regex-2022.10.31-cp38-cp38-win32.whl", hash = "sha256:5a260758454580f11dd8743fa98319bb046037dfab4f7828008909d0aa5292bc"}, - {file = "regex-2022.10.31-cp38-cp38-win_amd64.whl", hash = "sha256:5e6a5567078b3eaed93558842346c9d678e116ab0135e22eb72db8325e90b453"}, - {file = "regex-2022.10.31-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:5217c25229b6a85049416a5c1e6451e9060a1edcf988641e309dbe3ab26d3e49"}, - {file = "regex-2022.10.31-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:4bf41b8b0a80708f7e0384519795e80dcb44d7199a35d52c15cc674d10b3081b"}, - {file = "regex-2022.10.31-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0cf0da36a212978be2c2e2e2d04bdff46f850108fccc1851332bcae51c8907cc"}, - {file = "regex-2022.10.31-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d403d781b0e06d2922435ce3b8d2376579f0c217ae491e273bab8d092727d244"}, - {file = "regex-2022.10.31-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a37d51fa9a00d265cf73f3de3930fa9c41548177ba4f0faf76e61d512c774690"}, - {file = "regex-2022.10.31-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e4f781ffedd17b0b834c8731b75cce2639d5a8afe961c1e58ee7f1f20b3af185"}, - {file = "regex-2022.10.31-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d243b36fbf3d73c25e48014961e83c19c9cc92530516ce3c43050ea6276a2ab7"}, - {file = "regex-2022.10.31-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:370f6e97d02bf2dd20d7468ce4f38e173a124e769762d00beadec3bc2f4b3bc4"}, - {file = "regex-2022.10.31-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:597f899f4ed42a38df7b0e46714880fb4e19a25c2f66e5c908805466721760f5"}, - {file = "regex-2022.10.31-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:7dbdce0c534bbf52274b94768b3498abdf675a691fec5f751b6057b3030f34c1"}, - {file = "regex-2022.10.31-cp39-cp39-musllinux_1_1_ppc64le.whl", hash = "sha256:22960019a842777a9fa5134c2364efaed5fbf9610ddc5c904bd3a400973b0eb8"}, - {file = "regex-2022.10.31-cp39-cp39-musllinux_1_1_s390x.whl", hash = "sha256:7f5a3ffc731494f1a57bd91c47dc483a1e10048131ffb52d901bfe2beb6102e8"}, - {file = "regex-2022.10.31-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:7ef6b5942e6bfc5706301a18a62300c60db9af7f6368042227ccb7eeb22d0892"}, - {file = "regex-2022.10.31-cp39-cp39-win32.whl", hash = "sha256:395161bbdbd04a8333b9ff9763a05e9ceb4fe210e3c7690f5e68cedd3d65d8e1"}, - {file = "regex-2022.10.31-cp39-cp39-win_amd64.whl", hash = "sha256:957403a978e10fb3ca42572a23e6f7badff39aa1ce2f4ade68ee452dc6807692"}, - {file = "regex-2022.10.31.tar.gz", hash = "sha256:a3a98921da9a1bf8457aeee6a551948a83601689e5ecdd736894ea9bbec77e83"}, + {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"}, ] [[package]] @@ -1786,13 +1720,13 @@ idna2008 = ["idna"] [[package]] name = "rich" -version = "13.5.2" +version = "13.7.0" 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.5.2-py3-none-any.whl", hash = "sha256:146a90b3b6b47cac4a73c12866a499e9817426423f57c5a66949c086191a8808"}, - {file = "rich-13.5.2.tar.gz", hash = "sha256:fb9d6c0a0f643c99eed3875b5377a184132ba9be4d61516a55273d3554d75a39"}, + {file = "rich-13.7.0-py3-none-any.whl", hash = "sha256:6da14c108c4866ee9520bbffa71f6fe3962e193b7da68720583850cd4548e235"}, + {file = "rich-13.7.0.tar.gz", hash = "sha256:5cb5123b5cf9ee70584244246816e9114227e0b98ad9176eede6ad54bf5403fa"}, ] [package.dependencies] @@ -1805,19 +1739,19 @@ jupyter = ["ipywidgets (>=7.5.1,<9)"] [[package]] name = "setuptools" -version = "68.0.0" +version = "69.0.2" description = "Easily download, build, install, upgrade, and uninstall Python packages" optional = false -python-versions = ">=3.7" +python-versions = ">=3.8" files = [ - {file = "setuptools-68.0.0-py3-none-any.whl", hash = "sha256:11e52c67415a381d10d6b462ced9cfb97066179f0e871399e006c4ab101fc85f"}, - {file = "setuptools-68.0.0.tar.gz", hash = "sha256:baf1fdb41c6da4cd2eae722e135500da913332ab3f2f5c7d33af9b492acb5235"}, + {file = "setuptools-69.0.2-py3-none-any.whl", hash = "sha256:1e8fdff6797d3865f37397be788a4e3cba233608e9b509382a2777d25ebde7f2"}, + {file = "setuptools-69.0.2.tar.gz", hash = "sha256:735896e78a4742605974de002ac60562d286fa8051a7e2299445e8e8fbb01aa6"}, ] [package.extras] -docs = ["furo", "jaraco.packaging (>=9)", "jaraco.tidelift (>=1.4)", "pygments-github-lexers (==0.0.5)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-favicon", "sphinx-hoverxref (<2)", "sphinx-inline-tabs", "sphinx-lint", "sphinx-notfound-page (==0.8.3)", "sphinx-reredirects", "sphinxcontrib-towncrier"] -testing = ["build[virtualenv]", "filelock (>=3.4.0)", "flake8-2020", "ini2toml[lite] (>=0.9)", "jaraco.envs (>=2.2)", "jaraco.path (>=3.2.0)", "pip (>=19.1)", "pip-run (>=8.8)", "pytest (>=6)", "pytest-black (>=0.3.7)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=1.3)", "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]", "filelock (>=3.4.0)", "jaraco.envs (>=2.2)", "jaraco.path (>=3.2.0)", "pytest", "pytest-enabler", "pytest-xdist", "tomli", "virtualenv (>=13.0.0)", "wheel"] +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"] [[package]] name = "six" @@ -1832,13 +1766,13 @@ files = [ [[package]] name = "smmap" -version = "5.0.0" +version = "5.0.1" description = "A pure Python implementation of a sliding window memory map manager" optional = false -python-versions = ">=3.6" +python-versions = ">=3.7" files = [ - {file = "smmap-5.0.0-py3-none-any.whl", hash = "sha256:2aba19d6a040e78d8b09de5c57e96207b09ed71d8e55ce0959eeee6c8e190d94"}, - {file = "smmap-5.0.0.tar.gz", hash = "sha256:c840e62059cd3be204b0c9c9f74be2c09d5648eddd4580d9314c3ecde0b30936"}, + {file = "smmap-5.0.1-py3-none-any.whl", hash = "sha256:e6d8668fa5f93e706934a62d7b4db19c8d9eb8cf2adbb75ef1b675aa332b69da"}, + {file = "smmap-5.0.1.tar.gz", hash = "sha256:dceeb6c0028fdb6734471eb07c0cd2aae706ccaecab45965ee83f11c8d3b1f62"}, ] [[package]] @@ -1869,83 +1803,85 @@ pytest = ">=5.1.0,<8.0.0" [[package]] name = "textual-dev" -version = "1.1.0" +version = "1.2.1" description = "Development tools for working with Textual" optional = false python-versions = ">=3.7,<4.0" files = [ - {file = "textual_dev-1.1.0-py3-none-any.whl", hash = "sha256:c57320636098e31fa5d5c29fc3bc60829bb420da3c76bfed24db6eacf178dbc6"}, - {file = "textual_dev-1.1.0.tar.gz", hash = "sha256:e2f8ce4e1c18a16b80282f3257cd2feb49a7ede289a78908c9063ce071bb77ce"}, + {file = "textual_dev-1.2.1-py3-none-any.whl", hash = "sha256:a96ff43841cadf853dd689d68c2fc920a23ad71cfa9a33917ca53e96d1cc81f3"}, + {file = "textual_dev-1.2.1.tar.gz", hash = "sha256:0bda11adfc541e0cc9e49bdf37a8b852281dc2387bb6ff3d01f40c7a3f841684"}, ] [package.dependencies] aiohttp = ">=3.8.1" click = ">=8.1.2" msgpack = ">=1.0.3" -textual = ">=0.32.0" +textual = ">=0.33.0" typing-extensions = ">=4.4.0,<5.0.0" [[package]] name = "time-machine" -version = "2.10.0" +version = "2.13.0" description = "Travel through time in your tests." optional = false -python-versions = ">=3.7" -files = [ - {file = "time_machine-2.10.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:2d5e93c14b935d802a310c1d4694a9fe894b48a733ebd641c9a570d6f9e1f667"}, - {file = "time_machine-2.10.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:4c0dda6b132c0180941944ede357109016d161d840384c2fb1096a3a2ef619f4"}, - {file = "time_machine-2.10.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:900517e4a4121bf88527343d6aea2b5c99df134815bb8271ef589ec792502a71"}, - {file = "time_machine-2.10.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:860279c7f9413bc763b3d1aee622937c4538472e2e58ad668546b49a797cb9fb"}, - {file = "time_machine-2.10.0-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5f451be286d50ec9b685198c7f76cea46538b8c57ec816f60edf5eb68d71c4f4"}, - {file = "time_machine-2.10.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:b1b07f5da833b2d8ea170cdf15a322c6fa2c6f7e9097a1bea435adc597cdcb5d"}, - {file = "time_machine-2.10.0-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:6b3a529ecc819488783e371df5ad315e790b9558c6945a236b13d7cb9ab73b9a"}, - {file = "time_machine-2.10.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:51e36491bd4a43f8a937ca7c0d1a2287b8998f41306f47ebed250a02f93d2fe4"}, - {file = "time_machine-2.10.0-cp310-cp310-win32.whl", hash = "sha256:1e9973091ad3272c719dafae35a5bb08fa5433c2902224d0f745657f9e3ac327"}, - {file = "time_machine-2.10.0-cp310-cp310-win_amd64.whl", hash = "sha256:ab82ea5a59faa1faa7397465f2edd94789a13f543daa02d16244906339100080"}, - {file = "time_machine-2.10.0-cp310-cp310-win_arm64.whl", hash = "sha256:55bc6d666966fa2e6283d7433ebe875be37684a847eaa802075433c1ab3a377a"}, - {file = "time_machine-2.10.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:99fc366cb4fa26d81f12fa36a929db0da89d99909e28231c045e0f1277e0db84"}, - {file = "time_machine-2.10.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:5969f325c20bdcb7f8917a6ac2ef328ec41cc2e256320a99dfe38b4080eeae71"}, - {file = "time_machine-2.10.0-cp311-cp311-macosx_13_0_arm64.whl", hash = "sha256:a1a5e283ab47b28205f33fa3c5a2df3fd9f07f09add63dbe76637c3633893a23"}, - {file = "time_machine-2.10.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4083ec185ab9ece3e5a7ca7a7589114a555f04bcff31b29d4eb47a37e87d97fe"}, - {file = "time_machine-2.10.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:cbe45f88399b8af299136435a2363764d5fa6d16a936e4505081b6ea32ff3e18"}, - {file = "time_machine-2.10.0-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3d149a3fae8a06a3593361496ec036a27906fed478ade23ffc01dd402acd0b37"}, - {file = "time_machine-2.10.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:2e05306f63df3c7760170af6e77e1b37405b7c7c4a97cc9fdf0105f1094b1b1c"}, - {file = "time_machine-2.10.0-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:3d6d7b7680e34dbe60da34d75d6d5f31b6206c7149c0de8a7b0f0311d0ef7e3a"}, - {file = "time_machine-2.10.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:91b8b06e09e1dfd53dafe272d41b60690d6f8806d7194c62982b003a088dc423"}, - {file = "time_machine-2.10.0-cp311-cp311-win32.whl", hash = "sha256:6241a1742657622ebdcd66cf6045c92e0ec6ca6365c55434cc7fea945008192c"}, - {file = "time_machine-2.10.0-cp311-cp311-win_amd64.whl", hash = "sha256:48cce6dcb7118ba4a58537c6de4d1dd6e7ad6ea15d0257d6e0003b45c4a839c2"}, - {file = "time_machine-2.10.0-cp311-cp311-win_arm64.whl", hash = "sha256:8cb6285095efa0833fd0301e159748a06e950c7744dc3d38e92e7607e2232d5a"}, - {file = "time_machine-2.10.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:8829ca7ed939419c2a23c360101edc51e3b57f40708d304b6aed16214d8b2a1f"}, - {file = "time_machine-2.10.0-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7b5b60bc00ad2efa5fefee117e5611a28b26f563f1a64df118d1d2f2590a679a"}, - {file = "time_machine-2.10.0-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b1491fb647568134d38b06e844783d3069f5811405e9a3906eff88d55403e327"}, - {file = "time_machine-2.10.0-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e78f2759a63fcc7660d283e22054c7cfa7468fad1ad86d0846819b6ea958d63f"}, - {file = "time_machine-2.10.0-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:30881f263332245a665a49d0e30fda135597c4e18f2efa9c6759c224419c36a5"}, - {file = "time_machine-2.10.0-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:e93750309093275340e0e95bb270801ec9cbf2ee8702d71031f4ccd8cc91dd7f"}, - {file = "time_machine-2.10.0-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:a906bb338a6be978b83f09f09d8b24737239330f280c890ecbf1c13828e1838c"}, - {file = "time_machine-2.10.0-cp37-cp37m-win32.whl", hash = "sha256:10c8b170920d3f83dad2268ae8d5e1d8bb431a85198e32d778e6f3a1f93b172d"}, - {file = "time_machine-2.10.0-cp37-cp37m-win_amd64.whl", hash = "sha256:5efc4cc914d93138944c488fdebd6e4290273e3ac795d5c7a744af29eb04ce0f"}, - {file = "time_machine-2.10.0-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:1787887168e36f57d5ca1abf1b9d065a55eb67067df2fa23aaa4382da36f7098"}, - {file = "time_machine-2.10.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:26a8cc1f8e9f4f69ea3f50b9b9e3a699e80e44ac9359a867208be6adac30fc60"}, - {file = "time_machine-2.10.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e07e2c6c299c5509c72cc221a19f4bf680c87c793727a3127a29e18ddad3db13"}, - {file = "time_machine-2.10.0-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6f3e5f263a623148a448756a332aad45e65a59876fcb2511f7f61213e6d3ec3e"}, - {file = "time_machine-2.10.0-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a2b3abcb48d7ca7ed95e5d99220317b7ce31378636bb020cabfa62f9099e7dad"}, - {file = "time_machine-2.10.0-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:545a813b7407c33dee388aa380449e79f57f02613ea149c6e907fc9ca3d53e64"}, - {file = "time_machine-2.10.0-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:458b52673ec83d10da279d989d7a6ad1e60c93e4ba986210d72e6c78e17102f4"}, - {file = "time_machine-2.10.0-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:acb2ca50d779d39eab1d0fab48697359e4ffc1aedfa58b79cd3a86ee13253834"}, - {file = "time_machine-2.10.0-cp38-cp38-win32.whl", hash = "sha256:648fec54917a7e67acca38ed8e736b206e8a9688730e13e1cf7a74bcce89dec7"}, - {file = "time_machine-2.10.0-cp38-cp38-win_amd64.whl", hash = "sha256:3ed92d2a6e2c2b7a0c8161ecca5d012041b7ba147cbdfb2b7f62f45c02615111"}, - {file = "time_machine-2.10.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:6d2588581d3071d556f96954d084b7b99701e54120bb29dfadaab04791ef6ae4"}, - {file = "time_machine-2.10.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:185f7a4228e993ddae610e24fb3c7e7891130ebb6a40f42d58ea3be0bfafe1b1"}, - {file = "time_machine-2.10.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f8225eb813ea9488de99e61569fc1b2d148d236473a84c6758cc436ffef4c043"}, - {file = "time_machine-2.10.0-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7f03ac22440b00abd1027bfb7dd793dfeffb72dda26f336f4d561835e0ce6117"}, - {file = "time_machine-2.10.0-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4252f4daef831556e6685853d7a61b02910d0465528c549f179ea4e36aaeb14c"}, - {file = "time_machine-2.10.0-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:58c65bf4775fca62e1678cb234f1ca90254e811d978971c819d2cd24e1b7f136"}, - {file = "time_machine-2.10.0-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:8527ac8fca7b92556c3c4c0f08e0bea995202db4be5b7d95b9b2ccbcb63649f2"}, - {file = "time_machine-2.10.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:4684308d749fdb0c22af173b081206d2a5a85d2154a683a7f4a60c4b667f7a65"}, - {file = "time_machine-2.10.0-cp39-cp39-win32.whl", hash = "sha256:2adc24cf25b7e8d08aea2b109cc42c5db76817b07ee709fae5c66afa4ec7bc6e"}, - {file = "time_machine-2.10.0-cp39-cp39-win_amd64.whl", hash = "sha256:36f5be6f3042734fca043bedafbfbb6ad4809352e40b3283cb46b151a823674c"}, - {file = "time_machine-2.10.0-cp39-cp39-win_arm64.whl", hash = "sha256:c1775a949dd830579d1af5a271ec53d920dc01657035ad305f55c5a1ac9b9f1e"}, - {file = "time_machine-2.10.0.tar.gz", hash = "sha256:64fd89678cf589fc5554c311417128b2782222dd65f703bf248ef41541761da0"}, +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] @@ -1974,55 +1910,163 @@ files = [ ] [[package]] -name = "typed-ast" -version = "1.5.5" -description = "a fork of Python 2 and 3 ast modules with type comment support" -optional = false -python-versions = ">=3.6" +name = "tree-sitter" +version = "0.20.4" +description = "Python bindings for the Tree-Sitter parsing library" +optional = true +python-versions = ">=3.3" +files = [ + {file = "tree_sitter-0.20.4-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:c259b9bcb596e54f54713eb3951226fc834d65289940f4bfdcdf519f08e8e876"}, + {file = "tree_sitter-0.20.4-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:88da7e2e4c69881cd63916cc24ae0b809f96aae331da45b418ae6b2d1ed2ca19"}, + {file = "tree_sitter-0.20.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:66a68b156ba131e9d8dff4a1f72037f4b368cc50c58f18905a91743ae1d1c795"}, + {file = "tree_sitter-0.20.4-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ae28e25d551f406807011487bdfb9728041e656b30b554fa7f3391ab64ed69f9"}, + {file = "tree_sitter-0.20.4-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:36b10c9c69e825ba65cf9b0f77668bf33e70d2a5764b64ad6f133f8cc9220f09"}, + {file = "tree_sitter-0.20.4-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:7c18c64ddd44b75b7e1660b9793753eda427e4b145b6216d4b2d2e9b200c74f2"}, + {file = "tree_sitter-0.20.4-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:e9e9e594bbefb76ad9ea256f5c87eba7591b4758854d3df83ce4df415933a006"}, + {file = "tree_sitter-0.20.4-cp310-cp310-win32.whl", hash = "sha256:b4755229dc18644fe48bcab974bde09b171fcb6ef625d3cb5ece5c6198f4223e"}, + {file = "tree_sitter-0.20.4-cp310-cp310-win_amd64.whl", hash = "sha256:f792684cee8a46d9194d9f4223810e54ccc704470c5777538d59fbde0a4c91bf"}, + {file = "tree_sitter-0.20.4-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:9d22ee75f45836554ee6a11e50dd8f9827941e67c49fce9a0790245b899811a9"}, + {file = "tree_sitter-0.20.4-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:2a0ffd76dd991ba745bb5d0ba1d583bec85726d3ddef8c9685dc8636a619adde"}, + {file = "tree_sitter-0.20.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:060d4e5b803be0975f1ac46e54a292eab0701296ccd912f6cdac3f7331e29143"}, + {file = "tree_sitter-0.20.4-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:822e02366dbf223697b2b56b8f91aa5b60571f9fe7c998988a381db1c69604e9"}, + {file = "tree_sitter-0.20.4-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:527ca72c6a8f60fa719af37fa86f58b7ad0e07b8f74d1c1c7e926c5c888a7e6b"}, + {file = "tree_sitter-0.20.4-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:a418ca71309ea7052e076f08d623f33f58eae01a8e8cdc1e6d3a01b5b8ddebfe"}, + {file = "tree_sitter-0.20.4-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:08c3ba2561b61a83c28ca06a0bce2a5ffcfb6b39f9d27a45e5ebd9cad2bedb7f"}, + {file = "tree_sitter-0.20.4-cp311-cp311-win32.whl", hash = "sha256:8d04c75a389b2de94952d602264852acff8cd3ed1ccf8a2492a080973d5ddd58"}, + {file = "tree_sitter-0.20.4-cp311-cp311-win_amd64.whl", hash = "sha256:ba9215c0e7529d9eb370528e5d99b7389d14a7eae94f07d14fa9dab18f267c62"}, + {file = "tree_sitter-0.20.4-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:c4c1af5ed4306071d30970c83ec882520a7bf5d8053996dbc4aa5c59238d4990"}, + {file = "tree_sitter-0.20.4-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:9d70bfa550cf22c9cea9b3c0d18b889fc4f2a7e9dcf1d6cc93f49fa9d4a94954"}, + {file = "tree_sitter-0.20.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6de537bca0641775d8d175d37303d54998980fc0d997dd9aa89e16b415bf0cc3"}, + {file = "tree_sitter-0.20.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9b1c0f8c0e3e50267566f5116cdceedf4e23e8c08b55ef3becbe954a11b16e84"}, + {file = "tree_sitter-0.20.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:20ef2ee6d9bb8e21713949e5ff769ed670fe1217f95b7eeb6c675788438c1e6e"}, + {file = "tree_sitter-0.20.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:b6fd1c881ab0de5faa67168db2d001eee32be5482cb4e0b21b217689a05b6fe4"}, + {file = "tree_sitter-0.20.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:bf47047420021d50aec529cb66387c90562350b499ddf56ecef1fc8255439e30"}, + {file = "tree_sitter-0.20.4-cp312-cp312-win32.whl", hash = "sha256:c16b48378041fc9702b6aa3480f2ffa49ca8ea58141a862acd569e5a0679655f"}, + {file = "tree_sitter-0.20.4-cp312-cp312-win_amd64.whl", hash = "sha256:973e871167079a1b1d7304d361449253efbe2a6974728ad563cf407bd02ddccb"}, + {file = "tree_sitter-0.20.4-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:9d33a55598dd18a4d8b869a3417de82a4812c3a7dc7e61cb025ece3e9c3e4e96"}, + {file = "tree_sitter-0.20.4-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7cee6955c2c97fc5927a41c7a8b06647c4b4d9b99b8a1581bf1183435c8cec3e"}, + {file = "tree_sitter-0.20.4-cp36-cp36m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5022bea67e479ad212be7c05b983a72e297a013efb4e8ea5b5b4d7da79a9fdef"}, + {file = "tree_sitter-0.20.4-cp36-cp36m-musllinux_1_1_aarch64.whl", hash = "sha256:640f60a5b966f0990338f1bf559455c3dcb822bc4329d82b3d42f32a48374dfe"}, + {file = "tree_sitter-0.20.4-cp36-cp36m-musllinux_1_1_x86_64.whl", hash = "sha256:0e83f641fe6f27d91bd4d259fff5d35de1567d3f581b9efe9bbd5be50fe4ddc7"}, + {file = "tree_sitter-0.20.4-cp36-cp36m-win32.whl", hash = "sha256:ce6a85027c66fa3f09d482cc6d41927ea40955f7f33b86aedd26dd932709a2c9"}, + {file = "tree_sitter-0.20.4-cp36-cp36m-win_amd64.whl", hash = "sha256:fe10779347a6c067af29cb37fd4b75fa96c5cb68f587cc9530b70fe3f2a51a55"}, + {file = "tree_sitter-0.20.4-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:28d5f84e34e276887e3a240b60906ca7e2b51e975f3145c3149ceed977a69508"}, + {file = "tree_sitter-0.20.4-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6c913b65cbe10996116988ac436748f24883b5097e58274223e89bb2c5d1bb1a"}, + {file = "tree_sitter-0.20.4-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ecaed46241e071752195a628bb97d2b740f2fde9e34f8a74456a4ea8bb26df88"}, + {file = "tree_sitter-0.20.4-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:b641e88a97eab002a1736d93ef5a4beac90ea4fd6e25affd1831319b99f456c9"}, + {file = "tree_sitter-0.20.4-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:327c40f439c6155e4eee54c4657e4701a04f5f4816d9defdcb836bf65bf83d21"}, + {file = "tree_sitter-0.20.4-cp37-cp37m-win32.whl", hash = "sha256:1b7c1d95f006b3de42fbf4045bd00c273d113e372fcb6a5378e74ed120c12032"}, + {file = "tree_sitter-0.20.4-cp37-cp37m-win_amd64.whl", hash = "sha256:6140d037239a41046f5d34fba5e0374ee697adb4b48b90579c618b5402781c11"}, + {file = "tree_sitter-0.20.4-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:f42fd1104efaad8151370f1936e2a488b7337a5d24544a9ab59ba4c4010b1272"}, + {file = "tree_sitter-0.20.4-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:7859717c5d62ee386b3d036cab8ed0f88f8c027b6b4ae476a55a8c5fb8aab713"}, + {file = "tree_sitter-0.20.4-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:fdd361fe1cc68db68b4d85165641275e34b86cc26b2bab932790204fa14824dc"}, + {file = "tree_sitter-0.20.4-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9b8d7539075606027b67764543463ff2bc4e52f4158ef6dc419c9f5625aa5383"}, + {file = "tree_sitter-0.20.4-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:78e76307f05aca6cde72f3307b4d53701f34ae45f2248ceb83d1626051e201fd"}, + {file = "tree_sitter-0.20.4-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:dd8c352f4577f61098d06cf3feb7fd214259f41b5036b81003860ed54d16b448"}, + {file = "tree_sitter-0.20.4-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:281f3e5382d1bd7fccc88d1afe68c915565bc24f8b8dd4844079d46c7815b8a7"}, + {file = "tree_sitter-0.20.4-cp38-cp38-win32.whl", hash = "sha256:6a77ac3cdcddd80cdd1fd394318bff99f94f37e08d235aaefccb87e1224946e5"}, + {file = "tree_sitter-0.20.4-cp38-cp38-win_amd64.whl", hash = "sha256:8eee8adf54033dc48eab84b040f4d7b32355a964c4ae0aae5dfbdc4dbc3364ca"}, + {file = "tree_sitter-0.20.4-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:e89f6508e30fce05e2c724725d022db30d877817b9d64f933506ffb3a3f4a2c2"}, + {file = "tree_sitter-0.20.4-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:7fb6286bb1fae663c45ff0700ec88fb9b50a81eed2bae8a291f95fcf8cc19547"}, + {file = "tree_sitter-0.20.4-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:11e93f8b4bbae04070416a82257a7ab2eb0afb76e093ae3ea73bd63b792f6846"}, + {file = "tree_sitter-0.20.4-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8250725c5f78929aeb2c71db5dca76f1ef448389ca16f9439161f90978bb8478"}, + {file = "tree_sitter-0.20.4-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d404a8ca9de9b0843844f0cd4d423f46bc46375ab8afb63b1d8ec01201457ac8"}, + {file = "tree_sitter-0.20.4-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:0f2422c9ee70ba972dfc3943746e6cf7fc03725a866908950245bda9ccfc7301"}, + {file = "tree_sitter-0.20.4-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:21a937942e4729abbe778a609d2c218574436cb351c36fba89ef3c8c6066ec78"}, + {file = "tree_sitter-0.20.4-cp39-cp39-win32.whl", hash = "sha256:427a9a39360cc1816e28f8182550e478e4ba983595a2565ab9dfe32ea1b03fd7"}, + {file = "tree_sitter-0.20.4-cp39-cp39-win_amd64.whl", hash = "sha256:7095bb9aff297fa9c6026bf8914fd295997d714d1a6ee9a1edf7282c772f9f64"}, + {file = "tree_sitter-0.20.4-pp310-pypy310_pp73-macosx_10_9_x86_64.whl", hash = "sha256:859260b90f0e3867ae840e39f54e830f607b3bc531bc21deeeeaa8a30cbb89ad"}, + {file = "tree_sitter-0.20.4-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0dfc14be73cf46126660a3aecdd0396e69562ad1a902245225ca7bd29649594e"}, + {file = "tree_sitter-0.20.4-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5ec46355bf3ff23f54d5e365871ffd3e05cfbc65d1b36a8be7c0bcbda30a1d43"}, + {file = "tree_sitter-0.20.4-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:d933a942fde39876b99c36f12aa3764e4a555ae9366c10ce6cca8c16341c1bbf"}, + {file = "tree_sitter-0.20.4-pp37-pypy37_pp73-macosx_10_9_x86_64.whl", hash = "sha256:a7eec3b55135fe851a38fa248c9fd75fc3d58ceb6e1865b795e416e4d598c2a1"}, + {file = "tree_sitter-0.20.4-pp37-pypy37_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dfc76225529ee14a53e84413480ce81ec3c44eaa0455c140e961c90ac3118ead"}, + {file = "tree_sitter-0.20.4-pp37-pypy37_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ccf0396e47efffc0b528959a8f2e2346a98297579f867e9e1834c2aad4be829c"}, + {file = "tree_sitter-0.20.4-pp37-pypy37_pp73-win_amd64.whl", hash = "sha256:a15fbabd3bc8e29c48289c156d743e69f5ec72bb125cf44f7adbdaa1937c3da6"}, + {file = "tree_sitter-0.20.4-pp38-pypy38_pp73-macosx_10_9_x86_64.whl", hash = "sha256:36f8adf2126f496cf376b6e4b707cba061c25beb17841727eef6f0e083e53e1f"}, + {file = "tree_sitter-0.20.4-pp38-pypy38_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:841efb40c116ab0a066924925409a8a4dcffeb39a151c0b2a1c2abe56ad4fb42"}, + {file = "tree_sitter-0.20.4-pp38-pypy38_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2051e8a70fd8426f27a43dad71d11929a62ce30a9b1eb65bba0ed79e82481592"}, + {file = "tree_sitter-0.20.4-pp38-pypy38_pp73-win_amd64.whl", hash = "sha256:99a3c2824d4cfcffd9f961176891426bde2cb36ece5280c61480be93319c23c4"}, + {file = "tree_sitter-0.20.4-pp39-pypy39_pp73-macosx_10_9_x86_64.whl", hash = "sha256:72830dc85a10430eca3d56739b7efcd7a05459c8d425f08c1aee6179ab7f13a9"}, + {file = "tree_sitter-0.20.4-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4992dd226055b6cd0a4f5661c66b799a73d3eff716302e0f7ab06594ee12d49f"}, + {file = "tree_sitter-0.20.4-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a66d95bbf92175cdc295d6d77f330942811f02e3aaf3fc64431cb749683b2f7d"}, + {file = "tree_sitter-0.20.4-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:a25b1087e4f7825b2458dacf5f4b0be2938f78e850e822edca1ff4994b56081a"}, + {file = "tree_sitter-0.20.4.tar.gz", hash = "sha256:6adb123e2f3e56399bbf2359924633c882cc40ee8344885200bca0922f713be5"}, +] + +[package.dependencies] +setuptools = {version = ">=60.0.0", markers = "python_version >= \"3.12\""} + +[[package]] +name = "tree-sitter-languages" +version = "1.8.0" +description = "Binary Python wheels for all tree sitter languages." +optional = true +python-versions = "*" files = [ - {file = "typed_ast-1.5.5-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:4bc1efe0ce3ffb74784e06460f01a223ac1f6ab31c6bc0376a21184bf5aabe3b"}, - {file = "typed_ast-1.5.5-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:5f7a8c46a8b333f71abd61d7ab9255440d4a588f34a21f126bbfc95f6049e686"}, - {file = "typed_ast-1.5.5-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:597fc66b4162f959ee6a96b978c0435bd63791e31e4f410622d19f1686d5e769"}, - {file = "typed_ast-1.5.5-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d41b7a686ce653e06c2609075d397ebd5b969d821b9797d029fccd71fdec8e04"}, - {file = "typed_ast-1.5.5-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:5fe83a9a44c4ce67c796a1b466c270c1272e176603d5e06f6afbc101a572859d"}, - {file = "typed_ast-1.5.5-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:d5c0c112a74c0e5db2c75882a0adf3133adedcdbfd8cf7c9d6ed77365ab90a1d"}, - {file = "typed_ast-1.5.5-cp310-cp310-win_amd64.whl", hash = "sha256:e1a976ed4cc2d71bb073e1b2a250892a6e968ff02aa14c1f40eba4f365ffec02"}, - {file = "typed_ast-1.5.5-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:c631da9710271cb67b08bd3f3813b7af7f4c69c319b75475436fcab8c3d21bee"}, - {file = "typed_ast-1.5.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:b445c2abfecab89a932b20bd8261488d574591173d07827c1eda32c457358b18"}, - {file = "typed_ast-1.5.5-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cc95ffaaab2be3b25eb938779e43f513e0e538a84dd14a5d844b8f2932593d88"}, - {file = "typed_ast-1.5.5-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:61443214d9b4c660dcf4b5307f15c12cb30bdfe9588ce6158f4a005baeb167b2"}, - {file = "typed_ast-1.5.5-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:6eb936d107e4d474940469e8ec5b380c9b329b5f08b78282d46baeebd3692dc9"}, - {file = "typed_ast-1.5.5-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:e48bf27022897577d8479eaed64701ecaf0467182448bd95759883300ca818c8"}, - {file = "typed_ast-1.5.5-cp311-cp311-win_amd64.whl", hash = "sha256:83509f9324011c9a39faaef0922c6f720f9623afe3fe220b6d0b15638247206b"}, - {file = "typed_ast-1.5.5-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:44f214394fc1af23ca6d4e9e744804d890045d1643dd7e8229951e0ef39429b5"}, - {file = "typed_ast-1.5.5-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:118c1ce46ce58fda78503eae14b7664163aa735b620b64b5b725453696f2a35c"}, - {file = "typed_ast-1.5.5-cp36-cp36m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:be4919b808efa61101456e87f2d4c75b228f4e52618621c77f1ddcaae15904fa"}, - {file = "typed_ast-1.5.5-cp36-cp36m-musllinux_1_1_aarch64.whl", hash = "sha256:fc2b8c4e1bc5cd96c1a823a885e6b158f8451cf6f5530e1829390b4d27d0807f"}, - {file = "typed_ast-1.5.5-cp36-cp36m-musllinux_1_1_x86_64.whl", hash = "sha256:16f7313e0a08c7de57f2998c85e2a69a642e97cb32f87eb65fbfe88381a5e44d"}, - {file = "typed_ast-1.5.5-cp36-cp36m-win_amd64.whl", hash = "sha256:2b946ef8c04f77230489f75b4b5a4a6f24c078be4aed241cfabe9cbf4156e7e5"}, - {file = "typed_ast-1.5.5-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:2188bc33d85951ea4ddad55d2b35598b2709d122c11c75cffd529fbc9965508e"}, - {file = "typed_ast-1.5.5-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0635900d16ae133cab3b26c607586131269f88266954eb04ec31535c9a12ef1e"}, - {file = "typed_ast-1.5.5-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:57bfc3cf35a0f2fdf0a88a3044aafaec1d2f24d8ae8cd87c4f58d615fb5b6311"}, - {file = "typed_ast-1.5.5-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:fe58ef6a764de7b4b36edfc8592641f56e69b7163bba9f9c8089838ee596bfb2"}, - {file = "typed_ast-1.5.5-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:d09d930c2d1d621f717bb217bf1fe2584616febb5138d9b3e8cdd26506c3f6d4"}, - {file = "typed_ast-1.5.5-cp37-cp37m-win_amd64.whl", hash = "sha256:d40c10326893ecab8a80a53039164a224984339b2c32a6baf55ecbd5b1df6431"}, - {file = "typed_ast-1.5.5-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:fd946abf3c31fb50eee07451a6aedbfff912fcd13cf357363f5b4e834cc5e71a"}, - {file = "typed_ast-1.5.5-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:ed4a1a42df8a3dfb6b40c3d2de109e935949f2f66b19703eafade03173f8f437"}, - {file = "typed_ast-1.5.5-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:045f9930a1550d9352464e5149710d56a2aed23a2ffe78946478f7b5416f1ede"}, - {file = "typed_ast-1.5.5-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:381eed9c95484ceef5ced626355fdc0765ab51d8553fec08661dce654a935db4"}, - {file = "typed_ast-1.5.5-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:bfd39a41c0ef6f31684daff53befddae608f9daf6957140228a08e51f312d7e6"}, - {file = "typed_ast-1.5.5-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:8c524eb3024edcc04e288db9541fe1f438f82d281e591c548903d5b77ad1ddd4"}, - {file = "typed_ast-1.5.5-cp38-cp38-win_amd64.whl", hash = "sha256:7f58fabdde8dcbe764cef5e1a7fcb440f2463c1bbbec1cf2a86ca7bc1f95184b"}, - {file = "typed_ast-1.5.5-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:042eb665ff6bf020dd2243307d11ed626306b82812aba21836096d229fdc6a10"}, - {file = "typed_ast-1.5.5-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:622e4a006472b05cf6ef7f9f2636edc51bda670b7bbffa18d26b255269d3d814"}, - {file = "typed_ast-1.5.5-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1efebbbf4604ad1283e963e8915daa240cb4bf5067053cf2f0baadc4d4fb51b8"}, - {file = "typed_ast-1.5.5-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f0aefdd66f1784c58f65b502b6cf8b121544680456d1cebbd300c2c813899274"}, - {file = "typed_ast-1.5.5-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:48074261a842acf825af1968cd912f6f21357316080ebaca5f19abbb11690c8a"}, - {file = "typed_ast-1.5.5-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:429ae404f69dc94b9361bb62291885894b7c6fb4640d561179548c849f8492ba"}, - {file = "typed_ast-1.5.5-cp39-cp39-win_amd64.whl", hash = "sha256:335f22ccb244da2b5c296e6f96b06ee9bed46526db0de38d2f0e5a6597b81155"}, - {file = "typed_ast-1.5.5.tar.gz", hash = "sha256:94282f7a354f36ef5dbce0ef3467ebf6a258e370ab33d5b40c249fa996e590dd"}, + {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"}, ] +[package.dependencies] +tree-sitter = "*" + [[package]] name = "types-setuptools" version = "67.8.0.0" @@ -2035,25 +2079,50 @@ files = [ ] [[package]] -name = "typing-extensions" -version = "4.7.1" -description = "Backported and Experimental Type Hints for Python 3.7+" +name = "types-tree-sitter" +version = "0.20.1.6" +description = "Typing stubs for tree-sitter" optional = false python-versions = ">=3.7" files = [ - {file = "typing_extensions-4.7.1-py3-none-any.whl", hash = "sha256:440d5dd3af93b060174bf433bccd69b0babc3b15b1a8dca43789fd7f61514b36"}, - {file = "typing_extensions-4.7.1.tar.gz", hash = "sha256:b75ddc264f0ba5615db7ba217daeb99701ad295353c45f9e95963337ceeeffb2"}, + {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"}, +] + +[[package]] +name = "types-tree-sitter-languages" +version = "1.8.0.0" +description = "Typing stubs for tree-sitter-languages" +optional = false +python-versions = ">=3.7" +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"}, +] + +[package.dependencies] +types-tree-sitter = "*" + +[[package]] +name = "typing-extensions" +version = "4.8.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"}, ] [[package]] name = "tzdata" -version = "2022.7" +version = "2023.3" description = "Provider of IANA time zone data" optional = false python-versions = ">=2" files = [ - {file = "tzdata-2022.7-py2.py3-none-any.whl", hash = "sha256:2b88858b0e3120792a3c0635c23daf36a7d7eeeca657c323da299d2094402a0d"}, - {file = "tzdata-2022.7.tar.gz", hash = "sha256:fe5f866eddd8b96e9fcba978f8e503c909b19ea7efda11e52e39494bad3a7bfa"}, + {file = "tzdata-2023.3-py2.py3-none-any.whl", hash = "sha256:7e65763eef3120314099b6939b5546db7adce1e7d6f2e179e3df563c70511eda"}, + {file = "tzdata-2023.3.tar.gz", hash = "sha256:11ef1e08e54acb0d4f95bdb1be05da659673de4acbd21bf9c69e94cc5e907a3a"}, ] [[package]] @@ -2072,37 +2141,35 @@ test = ["coverage", "pytest", "pytest-cov"] [[package]] name = "urllib3" -version = "2.0.4" +version = "2.1.0" description = "HTTP library with thread-safe connection pooling, file post, and more." optional = false -python-versions = ">=3.7" +python-versions = ">=3.8" files = [ - {file = "urllib3-2.0.4-py3-none-any.whl", hash = "sha256:de7df1803967d2c2a98e4b11bb7d6bd9210474c46e8a0401514e3a42a75ebde4"}, - {file = "urllib3-2.0.4.tar.gz", hash = "sha256:8d22f86aae8ef5e410d4f539fde9ce6b2113a001bb4d189e0aed70642d602b11"}, + {file = "urllib3-2.1.0-py3-none-any.whl", hash = "sha256:55901e917a5896a349ff771be919f8bd99aff50b79fe58fec595eb37bbc56bb3"}, + {file = "urllib3-2.1.0.tar.gz", hash = "sha256:df7aa8afb0148fa78488e7899b2c59b5f4ffcfa82e6c54ccb9dd37c1d7b52d54"}, ] [package.extras] brotli = ["brotli (>=1.0.9)", "brotlicffi (>=0.8.0)"] -secure = ["certifi", "cryptography (>=1.9)", "idna (>=2.0.0)", "pyopenssl (>=17.1.0)", "urllib3-secure-extra"] socks = ["pysocks (>=1.5.6,!=1.5.7,<2.0)"] zstd = ["zstandard (>=0.18.0)"] [[package]] name = "virtualenv" -version = "20.24.4" +version = "20.25.0" description = "Virtual Python Environment builder" optional = false python-versions = ">=3.7" files = [ - {file = "virtualenv-20.24.4-py3-none-any.whl", hash = "sha256:29c70bb9b88510f6414ac3e55c8b413a1f96239b6b789ca123437d5e892190cb"}, - {file = "virtualenv-20.24.4.tar.gz", hash = "sha256:772b05bfda7ed3b8ecd16021ca9716273ad9f4467c801f27e83ac73430246dca"}, + {file = "virtualenv-20.25.0-py3-none-any.whl", hash = "sha256:4238949c5ffe6876362d9c0180fc6c3a824a7b12b80604eeb8085f2ed7460de3"}, + {file = "virtualenv-20.25.0.tar.gz", hash = "sha256:bf51c0d9c7dd63ea8e44086fa1e4fb1093a31e963b86959257378aef020e1f1b"}, ] [package.dependencies] distlib = ">=0.3.7,<1" filelock = ">=3.12.2,<4" -importlib-metadata = {version = ">=6.6", markers = "python_version < \"3.8\""} -platformdirs = ">=3.9.1,<4" +platformdirs = ">=3.9.1,<5" [package.extras] docs = ["furo (>=2023.7.26)", "proselint (>=0.13)", "sphinx (>=7.1.2)", "sphinx-argparse (>=0.4)", "sphinxcontrib-towncrier (>=0.2.1a0)", "towncrier (>=23.6)"] @@ -2149,108 +2216,126 @@ watchmedo = ["PyYAML (>=3.10)"] [[package]] name = "yarl" -version = "1.9.2" +version = "1.9.4" description = "Yet another URL library" optional = false python-versions = ">=3.7" files = [ - {file = "yarl-1.9.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:8c2ad583743d16ddbdf6bb14b5cd76bf43b0d0006e918809d5d4ddf7bde8dd82"}, - {file = "yarl-1.9.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:82aa6264b36c50acfb2424ad5ca537a2060ab6de158a5bd2a72a032cc75b9eb8"}, - {file = "yarl-1.9.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:c0c77533b5ed4bcc38e943178ccae29b9bcf48ffd1063f5821192f23a1bd27b9"}, - {file = "yarl-1.9.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ee4afac41415d52d53a9833ebae7e32b344be72835bbb589018c9e938045a560"}, - {file = "yarl-1.9.2-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9bf345c3a4f5ba7f766430f97f9cc1320786f19584acc7086491f45524a551ac"}, - {file = "yarl-1.9.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2a96c19c52ff442a808c105901d0bdfd2e28575b3d5f82e2f5fd67e20dc5f4ea"}, - {file = "yarl-1.9.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:891c0e3ec5ec881541f6c5113d8df0315ce5440e244a716b95f2525b7b9f3608"}, - {file = "yarl-1.9.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c3a53ba34a636a256d767c086ceb111358876e1fb6b50dfc4d3f4951d40133d5"}, - {file = "yarl-1.9.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:566185e8ebc0898b11f8026447eacd02e46226716229cea8db37496c8cdd26e0"}, - {file = "yarl-1.9.2-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:2b0738fb871812722a0ac2154be1f049c6223b9f6f22eec352996b69775b36d4"}, - {file = "yarl-1.9.2-cp310-cp310-musllinux_1_1_ppc64le.whl", hash = "sha256:32f1d071b3f362c80f1a7d322bfd7b2d11e33d2adf395cc1dd4df36c9c243095"}, - {file = "yarl-1.9.2-cp310-cp310-musllinux_1_1_s390x.whl", hash = "sha256:e9fdc7ac0d42bc3ea78818557fab03af6181e076a2944f43c38684b4b6bed8e3"}, - {file = "yarl-1.9.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:56ff08ab5df8429901ebdc5d15941b59f6253393cb5da07b4170beefcf1b2528"}, - {file = "yarl-1.9.2-cp310-cp310-win32.whl", hash = "sha256:8ea48e0a2f931064469bdabca50c2f578b565fc446f302a79ba6cc0ee7f384d3"}, - {file = "yarl-1.9.2-cp310-cp310-win_amd64.whl", hash = "sha256:50f33040f3836e912ed16d212f6cc1efb3231a8a60526a407aeb66c1c1956dde"}, - {file = "yarl-1.9.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:646d663eb2232d7909e6601f1a9107e66f9791f290a1b3dc7057818fe44fc2b6"}, - {file = "yarl-1.9.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:aff634b15beff8902d1f918012fc2a42e0dbae6f469fce134c8a0dc51ca423bb"}, - {file = "yarl-1.9.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a83503934c6273806aed765035716216cc9ab4e0364f7f066227e1aaea90b8d0"}, - {file = "yarl-1.9.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b25322201585c69abc7b0e89e72790469f7dad90d26754717f3310bfe30331c2"}, - {file = "yarl-1.9.2-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:22a94666751778629f1ec4280b08eb11815783c63f52092a5953faf73be24191"}, - {file = "yarl-1.9.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8ec53a0ea2a80c5cd1ab397925f94bff59222aa3cf9c6da938ce05c9ec20428d"}, - {file = "yarl-1.9.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:159d81f22d7a43e6eabc36d7194cb53f2f15f498dbbfa8edc8a3239350f59fe7"}, - {file = "yarl-1.9.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:832b7e711027c114d79dffb92576acd1bd2decc467dec60e1cac96912602d0e6"}, - {file = "yarl-1.9.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:95d2ecefbcf4e744ea952d073c6922e72ee650ffc79028eb1e320e732898d7e8"}, - {file = "yarl-1.9.2-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:d4e2c6d555e77b37288eaf45b8f60f0737c9efa3452c6c44626a5455aeb250b9"}, - {file = "yarl-1.9.2-cp311-cp311-musllinux_1_1_ppc64le.whl", hash = "sha256:783185c75c12a017cc345015ea359cc801c3b29a2966c2655cd12b233bf5a2be"}, - {file = "yarl-1.9.2-cp311-cp311-musllinux_1_1_s390x.whl", hash = "sha256:b8cc1863402472f16c600e3e93d542b7e7542a540f95c30afd472e8e549fc3f7"}, - {file = "yarl-1.9.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:822b30a0f22e588b32d3120f6d41e4ed021806418b4c9f0bc3048b8c8cb3f92a"}, - {file = "yarl-1.9.2-cp311-cp311-win32.whl", hash = "sha256:a60347f234c2212a9f0361955007fcf4033a75bf600a33c88a0a8e91af77c0e8"}, - {file = "yarl-1.9.2-cp311-cp311-win_amd64.whl", hash = "sha256:be6b3fdec5c62f2a67cb3f8c6dbf56bbf3f61c0f046f84645cd1ca73532ea051"}, - {file = "yarl-1.9.2-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:38a3928ae37558bc1b559f67410df446d1fbfa87318b124bf5032c31e3447b74"}, - {file = "yarl-1.9.2-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ac9bb4c5ce3975aeac288cfcb5061ce60e0d14d92209e780c93954076c7c4367"}, - {file = "yarl-1.9.2-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3da8a678ca8b96c8606bbb8bfacd99a12ad5dd288bc6f7979baddd62f71c63ef"}, - {file = "yarl-1.9.2-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:13414591ff516e04fcdee8dc051c13fd3db13b673c7a4cb1350e6b2ad9639ad3"}, - {file = "yarl-1.9.2-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bf74d08542c3a9ea97bb8f343d4fcbd4d8f91bba5ec9d5d7f792dbe727f88938"}, - {file = "yarl-1.9.2-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6e7221580dc1db478464cfeef9b03b95c5852cc22894e418562997df0d074ccc"}, - {file = "yarl-1.9.2-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:494053246b119b041960ddcd20fd76224149cfea8ed8777b687358727911dd33"}, - {file = "yarl-1.9.2-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:52a25809fcbecfc63ac9ba0c0fb586f90837f5425edfd1ec9f3372b119585e45"}, - {file = "yarl-1.9.2-cp37-cp37m-musllinux_1_1_ppc64le.whl", hash = "sha256:e65610c5792870d45d7b68c677681376fcf9cc1c289f23e8e8b39c1485384185"}, - {file = "yarl-1.9.2-cp37-cp37m-musllinux_1_1_s390x.whl", hash = "sha256:1b1bba902cba32cdec51fca038fd53f8beee88b77efc373968d1ed021024cc04"}, - {file = "yarl-1.9.2-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:662e6016409828ee910f5d9602a2729a8a57d74b163c89a837de3fea050c7582"}, - {file = "yarl-1.9.2-cp37-cp37m-win32.whl", hash = "sha256:f364d3480bffd3aa566e886587eaca7c8c04d74f6e8933f3f2c996b7f09bee1b"}, - {file = "yarl-1.9.2-cp37-cp37m-win_amd64.whl", hash = "sha256:6a5883464143ab3ae9ba68daae8e7c5c95b969462bbe42e2464d60e7e2698368"}, - {file = "yarl-1.9.2-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:5610f80cf43b6202e2c33ba3ec2ee0a2884f8f423c8f4f62906731d876ef4fac"}, - {file = "yarl-1.9.2-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:b9a4e67ad7b646cd6f0938c7ebfd60e481b7410f574c560e455e938d2da8e0f4"}, - {file = "yarl-1.9.2-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:83fcc480d7549ccebe9415d96d9263e2d4226798c37ebd18c930fce43dfb9574"}, - {file = "yarl-1.9.2-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5fcd436ea16fee7d4207c045b1e340020e58a2597301cfbcfdbe5abd2356c2fb"}, - {file = "yarl-1.9.2-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:84e0b1599334b1e1478db01b756e55937d4614f8654311eb26012091be109d59"}, - {file = "yarl-1.9.2-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3458a24e4ea3fd8930e934c129b676c27452e4ebda80fbe47b56d8c6c7a63a9e"}, - {file = "yarl-1.9.2-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:838162460b3a08987546e881a2bfa573960bb559dfa739e7800ceeec92e64417"}, - {file = "yarl-1.9.2-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f4e2d08f07a3d7d3e12549052eb5ad3eab1c349c53ac51c209a0e5991bbada78"}, - {file = "yarl-1.9.2-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:de119f56f3c5f0e2fb4dee508531a32b069a5f2c6e827b272d1e0ff5ac040333"}, - {file = "yarl-1.9.2-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:149ddea5abf329752ea5051b61bd6c1d979e13fbf122d3a1f9f0c8be6cb6f63c"}, - {file = "yarl-1.9.2-cp38-cp38-musllinux_1_1_ppc64le.whl", hash = "sha256:674ca19cbee4a82c9f54e0d1eee28116e63bc6fd1e96c43031d11cbab8b2afd5"}, - {file = "yarl-1.9.2-cp38-cp38-musllinux_1_1_s390x.whl", hash = "sha256:9b3152f2f5677b997ae6c804b73da05a39daa6a9e85a512e0e6823d81cdad7cc"}, - {file = "yarl-1.9.2-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:5415d5a4b080dc9612b1b63cba008db84e908b95848369aa1da3686ae27b6d2b"}, - {file = "yarl-1.9.2-cp38-cp38-win32.whl", hash = "sha256:f7a3d8146575e08c29ed1cd287068e6d02f1c7bdff8970db96683b9591b86ee7"}, - {file = "yarl-1.9.2-cp38-cp38-win_amd64.whl", hash = "sha256:63c48f6cef34e6319a74c727376e95626f84ea091f92c0250a98e53e62c77c72"}, - {file = "yarl-1.9.2-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:75df5ef94c3fdc393c6b19d80e6ef1ecc9ae2f4263c09cacb178d871c02a5ba9"}, - {file = "yarl-1.9.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:c027a6e96ef77d401d8d5a5c8d6bc478e8042f1e448272e8d9752cb0aff8b5c8"}, - {file = "yarl-1.9.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:f3b078dbe227f79be488ffcfc7a9edb3409d018e0952cf13f15fd6512847f3f7"}, - {file = "yarl-1.9.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:59723a029760079b7d991a401386390c4be5bfec1e7dd83e25a6a0881859e716"}, - {file = "yarl-1.9.2-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b03917871bf859a81ccb180c9a2e6c1e04d2f6a51d953e6a5cdd70c93d4e5a2a"}, - {file = "yarl-1.9.2-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c1012fa63eb6c032f3ce5d2171c267992ae0c00b9e164efe4d73db818465fac3"}, - {file = "yarl-1.9.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a74dcbfe780e62f4b5a062714576f16c2f3493a0394e555ab141bf0d746bb955"}, - {file = "yarl-1.9.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8c56986609b057b4839968ba901944af91b8e92f1725d1a2d77cbac6972b9ed1"}, - {file = "yarl-1.9.2-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:2c315df3293cd521033533d242d15eab26583360b58f7ee5d9565f15fee1bef4"}, - {file = "yarl-1.9.2-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:b7232f8dfbd225d57340e441d8caf8652a6acd06b389ea2d3222b8bc89cbfca6"}, - {file = "yarl-1.9.2-cp39-cp39-musllinux_1_1_ppc64le.whl", hash = "sha256:53338749febd28935d55b41bf0bcc79d634881195a39f6b2f767870b72514caf"}, - {file = "yarl-1.9.2-cp39-cp39-musllinux_1_1_s390x.whl", hash = "sha256:066c163aec9d3d073dc9ffe5dd3ad05069bcb03fcaab8d221290ba99f9f69ee3"}, - {file = "yarl-1.9.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:8288d7cd28f8119b07dd49b7230d6b4562f9b61ee9a4ab02221060d21136be80"}, - {file = "yarl-1.9.2-cp39-cp39-win32.whl", hash = "sha256:b124e2a6d223b65ba8768d5706d103280914d61f5cae3afbc50fc3dfcc016623"}, - {file = "yarl-1.9.2-cp39-cp39-win_amd64.whl", hash = "sha256:61016e7d582bc46a5378ffdd02cd0314fb8ba52f40f9cf4d9a5e7dbef88dee18"}, - {file = "yarl-1.9.2.tar.gz", hash = "sha256:04ab9d4b9f587c06d801c2abfe9317b77cdf996c65a90d5e84ecc45010823571"}, + {file = "yarl-1.9.4-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:a8c1df72eb746f4136fe9a2e72b0c9dc1da1cbd23b5372f94b5820ff8ae30e0e"}, + {file = "yarl-1.9.4-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:a3a6ed1d525bfb91b3fc9b690c5a21bb52de28c018530ad85093cc488bee2dd2"}, + {file = "yarl-1.9.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:c38c9ddb6103ceae4e4498f9c08fac9b590c5c71b0370f98714768e22ac6fa66"}, + {file = "yarl-1.9.4-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d9e09c9d74f4566e905a0b8fa668c58109f7624db96a2171f21747abc7524234"}, + {file = "yarl-1.9.4-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b8477c1ee4bd47c57d49621a062121c3023609f7a13b8a46953eb6c9716ca392"}, + {file = "yarl-1.9.4-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d5ff2c858f5f6a42c2a8e751100f237c5e869cbde669a724f2062d4c4ef93551"}, + {file = "yarl-1.9.4-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:357495293086c5b6d34ca9616a43d329317feab7917518bc97a08f9e55648455"}, + {file = "yarl-1.9.4-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:54525ae423d7b7a8ee81ba189f131054defdb122cde31ff17477951464c1691c"}, + {file = "yarl-1.9.4-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:801e9264d19643548651b9db361ce3287176671fb0117f96b5ac0ee1c3530d53"}, + {file = "yarl-1.9.4-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:e516dc8baf7b380e6c1c26792610230f37147bb754d6426462ab115a02944385"}, + {file = "yarl-1.9.4-cp310-cp310-musllinux_1_1_ppc64le.whl", hash = "sha256:7d5aaac37d19b2904bb9dfe12cdb08c8443e7ba7d2852894ad448d4b8f442863"}, + {file = "yarl-1.9.4-cp310-cp310-musllinux_1_1_s390x.whl", hash = "sha256:54beabb809ffcacbd9d28ac57b0db46e42a6e341a030293fb3185c409e626b8b"}, + {file = "yarl-1.9.4-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:bac8d525a8dbc2a1507ec731d2867025d11ceadcb4dd421423a5d42c56818541"}, + {file = "yarl-1.9.4-cp310-cp310-win32.whl", hash = "sha256:7855426dfbddac81896b6e533ebefc0af2f132d4a47340cee6d22cac7190022d"}, + {file = "yarl-1.9.4-cp310-cp310-win_amd64.whl", hash = "sha256:848cd2a1df56ddbffeb375535fb62c9d1645dde33ca4d51341378b3f5954429b"}, + {file = "yarl-1.9.4-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:35a2b9396879ce32754bd457d31a51ff0a9d426fd9e0e3c33394bf4b9036b099"}, + {file = "yarl-1.9.4-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:4c7d56b293cc071e82532f70adcbd8b61909eec973ae9d2d1f9b233f3d943f2c"}, + {file = "yarl-1.9.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d8a1c6c0be645c745a081c192e747c5de06e944a0d21245f4cf7c05e457c36e0"}, + {file = "yarl-1.9.4-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4b3c1ffe10069f655ea2d731808e76e0f452fc6c749bea04781daf18e6039525"}, + {file = "yarl-1.9.4-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:549d19c84c55d11687ddbd47eeb348a89df9cb30e1993f1b128f4685cd0ebbf8"}, + {file = "yarl-1.9.4-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a7409f968456111140c1c95301cadf071bd30a81cbd7ab829169fb9e3d72eae9"}, + {file = "yarl-1.9.4-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e23a6d84d9d1738dbc6e38167776107e63307dfc8ad108e580548d1f2c587f42"}, + {file = "yarl-1.9.4-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d8b889777de69897406c9fb0b76cdf2fd0f31267861ae7501d93003d55f54fbe"}, + {file = "yarl-1.9.4-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:03caa9507d3d3c83bca08650678e25364e1843b484f19986a527630ca376ecce"}, + {file = "yarl-1.9.4-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:4e9035df8d0880b2f1c7f5031f33f69e071dfe72ee9310cfc76f7b605958ceb9"}, + {file = "yarl-1.9.4-cp311-cp311-musllinux_1_1_ppc64le.whl", hash = "sha256:c0ec0ed476f77db9fb29bca17f0a8fcc7bc97ad4c6c1d8959c507decb22e8572"}, + {file = "yarl-1.9.4-cp311-cp311-musllinux_1_1_s390x.whl", hash = "sha256:ee04010f26d5102399bd17f8df8bc38dc7ccd7701dc77f4a68c5b8d733406958"}, + {file = "yarl-1.9.4-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:49a180c2e0743d5d6e0b4d1a9e5f633c62eca3f8a86ba5dd3c471060e352ca98"}, + {file = "yarl-1.9.4-cp311-cp311-win32.whl", hash = "sha256:81eb57278deb6098a5b62e88ad8281b2ba09f2f1147c4767522353eaa6260b31"}, + {file = "yarl-1.9.4-cp311-cp311-win_amd64.whl", hash = "sha256:d1d2532b340b692880261c15aee4dc94dd22ca5d61b9db9a8a361953d36410b1"}, + {file = "yarl-1.9.4-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:0d2454f0aef65ea81037759be5ca9947539667eecebca092733b2eb43c965a81"}, + {file = "yarl-1.9.4-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:44d8ffbb9c06e5a7f529f38f53eda23e50d1ed33c6c869e01481d3fafa6b8142"}, + {file = "yarl-1.9.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:aaaea1e536f98754a6e5c56091baa1b6ce2f2700cc4a00b0d49eca8dea471074"}, + {file = "yarl-1.9.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3777ce5536d17989c91696db1d459574e9a9bd37660ea7ee4d3344579bb6f129"}, + {file = "yarl-1.9.4-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9fc5fc1eeb029757349ad26bbc5880557389a03fa6ada41703db5e068881e5f2"}, + {file = "yarl-1.9.4-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ea65804b5dc88dacd4a40279af0cdadcfe74b3e5b4c897aa0d81cf86927fee78"}, + {file = "yarl-1.9.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:aa102d6d280a5455ad6a0f9e6d769989638718e938a6a0a2ff3f4a7ff8c62cc4"}, + {file = "yarl-1.9.4-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:09efe4615ada057ba2d30df871d2f668af661e971dfeedf0c159927d48bbeff0"}, + {file = "yarl-1.9.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:008d3e808d03ef28542372d01057fd09168419cdc8f848efe2804f894ae03e51"}, + {file = "yarl-1.9.4-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:6f5cb257bc2ec58f437da2b37a8cd48f666db96d47b8a3115c29f316313654ff"}, + {file = "yarl-1.9.4-cp312-cp312-musllinux_1_1_ppc64le.whl", hash = "sha256:992f18e0ea248ee03b5a6e8b3b4738850ae7dbb172cc41c966462801cbf62cf7"}, + {file = "yarl-1.9.4-cp312-cp312-musllinux_1_1_s390x.whl", hash = "sha256:0e9d124c191d5b881060a9e5060627694c3bdd1fe24c5eecc8d5d7d0eb6faabc"}, + {file = "yarl-1.9.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:3986b6f41ad22988e53d5778f91855dc0399b043fc8946d4f2e68af22ee9ff10"}, + {file = "yarl-1.9.4-cp312-cp312-win32.whl", hash = "sha256:4b21516d181cd77ebd06ce160ef8cc2a5e9ad35fb1c5930882baff5ac865eee7"}, + {file = "yarl-1.9.4-cp312-cp312-win_amd64.whl", hash = "sha256:a9bd00dc3bc395a662900f33f74feb3e757429e545d831eef5bb280252631984"}, + {file = "yarl-1.9.4-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:63b20738b5aac74e239622d2fe30df4fca4942a86e31bf47a81a0e94c14df94f"}, + {file = "yarl-1.9.4-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d7d7f7de27b8944f1fee2c26a88b4dabc2409d2fea7a9ed3df79b67277644e17"}, + {file = "yarl-1.9.4-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c74018551e31269d56fab81a728f683667e7c28c04e807ba08f8c9e3bba32f14"}, + {file = "yarl-1.9.4-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ca06675212f94e7a610e85ca36948bb8fc023e458dd6c63ef71abfd482481aa5"}, + {file = "yarl-1.9.4-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5aef935237d60a51a62b86249839b51345f47564208c6ee615ed2a40878dccdd"}, + {file = "yarl-1.9.4-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2b134fd795e2322b7684155b7855cc99409d10b2e408056db2b93b51a52accc7"}, + {file = "yarl-1.9.4-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:d25039a474c4c72a5ad4b52495056f843a7ff07b632c1b92ea9043a3d9950f6e"}, + {file = "yarl-1.9.4-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:f7d6b36dd2e029b6bcb8a13cf19664c7b8e19ab3a58e0fefbb5b8461447ed5ec"}, + {file = "yarl-1.9.4-cp37-cp37m-musllinux_1_1_ppc64le.whl", hash = "sha256:957b4774373cf6f709359e5c8c4a0af9f6d7875db657adb0feaf8d6cb3c3964c"}, + {file = "yarl-1.9.4-cp37-cp37m-musllinux_1_1_s390x.whl", hash = "sha256:d7eeb6d22331e2fd42fce928a81c697c9ee2d51400bd1a28803965883e13cead"}, + {file = "yarl-1.9.4-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:6a962e04b8f91f8c4e5917e518d17958e3bdee71fd1d8b88cdce74dd0ebbf434"}, + {file = "yarl-1.9.4-cp37-cp37m-win32.whl", hash = "sha256:f3bc6af6e2b8f92eced34ef6a96ffb248e863af20ef4fde9448cc8c9b858b749"}, + {file = "yarl-1.9.4-cp37-cp37m-win_amd64.whl", hash = "sha256:ad4d7a90a92e528aadf4965d685c17dacff3df282db1121136c382dc0b6014d2"}, + {file = "yarl-1.9.4-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:ec61d826d80fc293ed46c9dd26995921e3a82146feacd952ef0757236fc137be"}, + {file = "yarl-1.9.4-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:8be9e837ea9113676e5754b43b940b50cce76d9ed7d2461df1af39a8ee674d9f"}, + {file = "yarl-1.9.4-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:bef596fdaa8f26e3d66af846bbe77057237cb6e8efff8cd7cc8dff9a62278bbf"}, + {file = "yarl-1.9.4-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2d47552b6e52c3319fede1b60b3de120fe83bde9b7bddad11a69fb0af7db32f1"}, + {file = "yarl-1.9.4-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:84fc30f71689d7fc9168b92788abc977dc8cefa806909565fc2951d02f6b7d57"}, + {file = "yarl-1.9.4-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4aa9741085f635934f3a2583e16fcf62ba835719a8b2b28fb2917bb0537c1dfa"}, + {file = "yarl-1.9.4-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:206a55215e6d05dbc6c98ce598a59e6fbd0c493e2de4ea6cc2f4934d5a18d130"}, + {file = "yarl-1.9.4-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:07574b007ee20e5c375a8fe4a0789fad26db905f9813be0f9fef5a68080de559"}, + {file = "yarl-1.9.4-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:5a2e2433eb9344a163aced6a5f6c9222c0786e5a9e9cac2c89f0b28433f56e23"}, + {file = "yarl-1.9.4-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:6ad6d10ed9b67a382b45f29ea028f92d25bc0bc1daf6c5b801b90b5aa70fb9ec"}, + {file = "yarl-1.9.4-cp38-cp38-musllinux_1_1_ppc64le.whl", hash = "sha256:6fe79f998a4052d79e1c30eeb7d6c1c1056ad33300f682465e1b4e9b5a188b78"}, + {file = "yarl-1.9.4-cp38-cp38-musllinux_1_1_s390x.whl", hash = "sha256:a825ec844298c791fd28ed14ed1bffc56a98d15b8c58a20e0e08c1f5f2bea1be"}, + {file = "yarl-1.9.4-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:8619d6915b3b0b34420cf9b2bb6d81ef59d984cb0fde7544e9ece32b4b3043c3"}, + {file = "yarl-1.9.4-cp38-cp38-win32.whl", hash = "sha256:686a0c2f85f83463272ddffd4deb5e591c98aac1897d65e92319f729c320eece"}, + {file = "yarl-1.9.4-cp38-cp38-win_amd64.whl", hash = "sha256:a00862fb23195b6b8322f7d781b0dc1d82cb3bcac346d1e38689370cc1cc398b"}, + {file = "yarl-1.9.4-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:604f31d97fa493083ea21bd9b92c419012531c4e17ea6da0f65cacdcf5d0bd27"}, + {file = "yarl-1.9.4-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:8a854227cf581330ffa2c4824d96e52ee621dd571078a252c25e3a3b3d94a1b1"}, + {file = "yarl-1.9.4-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:ba6f52cbc7809cd8d74604cce9c14868306ae4aa0282016b641c661f981a6e91"}, + {file = "yarl-1.9.4-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a6327976c7c2f4ee6816eff196e25385ccc02cb81427952414a64811037bbc8b"}, + {file = "yarl-1.9.4-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8397a3817d7dcdd14bb266283cd1d6fc7264a48c186b986f32e86d86d35fbac5"}, + {file = "yarl-1.9.4-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e0381b4ce23ff92f8170080c97678040fc5b08da85e9e292292aba67fdac6c34"}, + {file = "yarl-1.9.4-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:23d32a2594cb5d565d358a92e151315d1b2268bc10f4610d098f96b147370136"}, + {file = "yarl-1.9.4-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ddb2a5c08a4eaaba605340fdee8fc08e406c56617566d9643ad8bf6852778fc7"}, + {file = "yarl-1.9.4-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:26a1dc6285e03f3cc9e839a2da83bcbf31dcb0d004c72d0730e755b33466c30e"}, + {file = "yarl-1.9.4-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:18580f672e44ce1238b82f7fb87d727c4a131f3a9d33a5e0e82b793362bf18b4"}, + {file = "yarl-1.9.4-cp39-cp39-musllinux_1_1_ppc64le.whl", hash = "sha256:29e0f83f37610f173eb7e7b5562dd71467993495e568e708d99e9d1944f561ec"}, + {file = "yarl-1.9.4-cp39-cp39-musllinux_1_1_s390x.whl", hash = "sha256:1f23e4fe1e8794f74b6027d7cf19dc25f8b63af1483d91d595d4a07eca1fb26c"}, + {file = "yarl-1.9.4-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:db8e58b9d79200c76956cefd14d5c90af54416ff5353c5bfd7cbe58818e26ef0"}, + {file = "yarl-1.9.4-cp39-cp39-win32.whl", hash = "sha256:c7224cab95645c7ab53791022ae77a4509472613e839dab722a72abe5a684575"}, + {file = "yarl-1.9.4-cp39-cp39-win_amd64.whl", hash = "sha256:824d6c50492add5da9374875ce72db7a0733b29c2394890aef23d533106e2b15"}, + {file = "yarl-1.9.4-py3-none-any.whl", hash = "sha256:928cecb0ef9d5a7946eb6ff58417ad2fe9375762382f1bf5c55e61645f2c43ad"}, + {file = "yarl-1.9.4.tar.gz", hash = "sha256:566db86717cf8080b99b58b083b773a908ae40f06681e87e589a976faf8246bf"}, ] [package.dependencies] idna = ">=2.0" multidict = ">=4.0" -typing-extensions = {version = ">=3.7.4", markers = "python_version < \"3.8\""} [[package]] name = "zipp" -version = "3.15.0" +version = "3.17.0" description = "Backport of pathlib-compatible object wrapper for zip files" optional = false -python-versions = ">=3.7" +python-versions = ">=3.8" files = [ - {file = "zipp-3.15.0-py3-none-any.whl", hash = "sha256:48904fc76a60e542af151aded95726c1a5c34ed43ab4134b597665c86d7ad556"}, - {file = "zipp-3.15.0.tar.gz", hash = "sha256:112929ad649da941c23de50f356a2b5570c954b65150642bccdd66bf194d224b"}, + {file = "zipp-3.17.0-py3-none-any.whl", hash = "sha256:0e923e726174922dce09c53c59ad483ff7bbb8e572e00c7f7c46b88556409f31"}, + {file = "zipp-3.17.0.tar.gz", hash = "sha256:84e64a1c28cf7e91ed2078bb8cc8c259cb19b76942096c8d7b84947690cabaf0"}, ] [package.extras] -docs = ["furo", "jaraco.packaging (>=9)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"] -testing = ["big-O", "flake8 (<5)", "jaraco.functools", "jaraco.itertools", "more-itertools", "pytest (>=6)", "pytest-black (>=0.3.7)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=1.3)", "pytest-flake8", "pytest-mypy (>=0.9.1)"] +docs = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (<7.2.5)", "sphinx (>=3.5)", "sphinx-lint"] +testing = ["big-O", "jaraco.functools", "jaraco.itertools", "more-itertools", "pytest (>=6)", "pytest-black (>=0.3.7)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=2.2)", "pytest-ignore-flaky", "pytest-mypy (>=0.9.1)", "pytest-ruff"] + +[extras] +syntax = ["tree-sitter", "tree_sitter_languages"] [metadata] lock-version = "2.0" -python-versions = "^3.7" -content-hash = "3817b3d8b678845abb17cddd49d5a6ea5fb9d0083faa356ef232184a94312ba6" +python-versions = "^3.8" +content-hash = "c4c26f6d0bd1266a7a38b9236c99cf51bf658447a18ffc2c96fb5da442762d6a" diff --git a/pyproject.toml b/pyproject.toml index f343aea290..19261bafca 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "textual" -version = "0.37.1" +version = "0.47.0" homepage = "https://github.com/Textualize/textual" repository = "https://github.com/Textualize/textual" documentation = "https://textual.textualize.io/" @@ -17,11 +17,11 @@ classifiers = [ "Operating System :: Microsoft :: Windows :: Windows 11", "Operating System :: MacOS", "Operating System :: POSIX :: Linux", - "Programming Language :: Python :: 3.7", "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", "Typing :: Typed", ] include = [ @@ -40,12 +40,16 @@ include = [ "Bug Tracker" = "https://github.com/Textualize/textual/issues" [tool.poetry.dependencies] -python = "^3.7" +python = "^3.8" rich = ">=13.3.3" markdown-it-py = { extras = ["plugins", "linkify"], version = ">=2.1.0" } #rich = {path="../rich", develop=true} -importlib-metadata = ">=4.11.3" typing-extensions = "^4.4.0" +tree-sitter = { version = "^0.20.1", optional = true } +tree_sitter_languages = { version = ">=1.7.0", optional = true } + +[tool.poetry.extras] +syntax = ["tree-sitter", "tree_sitter_languages"] [tool.poetry.group.dev.dependencies] pytest = "^7.1.3" @@ -58,14 +62,16 @@ mkdocstrings-python = "0.10.1" mkdocs-material = "^9.0.11" mkdocs-exclude = "^1.0.2" pre-commit = "^2.13.0" -pytest-aiohttp = "^1.0.4" time-machine = "^2.6.0" mkdocs-rss-plugin = "^1.5.0" httpx = "^0.23.1" types-setuptools = "^67.2.0.1" -textual-dev = "^1.1.0" +textual-dev = "^1.2.0" pytest-asyncio = "*" -pytest-textual-snapshot = "*" +pytest-textual-snapshot = ">=0.4.0" +types-tree-sitter = "^0.20.1.4" +types-tree-sitter-languages = "^1.7.0.1" +griffe = "0.32.3" [tool.black] includes = "src" @@ -76,6 +82,7 @@ testpaths = ["tests"] addopts = "--strict-markers" markers = [ "integration_test: marks tests as slow integration tests (deselect with '-m \"not integration_test\"')", + "syntax: marks tests that require syntax highlighting (deselect with '-m \"not syntax\"')", ] [build-system] diff --git a/reference/_devtools.md b/reference/_devtools.md deleted file mode 100644 index 964c69ed3b..0000000000 --- a/reference/_devtools.md +++ /dev/null @@ -1,12 +0,0 @@ -# Devtools - -## Installation - -Using the Textual Devtools requires installation of the `dev` [optional dependency group](https://packaging.python.org/en/latest/specifications/declaring-project-metadata/#dependencies-optional-dependencies). - -## Running - -TODO: Note how we run the devtools themselves and how we run our Textual apps -such that they can connect. Don't forget Windows instructions :) -We might also add a link to the documentation from the exception that gets -raised when the "dev" extra dependencies aren't installed. diff --git a/src/textual/__init__.py b/src/textual/__init__.py index 103f9db2fe..0b93010125 100644 --- a/src/textual/__init__.py +++ b/src/textual/__init__.py @@ -28,16 +28,11 @@ def __getattr__(name: str) -> str: - """Lazily get the version from whatever API is available.""" + """Lazily get the version.""" if name == "__version__": - try: - from importlib.metadata import version - except ImportError: - import pkg_resources + from importlib.metadata import version - return pkg_resources.get_distribution("textual").version - else: - return version("textual") + return version("textual") raise AttributeError(f"module {__name__!r} has no attribute {name!r}") diff --git a/src/textual/_animator.py b/src/textual/_animator.py index 54a3cf6cf3..3561b1ba58 100644 --- a/src/textual/_animator.py +++ b/src/textual/_animator.py @@ -349,6 +349,7 @@ def _animate( duration is None and speed is not None ), "An Animation should have a duration OR a speed" + # If an animation is already scheduled for this attribute, unschedule it. animation_key = (id(obj), attribute) try: del self._scheduled[animation_key] @@ -359,9 +360,7 @@ def _animate( final_value = value start_time = self._get_time() - easing_function = EASING[easing] if isinstance(easing, str) else easing - animation: Animation | None = None if hasattr(obj, "__textual_animation__"): diff --git a/src/textual/_ansi_sequences.py b/src/textual/_ansi_sequences.py index fc3b8de624..a504ea718d 100644 --- a/src/textual/_ansi_sequences.py +++ b/src/textual/_ansi_sequences.py @@ -1,9 +1,11 @@ +from __future__ import annotations + from typing import Mapping, Tuple from .keys import Keys # Mapping of vt100 escape codes to Keys. -ANSI_SEQUENCES_KEYS: Mapping[str, Tuple[Keys, ...]] = { +ANSI_SEQUENCES_KEYS: Mapping[str, Tuple[Keys, ...] | str] = { # Control keys. " ": (Keys.Space,), "\r": (Keys.Enter,), @@ -98,7 +100,9 @@ # Xterm "\x1b[1;2P": (Keys.F13,), "\x1b[1;2Q": (Keys.F14,), - # '\x1b[1;2R': Keys.F15, # Conflicts with CPR response. + "\x1b[1;2R": ( + Keys.F15, + ), # Conflicts with CPR response; enabled after https://github.com/Textualize/textual/issues/3440. "\x1b[1;2S": (Keys.F16,), "\x1b[15;2~": (Keys.F17,), "\x1b[17;2~": (Keys.F18,), @@ -112,7 +116,9 @@ # Control + function keys. "\x1b[1;5P": (Keys.ControlF1,), "\x1b[1;5Q": (Keys.ControlF2,), - # "\x1b[1;5R": Keys.ControlF3, # Conflicts with CPR response. + "\x1b[1;5R": ( + Keys.ControlF3, + ), # Conflicts with CPR response; enabled after https://github.com/Textualize/textual/issues/3440. "\x1b[1;5S": (Keys.ControlF4,), "\x1b[15;5~": (Keys.ControlF5,), "\x1b[17;5~": (Keys.ControlF6,), @@ -124,7 +130,9 @@ "\x1b[24;5~": (Keys.ControlF12,), "\x1b[1;6P": (Keys.ControlF13,), "\x1b[1;6Q": (Keys.ControlF14,), - # "\x1b[1;6R": Keys.ControlF15, # Conflicts with CPR response. + "\x1b[1;6R": ( + Keys.ControlF15, + ), # Conflicts with CPR response; enabled after https://github.com/Textualize/textual/issues/3440. "\x1b[1;6S": (Keys.ControlF16,), "\x1b[15;6~": (Keys.ControlF17,), "\x1b[17;6~": (Keys.ControlF18,), @@ -134,6 +142,30 @@ "\x1b[21;6~": (Keys.ControlF22,), "\x1b[23;6~": (Keys.ControlF23,), "\x1b[24;6~": (Keys.ControlF24,), + # rxvt-unicode control function keys: + "\x1b[11^": (Keys.ControlF1,), + "\x1b[12^": (Keys.ControlF2,), + "\x1b[13^": (Keys.ControlF3,), + "\x1b[14^": (Keys.ControlF4,), + "\x1b[15^": (Keys.ControlF5,), + "\x1b[17^": (Keys.ControlF6,), + "\x1b[18^": (Keys.ControlF7,), + "\x1b[19^": (Keys.ControlF8,), + "\x1b[20^": (Keys.ControlF9,), + "\x1b[21^": (Keys.ControlF10,), + "\x1b[23^": (Keys.ControlF11,), + "\x1b[24^": (Keys.ControlF12,), + # rxvt-unicode control+shift function keys: + "\x1b[25^": (Keys.ControlF13,), + "\x1b[26^": (Keys.ControlF14,), + "\x1b[28^": (Keys.ControlF15,), + "\x1b[29^": (Keys.ControlF16,), + "\x1b[31^": (Keys.ControlF17,), + "\x1b[32^": (Keys.ControlF18,), + "\x1b[33^": (Keys.ControlF19,), + "\x1b[34^": (Keys.ControlF20,), + "\x1b[23@": (Keys.ControlF21,), + "\x1b[24@": (Keys.ControlF22,), # -- # Tmux (Win32 subsystem) sends the following scroll events. "\x1b[62~": (Keys.ScrollUp,), @@ -146,6 +178,7 @@ # -- # Meta/control/escape + pageup/pagedown/insert/delete. "\x1b[3;2~": (Keys.ShiftDelete,), # xterm, gnome-terminal. + "\x1b[3$": (Keys.ShiftDelete,), # rxvt "\x1b[5;2~": (Keys.ShiftPageUp,), "\x1b[6;2~": (Keys.ShiftPageDown,), "\x1b[2;3~": (Keys.Escape, Keys.Insert), @@ -157,8 +190,11 @@ "\x1b[5;4~": (Keys.Escape, Keys.ShiftPageUp), "\x1b[6;4~": (Keys.Escape, Keys.ShiftPageDown), "\x1b[3;5~": (Keys.ControlDelete,), # xterm, gnome-terminal. + "\x1b[3^": (Keys.ControlDelete,), # rxvt "\x1b[5;5~": (Keys.ControlPageUp,), "\x1b[6;5~": (Keys.ControlPageDown,), + "\x1b[5^": (Keys.ControlPageUp,), # rxvt + "\x1b[6^": (Keys.ControlPageDown,), # rxvt "\x1b[3;6~": (Keys.ControlShiftDelete,), "\x1b[5;6~": (Keys.ControlShiftPageUp,), "\x1b[6;6~": (Keys.ControlShiftPageDown,), @@ -194,6 +230,13 @@ "\x1b[1;2D": (Keys.ShiftLeft,), "\x1b[1;2F": (Keys.ShiftEnd,), "\x1b[1;2H": (Keys.ShiftHome,), + # Shift+navigation in rxvt + "\x1b[a": (Keys.ShiftUp,), + "\x1b[b": (Keys.ShiftDown,), + "\x1b[c": (Keys.ShiftRight,), + "\x1b[d": (Keys.ShiftLeft,), + "\x1b[7$": (Keys.ShiftHome,), + "\x1b[8$": (Keys.ShiftEnd,), # Meta + arrow keys. Several terminals handle this differently. # The following sequences are for xterm and gnome-terminal. # (Iterm sends ESC followed by the normal arrow_up/down/left/right @@ -221,8 +264,13 @@ "\x1b[1;5B": (Keys.ControlDown,), # Cursor Mode "\x1b[1;5C": (Keys.ControlRight,), # Cursor Mode "\x1b[1;5D": (Keys.ControlLeft,), # Cursor Mode + "\x1bf": (Keys.ControlRight,), # iTerm natural editing keys + "\x1bb": (Keys.ControlLeft,), # iTerm natural editing keys "\x1b[1;5F": (Keys.ControlEnd,), "\x1b[1;5H": (Keys.ControlHome,), + # rxvt + "\x1b[7^": (Keys.ControlEnd,), + "\x1b[8^": (Keys.ControlHome,), # Tmux sends following keystrokes when control+arrow is pressed, but for # Emacs ansi-term sends the same sequences for normal arrow keys. Consider # it a normal arrow press, because that's more important. @@ -230,8 +278,11 @@ "\x1b[5B": (Keys.ControlDown,), "\x1b[5C": (Keys.ControlRight,), "\x1b[5D": (Keys.ControlLeft,), - "\x1bOc": (Keys.ControlRight,), # rxvt - "\x1bOd": (Keys.ControlLeft,), # rxvt + # Control arrow keys in rxvt + "\x1bOa": (Keys.ControlUp,), + "\x1bOb": (Keys.ControlUp,), + "\x1bOc": (Keys.ControlRight,), + "\x1bOd": (Keys.ControlLeft,), # Control + shift + arrows. "\x1b[1;6A": (Keys.ControlShiftUp,), "\x1b[1;6B": (Keys.ControlShiftDown,), @@ -301,6 +352,24 @@ "\x1b[1;8w": (Keys.Escape, Keys.ControlShift7), "\x1b[1;8x": (Keys.Escape, Keys.ControlShift8), "\x1b[1;8y": (Keys.Escape, Keys.ControlShift9), + # Simplify some sequences that appear to be unique to rxvt; see + # https://github.com/Textualize/textual/issues/3741 for context. + "\x1bOj": "*", + "\x1bOk": "+", + "\x1bOm": "-", + "\x1bOn": ".", + "\x1bOo": "/", + "\x1bOp": "0", + "\x1bOq": "1", + "\x1bOr": "2", + "\x1bOs": "3", + "\x1bOt": "4", + "\x1bOu": "5", + "\x1bOv": "6", + "\x1bOw": "7", + "\x1bOx": "8", + "\x1bOy": "9", + "\x1bOM": (Keys.Enter,), } # https://gist.github.com/christianparpart/d8a62cc1ab659194337d73e399004036 diff --git a/src/textual/_asyncio.py b/src/textual/_asyncio.py deleted file mode 100644 index 351ff650d5..0000000000 --- a/src/textual/_asyncio.py +++ /dev/null @@ -1,21 +0,0 @@ -""" -Compatibility layer for asyncio. -""" - -from __future__ import annotations - -import sys - -__all__ = ["create_task"] - -if sys.version_info >= (3, 8): - from asyncio import create_task - -else: - import asyncio - from asyncio import create_task as _create_task - from typing import Awaitable - - def create_task(coroutine: Awaitable, *, name: str | None = None) -> asyncio.Task: - """Schedule the execution of a coroutine object in a spawn task.""" - return _create_task(coroutine) diff --git a/src/textual/_box_drawing.py b/src/textual/_box_drawing.py new file mode 100644 index 0000000000..e30d73b4c6 --- /dev/null +++ b/src/textual/_box_drawing.py @@ -0,0 +1,366 @@ +""" +Box drawing utilities for Canvas. + +The box drawing characters have zero to four lines radiating from the center of the glyph. +There are three line types: thin, heavy, and double. These are indicated by 1, 2, and 3 respectively (0 for no line). + +This code represents the characters as a tuple of 4 integers, (, , , ). This format +makes it possible to logically combine characters together, as there is no mathematical relationship in the unicode db. + +Note that not all combinations are possible. Characters can have a maximum of 2 border types in a single glyph. +There are also fewer characters for the "double" line type. + +""" + +from __future__ import annotations + +from functools import lru_cache + +from typing_extensions import TypeAlias + +Quad: TypeAlias = "tuple[int, int, int, int]" +"""Four values indicating the composition of the box character.""" + +# Yes, I typed this out by hand. - WM +BOX_CHARACTERS: dict[Quad, str] = { + (0, 0, 0, 0): " ", + (0, 0, 0, 1): "╴", + (0, 0, 0, 2): "╸", + (0, 0, 0, 3): "╸", + # + (0, 0, 1, 0): "╷", + (0, 0, 1, 1): "┐", + (0, 0, 1, 2): "┑", + (0, 0, 1, 3): "╕", + # + (0, 0, 2, 0): "╻", + (0, 0, 2, 1): "┒", + (0, 0, 2, 2): "┓", + (0, 0, 2, 3): "╕", + # + (0, 0, 3, 0): "╻", + (0, 0, 3, 1): "╖", + (0, 0, 3, 2): "╖", + (0, 0, 3, 3): "╗", + # + (0, 1, 0, 0): "╶", + (0, 1, 0, 1): "─", + (0, 1, 0, 2): "╾", + (0, 1, 0, 3): "╼", + # + (0, 1, 1, 0): "┌", + (0, 1, 1, 1): "┬", + (0, 1, 1, 2): "┭", + (0, 1, 1, 3): "╤", + # + (0, 1, 2, 0): "┎", + (0, 1, 2, 1): "┰", + (0, 1, 2, 2): "┱", + (0, 1, 2, 3): "┱", + # + (0, 1, 3, 0): "╓", + (0, 1, 3, 1): "╥", + (0, 1, 3, 2): "╥", + (0, 1, 3, 3): "╥", + # + (0, 2, 0, 0): "╺", + (0, 2, 0, 1): "╼", + (0, 2, 0, 2): "━", + (0, 2, 0, 3): "━", + # + (0, 2, 1, 0): "┍", + (0, 2, 1, 1): "┮", + (0, 2, 1, 2): "┯", + (0, 2, 1, 3): "┯", + # + (0, 2, 2, 0): "┏", + (0, 2, 2, 1): "┲", + (0, 2, 2, 2): "┳", + (0, 2, 2, 3): "╦", + # + (0, 2, 3, 0): "╒", + (0, 2, 3, 1): "╥", + (0, 2, 3, 2): "╥", + (0, 2, 3, 3): "╦", + # + (0, 3, 0, 0): "╺", + (0, 3, 0, 1): "╾", + (0, 3, 0, 2): "╾", + (0, 3, 0, 3): "═", + # + (0, 3, 1, 0): "╒", + (0, 3, 1, 1): "╤", + (0, 3, 1, 2): "╤", + (0, 3, 1, 3): "╤", + # + (0, 3, 2, 0): "╒", + (0, 3, 2, 1): "╤", + (0, 3, 2, 2): "╤", + (0, 3, 2, 3): "╤", + # + (0, 3, 3, 0): "╔", + (0, 3, 3, 1): "╦", + (0, 3, 3, 2): "╦", + (0, 3, 3, 3): "╦", + # + (1, 0, 0, 0): "╵", + (1, 0, 0, 1): "┘", + (1, 0, 0, 2): "┙", + (1, 0, 0, 3): "╛", + # + (1, 0, 1, 0): "│", + (1, 0, 1, 1): "┤", + (1, 0, 1, 2): "┥", + (1, 0, 1, 3): "╡", + # + (1, 0, 2, 0): "╽", + (1, 0, 2, 1): "┧", + (1, 0, 2, 2): "┪", + (1, 0, 2, 3): "┪", + # + (1, 0, 3, 0): "╽", + (1, 0, 3, 1): "┧", + (1, 0, 3, 2): "┪", + (1, 0, 3, 3): "┪", + # + (1, 1, 0, 0): "└", + (1, 1, 0, 1): "┴", + (1, 1, 0, 2): "┵", + (1, 1, 0, 3): "┵", + # + (1, 1, 1, 0): "├", + (1, 1, 1, 1): "┼", + (1, 1, 1, 2): "┽", + (1, 1, 1, 3): "┽", + # + (1, 1, 2, 0): "┟", + (1, 1, 2, 1): "╁", + (1, 1, 2, 2): "╅", + (1, 1, 2, 3): "╅", + # + (1, 1, 3, 0): "┟", + (1, 1, 3, 1): "╁", + (1, 1, 3, 2): "╅", + (1, 1, 3, 3): "╅", + # + (1, 2, 0, 0): "┕", + (1, 2, 0, 1): "┶", + (1, 2, 0, 2): "┷", + (1, 2, 0, 3): "╧", + # + (1, 2, 1, 0): "┝", + (1, 2, 1, 1): "┾", + (1, 2, 1, 2): "┿", + (1, 2, 1, 3): "┿", + # + (1, 2, 2, 0): "┢", + (1, 2, 2, 1): "╆", + (1, 2, 2, 2): "╈", + (1, 2, 2, 3): "╈", + # + (1, 2, 3, 0): "┢", + (1, 2, 3, 1): "╆", + (1, 2, 3, 2): "╈", + (1, 2, 3, 3): "╈", + # + (1, 3, 0, 0): "╘", + (1, 3, 0, 1): "╧", + (1, 3, 0, 2): "╧", + (1, 3, 0, 3): "╧", + # + (1, 3, 1, 0): "╞", + (1, 3, 1, 1): "╬", + (1, 3, 1, 2): "╪", + (1, 3, 1, 3): "╪", + # + (1, 3, 2, 0): "╟", + (1, 3, 2, 1): "┾", + (1, 3, 2, 2): "┾", + (1, 3, 2, 3): "╪", + # + (1, 3, 3, 0): "╞", + (1, 3, 3, 1): "╆", + (1, 3, 3, 2): "╆", + (1, 3, 3, 3): "╈", + # + (2, 0, 0, 0): "╹", + (2, 0, 0, 1): "┚", + (2, 0, 0, 2): "┛", + (2, 0, 0, 3): "╛", + # + (2, 0, 1, 0): "╿", + (2, 0, 1, 1): "┦", + (2, 0, 1, 2): "┩", + (2, 0, 1, 3): "┩", + # + (2, 0, 2, 0): "┃", + (2, 0, 2, 1): "┨", + (2, 0, 2, 2): "┫", + (2, 0, 2, 3): "╢", + # + (2, 0, 3, 0): "║", + (2, 0, 3, 1): "╢", + (2, 0, 3, 2): "╢", + (2, 0, 3, 3): "╢", + # + (2, 1, 0, 0): "┖", + (2, 1, 0, 1): "┸", + (2, 1, 0, 2): "┹", + (2, 1, 0, 3): "┹", + # + (2, 1, 1, 0): "┞", + (2, 1, 1, 1): "╀", + (2, 1, 1, 2): "╃", + (2, 1, 1, 3): "╃", + # + (2, 1, 2, 0): "┠", + (2, 1, 2, 1): "╂", + (2, 1, 2, 2): "╉", + (2, 1, 2, 3): "╉", + # + (2, 1, 3, 0): "╟", + (2, 1, 3, 1): "╫", + (2, 1, 3, 2): "╫", + (2, 1, 3, 3): "╫", + # + (2, 2, 0, 0): "┗", + (2, 2, 0, 1): "┺", + (2, 2, 0, 2): "┻", + (2, 2, 0, 3): "┻", + # + (2, 2, 1, 0): "┡", + (2, 2, 1, 1): "╄", + (2, 2, 1, 2): "╇", + (2, 2, 1, 3): "╇", + # + (2, 2, 2, 0): "┣", + (2, 2, 2, 1): "╊", + (2, 2, 2, 2): "╋", + (2, 2, 2, 3): "╬", + # + (2, 2, 3, 0): "╠", + (2, 2, 3, 1): "╬", + (2, 2, 3, 2): "╬", + (2, 2, 3, 3): "╬", + # + (2, 3, 0, 0): "╚", + (2, 3, 0, 1): "╩", + (2, 3, 0, 2): "╩", + (2, 3, 0, 3): "╩", + # + (2, 3, 1, 0): "╞", + (2, 3, 1, 1): "╬", + (2, 3, 1, 2): "╬", + (2, 3, 1, 3): "╬", + # + (2, 3, 2, 0): "╞", + (2, 3, 2, 1): "╬", + (2, 3, 2, 2): "╬", + (2, 3, 2, 3): "╬", + # + (2, 3, 3, 0): "╠", + (2, 3, 3, 1): "╬", + (2, 3, 3, 2): "╬", + (2, 3, 3, 3): "╬", + # + (3, 0, 0, 0): "╹", + (3, 0, 0, 1): "╜", + (3, 0, 0, 2): "╜", + (3, 0, 0, 3): "╝", + # + (3, 0, 1, 0): "╿", + (3, 0, 1, 1): "┦", + (3, 0, 1, 2): "┦", + (3, 0, 1, 3): "┩", + # + (3, 0, 2, 0): "║", + (3, 0, 2, 1): "╢", + (3, 0, 2, 2): "╢", + (3, 0, 2, 3): "╣", + # + (3, 0, 3, 0): "║", + (3, 0, 3, 1): "╢", + (3, 0, 3, 2): "╢", + (3, 0, 3, 3): "╣", + # + (3, 1, 0, 0): "╙", + (3, 1, 0, 1): "╨", + (3, 1, 0, 2): "╨", + (3, 1, 0, 3): "╩", + # + (3, 1, 1, 0): "╟", + (3, 1, 1, 1): "╬", + (3, 1, 1, 2): "╬", + (3, 1, 1, 3): "╬", + # + (3, 1, 2, 0): "╟", + (3, 1, 2, 1): "╬", + (3, 1, 2, 2): "╬", + (3, 1, 2, 3): "╬", + # + (3, 1, 3, 0): "╟", + (3, 1, 3, 1): "╫", + (3, 1, 3, 2): "╫", + (3, 1, 3, 3): "╉", + # + (3, 2, 0, 0): "╙", + (3, 2, 0, 1): "╨", + (3, 2, 0, 2): "╨", + (3, 2, 0, 3): "╩", + # + (3, 2, 1, 0): "╟", + (3, 2, 1, 1): "╬", + (3, 2, 1, 2): "╬", + (3, 2, 1, 3): "╬", + # + (3, 2, 2, 0): "╟", + (3, 2, 2, 1): "╬", + (3, 2, 2, 2): "╬", + (3, 2, 2, 3): "╬", + # + (3, 2, 3, 0): "╟", + (3, 2, 3, 1): "╫", + (3, 2, 3, 2): "╫", + (3, 2, 3, 3): "╬", + # + (3, 3, 0, 0): "╚", + (3, 3, 0, 1): "╩", + (3, 3, 0, 2): "╩", + (3, 3, 0, 3): "╩", + # + (3, 3, 1, 0): "╠", + (3, 3, 1, 1): "╄", + (3, 3, 1, 2): "╄", + (3, 3, 1, 3): "╇", + # + (3, 3, 2, 0): "╠", + (3, 3, 2, 1): "╬", + (3, 3, 2, 2): "╬", + (3, 3, 2, 3): "╬", + # + (3, 3, 3, 0): "╠", + (3, 3, 3, 1): "╊", + (3, 3, 3, 2): "╬", + (3, 3, 3, 3): "╬", +} + + +@lru_cache(1024) +def combine_quads(box1: Quad, box2: Quad) -> Quad: + """Combine two box drawing quads. + + Args: + box1: Existing box quad. + box2: New box quad. + + Returns: + A new box quad. + """ + top1, right1, bottom1, left1 = box1 + top2, right2, bottom2, left2 = box2 + return ( + top2 or top1, + right2 or right1, + bottom2 or bottom1, + left2 or left1, + ) diff --git a/src/textual/_cache.py b/src/textual/_cache.py index a4b440e602..79a589ff0e 100644 --- a/src/textual/_cache.py +++ b/src/textual/_cache.py @@ -183,14 +183,22 @@ def discard(self, key: CacheKey) -> None: Args: key: Cache key. """ - link = self._cache.get(key) - if link is None: + if key not in self._cache: return + link = self._cache[key] + # Remove link from list link[0][1] = link[1] # type: ignore[index] link[1][0] = link[0] # type: ignore[index] # Remove link from cache + + if self._head[2] == key: + self._head = self._head[1] # type: ignore[assignment] + if self._head[2] == key: # type: ignore[index] + self._head = [] + del self._cache[key] + self._full = False class FIFOCache(Generic[CacheKey, CacheValue]): diff --git a/src/textual/_callback.py b/src/textual/_callback.py index abefeae557..756a9352af 100644 --- a/src/textual/_callback.py +++ b/src/textual/_callback.py @@ -1,7 +1,7 @@ from __future__ import annotations import asyncio -from functools import lru_cache +from functools import lru_cache, partial from inspect import isawaitable, signature from typing import TYPE_CHECKING, Any, Callable @@ -14,8 +14,19 @@ INVOKE_TIMEOUT_WARNING = 3 -@lru_cache(maxsize=2048) def count_parameters(func: Callable) -> int: + """Count the number of parameters in a callable""" + if isinstance(func, partial): + return _count_parameters(func.func) + len(func.args) + if hasattr(func, "__self__"): + # Bound method + func = func.__func__ # type: ignore + return _count_parameters(func) - 1 + return _count_parameters(func) + + +@lru_cache(maxsize=2048) +def _count_parameters(func: Callable) -> int: """Count the number of parameters in a callable""" return len(signature(func).parameters) diff --git a/src/textual/_cells.py b/src/textual/_cells.py index 2c23b0e3ea..716faaed66 100644 --- a/src/textual/_cells.py +++ b/src/textual/_cells.py @@ -1,6 +1,8 @@ from typing import Callable -__all__ = ["cell_len"] +from textual.expand_tabs import get_tab_widths + +__all__ = ["cell_len", "cell_width_to_column_index"] cell_len: Callable[[str], int] @@ -8,3 +10,35 @@ from rich.cells import cached_cell_len as cell_len except ImportError: from rich.cells import cell_len + + +def cell_width_to_column_index(line: str, cell_width: int, tab_width: int) -> int: + """Retrieve the column index corresponding to the given cell width. + + Args: + line: The line of text to search within. + cell_width: The cell width to convert to column index. + tab_width: The tab stop width to expand tabs contained within the line. + + Returns: + The column corresponding to the cell width. + """ + column_index = 0 + total_cell_offset = 0 + for part, expanded_tab_width in get_tab_widths(line, tab_width): + # Check if the click landed on a character within this part. + for character in part: + total_cell_offset += cell_len(character) + if total_cell_offset > cell_width: + return column_index + column_index += 1 + + # Account for the appearance of the tab character for this part + total_cell_offset += expanded_tab_width + # Check if the click falls within the boundary of the expanded tab. + if total_cell_offset > cell_width: + return column_index + + column_index += 1 + + return len(line) diff --git a/src/textual/_compose.py b/src/textual/_compose.py index 022664d2b0..482b27fb6a 100644 --- a/src/textual/_compose.py +++ b/src/textual/_compose.py @@ -16,19 +16,57 @@ def compose(node: App | Widget) -> list[Widget]: Returns: A list of widgets. """ + _rich_traceback_omit = True + from .widget import MountError, Widget + app = node.app nodes: list[Widget] = [] compose_stack: list[Widget] = [] composed: list[Widget] = [] app._compose_stacks.append(compose_stack) app._composed.append(composed) + iter_compose = iter(node.compose()) + is_generator = hasattr(iter_compose, "throw") try: - for child in node.compose(): + while True: + try: + child = next(iter_compose) + except StopIteration: + break + + if not isinstance(child, Widget): + mount_error = MountError( + f"Can't mount {type(child)}; expected a Widget instance." + ) + if is_generator: + iter_compose.throw(mount_error) # type: ignore + else: + raise mount_error from None + + try: + child.id + except AttributeError: + mount_error = MountError( + "Widget is missing an 'id' attribute; did you forget to call super().__init__()?" + ) + if is_generator: + iter_compose.throw(mount_error) # type: ignore + else: + raise mount_error from None + if composed: nodes.extend(composed) composed.clear() if compose_stack: - compose_stack[-1].compose_add_child(child) + try: + compose_stack[-1].compose_add_child(child) + except Exception as error: + if is_generator: + # So the error is raised inside the generator + # This will generate a more sensible traceback for the dev + iter_compose.throw(error) # type: ignore + else: + raise else: nodes.append(child) if composed: diff --git a/src/textual/_compositor.py b/src/textual/_compositor.py index ddfba87806..6b786e8b26 100644 --- a/src/textual/_compositor.py +++ b/src/textual/_compositor.py @@ -423,9 +423,7 @@ def reflow_visible(self, parent: Widget, size: Size) -> set[Widget]: self.size = size # Keep a copy of the old map because we're going to compare it with the update - old_map = ( - self._visible_map if self._visible_map is not None else self._full_map or {} - ) + old_map = self._visible_map or {} map, widgets = self._arrange_root(parent, size, visible_only=True) # Replace map and widgets @@ -573,6 +571,8 @@ def add_widget( visible: Whether the widget should be visible by default. This may be overridden by the CSS rule `visibility`. """ + if not widget._is_mounted: + return styles = widget.styles visibility = styles.get_rule("visibility") if visibility is not None: @@ -594,10 +594,11 @@ def add_widget( # Widgets with scrollbars (containers or scroll view) require additional processing if widget.is_scrollable: # The region that contains the content (container region minus scrollbars) - child_region = widget._get_scrollable_region(container_region) - - # Adjust the clip region accordingly - sub_clip = clip.intersection(child_region) + child_region = ( + container_region + if widget.loading + else widget._get_scrollable_region(container_region) + ) # The region covered by children relative to parent widget total_region = child_region.reset_offset @@ -608,9 +609,12 @@ def add_widget( arranged_widgets = arrange_result.widgets widgets.update(arranged_widgets) + # Get the region that will be updated + sub_clip = clip.intersection(child_region) + if visible_only: placements = arrange_result.get_visible_placements( - container_size.region + widget.scroll_offset + sub_clip - child_region.offset + widget.scroll_offset ) else: placements = arrange_result.placements @@ -620,9 +624,9 @@ def add_widget( placement_offset = container_region.offset placement_scroll_offset = placement_offset - widget.scroll_offset - _layers = widget.layers layers_to_index = { - layer_name: index for index, layer_name in enumerate(_layers) + layer_name: index + for index, layer_name in enumerate(widget.layers) } get_layer_index = layers_to_index.get @@ -660,7 +664,10 @@ def add_widget( if visible: # Add any scrollbars - if any(widget.scrollbars_enabled): + if ( + widget.show_vertical_scrollbar + or widget.show_horizontal_scrollbar + ): for chrome_widget, chrome_region in widget._arrange_scrollbars( container_region ): @@ -874,16 +881,14 @@ def cuts(self) -> list[list[int]]: return self._cuts width, height = self.size - screen_region = self.size.region cuts = [[0, width] for _ in range(height)] intersection = Region.intersection extend = list.extend for region, clip in self.visible_widgets.values(): - region = intersection(region, clip) - if region and (region in screen_region): - x, y, region_width, region_height = region + x, y, region_width, region_height = intersection(region, clip) + if region_width and region_height: region_cuts = (x, x + region_width) for cut in cuts[y : y + region_height]: extend(cut, region_cuts) @@ -934,15 +939,13 @@ def _get_renders( _Region(0, 0, region.width, region.height) ) else: - clipped_region = intersection(region, clip) - if not clipped_region: - continue - new_x, new_y, new_width, new_height = clipped_region - delta_x = new_x - region.x - delta_y = new_y - region.y - yield region, clip, widget.render_lines( - _Region(delta_x, delta_y, new_width, new_height) - ) + new_x, new_y, new_width, new_height = intersection(region, clip) + if new_width and new_height: + yield region, clip, widget.render_lines( + _Region( + new_x - region.x, new_y - region.y, new_width, new_height + ) + ) def render_update( self, full: bool = False, screen_stack: list[Screen] | None = None diff --git a/src/textual/_doc.py b/src/textual/_doc.py index 8639f1d25d..54aa7008be 100644 --- a/src/textual/_doc.py +++ b/src/textual/_doc.py @@ -127,6 +127,8 @@ async def auto_pilot(pilot: Pilot) -> None: if wait_for_animation: await pilot.wait_for_scheduled_animations() await pilot.pause() + await pilot.pause() + await pilot.wait_for_scheduled_animations() svg = app.export_screenshot(title=title) app.exit(svg) diff --git a/src/textual/_layout.py b/src/textual/_layout.py index 575dc547fb..c147a0e419 100644 --- a/src/textual/_layout.py +++ b/src/textual/_layout.py @@ -5,7 +5,9 @@ from typing import TYPE_CHECKING, ClassVar, Iterable, NamedTuple from ._spatial_map import SpatialMap +from .canvas import Canvas, Rectangle from .geometry import Offset, Region, Size, Spacing +from .strip import StripRenderable if TYPE_CHECKING: from typing_extensions import TypeAlias @@ -63,8 +65,17 @@ def get_visible_placements(self, region: Region) -> list[WidgetPlacement]: Returns: Set of placements. """ + if self.total_region in region: + # Short circuit for when we want all the placements + return self.placements visible_placements = self.spatial_map.get_values_in_region(region) - return visible_placements + overlaps = region.overlaps + culled_placements = [ + placement + for placement in visible_placements + if placement.fixed or overlaps(placement.region) + ] + return culled_placements class WidgetPlacement(NamedTuple): @@ -172,7 +183,54 @@ def get_content_height( height = 0 else: # Use a height of zero to ignore relative heights - arrangement = widget._arrange(Size(width, 0)) + styles_height = widget.styles.height + if widget._parent and len(widget._nodes) == 1: + # If it is an only child with height auto we want it to expand + height = ( + container.height + if styles_height is not None and styles_height.is_auto + else 0 + ) + else: + height = 0 + arrangement = widget._arrange(Size(width, height)) height = arrangement.total_region.bottom return height + + def render_keyline(self, container: Widget) -> StripRenderable: + """Render keylines around all widgets. + + Args: + container: The container widget. + + Returns: + A renderable to draw the keylines. + """ + width, height = container.outer_size + canvas = Canvas(width, height) + + line_style, keyline_color = container.styles.keyline + + container_offset = container.content_region.offset + + def get_rectangle(region: Region) -> Rectangle: + """Get a canvas Rectangle that wraps a region. + + Args: + region: Widget region. + + Returns: + A Rectangle that encloses the widget. + """ + offset = region.offset - container_offset - (1, 1) + width, height = region.size + return Rectangle(offset, width + 2, height + 2, keyline_color, line_style) + + primitives = [ + get_rectangle(widget.region) + for widget in container.children + if widget.visible + ] + canvas_renderable = canvas.render(primitives, container.rich_style) + return canvas_renderable diff --git a/src/textual/_on.py b/src/textual/_on.py index 40e2a706c8..fd4c3d12a0 100644 --- a/src/textual/_on.py +++ b/src/textual/_on.py @@ -27,7 +27,7 @@ def on( """Decorator to declare that the method is a message handler. The decorator accepts an optional CSS selector that will be matched against a widget exposed by - a `control` attribute on the message. + a `control` property on the message. Example: ```python diff --git a/src/textual/_opacity.py b/src/textual/_opacity.py index f2bc57508a..41e48137e7 100644 --- a/src/textual/_opacity.py +++ b/src/textual/_opacity.py @@ -1,4 +1,4 @@ -from typing import Iterable +from typing import Iterable, cast from rich.segment import Segment from rich.style import Style @@ -25,8 +25,8 @@ def _apply_opacity( from_rich_color = Color.from_rich_color from_color = Style.from_color blend = base_background.blend - for segment in segments: - text, style, _ = segment + styled_segments = cast("Iterable[tuple[str, Style, object]]", segments) + for text, style, _ in styled_segments: blended_style = style if style.color is not None: diff --git a/src/textual/_parser.py b/src/textual/_parser.py index a1a187da46..812e063882 100644 --- a/src/textual/_parser.py +++ b/src/textual/_parser.py @@ -165,8 +165,6 @@ def parse( test_parser = TestParser() - import time - for n in range(0, len(data), 5): for token in test_parser.feed(data[n : n + 5]): print(token) diff --git a/src/textual/_segment_tools.py b/src/textual/_segment_tools.py index 79f3bc0772..219c6dcadf 100644 --- a/src/textual/_segment_tools.py +++ b/src/textual/_segment_tools.py @@ -197,11 +197,23 @@ def align_lines( Returns: Aligned lines. """ - + if not lines: + return width, height = size - shape_width, shape_height = Segment.get_shape(lines) + get_line_length = Segment.get_line_length + line_lengths = [get_line_length(line) for line in lines] + shape_width = max(line_lengths) + shape_height = len(line_lengths) def blank_lines(count: int) -> list[list[Segment]]: + """Create blank lines. + + Args: + count: Desired number of blank lines. + + Returns: + A list of blank lines. + """ return [[Segment(" " * width, style)]] * count top_blank_lines = bottom_blank_lines = 0 @@ -211,31 +223,38 @@ def blank_lines(count: int) -> list[list[Segment]]: bottom_blank_lines = vertical_excess_space elif vertical == "middle": top_blank_lines = vertical_excess_space // 2 - bottom_blank_lines = height - top_blank_lines + bottom_blank_lines = vertical_excess_space - top_blank_lines elif vertical == "bottom": top_blank_lines = vertical_excess_space - yield from blank_lines(top_blank_lines) + if top_blank_lines: + yield from blank_lines(top_blank_lines) horizontal_excess_space = max(0, width - shape_width) - adjust_line_length = Segment.adjust_line_length if horizontal == "left": - for line in lines: - yield adjust_line_length(line, width, style, pad=True) + for cell_length, line in zip(line_lengths, lines): + if cell_length == width: + yield line + else: + yield line_pad(line, 0, width - cell_length, style) elif horizontal == "center": left_space = horizontal_excess_space // 2 - for line in lines: - yield [ - Segment(" " * left_space, style), - *adjust_line_length(line, width - left_space, style, pad=True), - ] + for cell_length, line in zip(line_lengths, lines): + if cell_length == width: + yield line + else: + yield line_pad( + line, left_space, width - cell_length - left_space, style + ) elif horizontal == "right": - get_line_length = Segment.get_line_length - for line in lines: - left_space = width - get_line_length(line) - yield [Segment(" " * left_space, style), *line] - - yield from blank_lines(bottom_blank_lines) + for cell_length, line in zip(line_lengths, lines): + if width == cell_length: + yield line + else: + yield line_pad(line, width - cell_length, 0, style) + + if bottom_blank_lines: + yield from blank_lines(bottom_blank_lines) diff --git a/src/textual/_spatial_map.py b/src/textual/_spatial_map.py index 3f778a06bb..9426e9b949 100644 --- a/src/textual/_spatial_map.py +++ b/src/textual/_spatial_map.py @@ -65,7 +65,7 @@ def insert( indicates fixed regions. Fixed regions don't scroll and are always visible. Args: - regions_and_values: An iterable of (REGION, FIXED, VALUE). + regions_and_values: An iterable of (REGION, FIXED, OVERLAY, VALUE). """ append_fixed = self._fixed.append get_grid_list = self._map.__getitem__ diff --git a/src/textual/_styles_cache.py b/src/textual/_styles_cache.py index c874015dc3..adea09045b 100644 --- a/src/textual/_styles_cache.py +++ b/src/textual/_styles_cache.py @@ -9,10 +9,12 @@ from rich.style import Style from rich.text import Text +from . import log from ._border import get_box, render_border_label, render_row from ._opacity import _apply_opacity from ._segment_tools import line_pad, line_trim from .color import Color +from .constants import DEBUG from .filter import LineFilter from .geometry import Region, Size, Spacing from .renderables.text_opacity import TextOpacity @@ -148,10 +150,10 @@ def render_widget(self, widget: Widget, crop: Region) -> list[Strip]: and hover_style._meta and "@click" in hover_style.meta ): - link_hover_style = widget.link_hover_style - if link_hover_style: + link_style_hover = widget.link_style_hover + if link_style_hover: strips = [ - strip.style_links(hover_style.link_id, link_hover_style) + strip.style_links(hover_style.link_id, link_style_hover) for strip in strips ] @@ -228,10 +230,17 @@ def render( self._cache[y] = strip else: strip = self._cache[y] + if filters: for filter in filters: strip = strip.apply_filter(filter, background) + + if DEBUG: + if any([not (segment.control or segment.text) for segment in strip]): + log.warning(f"Strip contains invalid empty Segments: {strip!r}.") + add_strip(strip) + self._dirty_lines.difference_update(crop.line_range) if crop.column_span != (0, width): diff --git a/src/textual/_system_commands.py b/src/textual/_system_commands.py index c8b499c395..8cbb6ef017 100644 --- a/src/textual/_system_commands.py +++ b/src/textual/_system_commands.py @@ -17,7 +17,7 @@ async def search(self, query: str) -> Hits: """Handle a request to search for system commands that match the query. Args: - user_input: The user input to be matched. + query: The user input to be matched. Yields: Command hits for use in the command palette. diff --git a/src/textual/_text_area_theme.py b/src/textual/_text_area_theme.py new file mode 100644 index 0000000000..33845e4494 --- /dev/null +++ b/src/textual/_text_area_theme.py @@ -0,0 +1,353 @@ +from __future__ import annotations + +from dataclasses import dataclass, field + +from rich.style import Style + +from textual.app import DEFAULT_COLORS +from textual.color import Color +from textual.design import DEFAULT_DARK_SURFACE + + +@dataclass +class TextAreaTheme: + """A theme for the `TextArea` widget. + + Allows theming the general widget (gutter, selections, cursor, and so on) and + mapping of tree-sitter tokens to Rich styles. + + For example, consider the following snippet from the `markdown.scm` highlight + query file. We've assigned the `heading_content` token type to the name `heading`. + + ``` + (heading_content) @heading + ``` + + Now, we can map this `heading` name to a Rich style, and it will be styled as + such in the `TextArea`, assuming a parser which returns a `heading_content` + node is used (as will be the case when language="markdown"). + + ``` + TextAreaTheme('my_theme', syntax_styles={'heading': Style(color='cyan', bold=True)}) + ``` + + We can register this theme with our `TextArea` using the [`TextArea.register_theme`][textual.widgets._text_area.TextArea.register_theme] method, + and headings in our markdown files will be styled bold cyan. + """ + + name: str + """The name of the theme.""" + + base_style: Style | None = None + """The background style of the text area. If `None` the parent style will be used.""" + + gutter_style: Style | None = None + """The style of the gutter. If `None`, a legible Style will be generated.""" + + cursor_style: Style | None = None + """The style of the cursor. If `None`, a legible Style will be generated.""" + + cursor_line_style: Style | None = None + """The style to apply to the line the cursor is on.""" + + cursor_line_gutter_style: Style | None = None + """The style to apply to the gutter of the line the cursor is on. If `None`, a legible Style will be + generated.""" + + bracket_matching_style: Style | None = None + """The style to apply to matching brackets. If `None`, a legible Style will be generated.""" + + selection_style: Style | None = None + """The style of the selection. If `None` a default selection Style will be generated.""" + + syntax_styles: dict[str, Style] = field(default_factory=dict) + """The mapping of tree-sitter names from the `highlight_query` to Rich styles.""" + + def __post_init__(self) -> None: + """Generate some styles if they haven't been supplied.""" + if self.base_style is None: + self.base_style = Style() + + if self.base_style.color is None: + self.base_style = Style(color="#f3f3f3", bgcolor=self.base_style.bgcolor) + + if self.base_style.bgcolor is None: + self.base_style = Style( + color=self.base_style.color, bgcolor=DEFAULT_DARK_SURFACE + ) + + 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() + + 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 + ) + + @classmethod + def get_builtin_theme(cls, theme_name: str) -> TextAreaTheme | None: + """Get a `TextAreaTheme` by name. + + Given a `theme_name`, return the corresponding `TextAreaTheme` object. + + Args: + theme_name: The name of the theme. + + Returns: + The `TextAreaTheme` corresponding to the name or `None` if the theme isn't + found. + """ + return _BUILTIN_THEMES.get(theme_name) + + def get_highlight(self, name: str) -> Style | None: + """Return the Rich style corresponding to the name defined in the tree-sitter + highlight query for the current theme. + + Args: + name: The name of the highlight. + + Returns: + The `Style` to use for this highlight, or `None` if no style. + """ + return self.syntax_styles.get(name) + + @classmethod + def builtin_themes(cls) -> list[TextAreaTheme]: + """Get a list of all builtin TextAreaThemes. + + Returns: + A list of all builtin TextAreaThemes. + """ + 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", + base_style=Style(color="#f8f8f2", bgcolor="#272822"), + gutter_style=Style(color="#90908a", bgcolor="#272822"), + cursor_style=Style(color="#272822", bgcolor="#f8f8f0"), + cursor_line_style=Style(bgcolor="#3e3d32"), + cursor_line_gutter_style=Style(color="#c2c2bf", bgcolor="#3e3d32"), + bracket_matching_style=Style(bgcolor="#838889", bold=True), + selection_style=Style(bgcolor="#65686a"), + syntax_styles={ + "string": Style(color="#E6DB74"), + "string.documentation": Style(color="#E6DB74"), + "comment": Style(color="#75715E"), + "keyword": Style(color="#F92672"), + "operator": Style(color="#F92672"), + "repeat": Style(color="#F92672"), + "exception": Style(color="#F92672"), + "include": Style(color="#F92672"), + "keyword.function": Style(color="#F92672"), + "keyword.return": Style(color="#F92672"), + "keyword.operator": Style(color="#F92672"), + "conditional": Style(color="#F92672"), + "number": Style(color="#AE81FF"), + "float": Style(color="#AE81FF"), + "class": Style(color="#A6E22E"), + "type.class": Style(color="#A6E22E"), + "function": Style(color="#A6E22E"), + "function.call": Style(color="#A6E22E"), + "method": Style(color="#A6E22E"), + "method.call": Style(color="#A6E22E"), + "boolean": Style(color="#66D9EF", italic=True), + "json.null": Style(color="#66D9EF", italic=True), + "regex.punctuation.bracket": Style(color="#F92672"), + "regex.operator": Style(color="#F92672"), + "html.end_tag_error": Style(color="red", underline=True), + "tag": Style(color="#F92672"), + "yaml.field": Style(color="#F92672", bold=True), + "json.label": Style(color="#F92672", bold=True), + "toml.type": Style(color="#F92672"), + "toml.datetime": Style(color="#AE81FF"), + "heading": Style(color="#F92672", bold=True), + "bold": Style(bold=True), + "italic": Style(italic=True), + "strikethrough": Style(strike=True), + "link": Style(color="#66D9EF", underline=True), + "inline_code": Style(color="#E6DB74"), + }, +) + +_DRACULA = TextAreaTheme( + name="dracula", + base_style=Style(color="#f8f8f2", bgcolor="#1E1F35"), + gutter_style=Style(color="#6272a4"), + cursor_style=Style(color="#282a36", bgcolor="#f8f8f0"), + cursor_line_style=Style(bgcolor="#282b45"), + cursor_line_gutter_style=Style(color="#c2c2bf", bgcolor="#282b45", bold=True), + bracket_matching_style=Style(bgcolor="#99999d", bold=True, underline=True), + selection_style=Style(bgcolor="#44475A"), + syntax_styles={ + "string": Style(color="#f1fa8c"), + "string.documentation": Style(color="#f1fa8c"), + "comment": Style(color="#6272a4"), + "keyword": Style(color="#ff79c6"), + "operator": Style(color="#ff79c6"), + "repeat": Style(color="#ff79c6"), + "exception": Style(color="#ff79c6"), + "include": Style(color="#ff79c6"), + "keyword.function": Style(color="#ff79c6"), + "keyword.return": Style(color="#ff79c6"), + "keyword.operator": Style(color="#ff79c6"), + "conditional": Style(color="#ff79c6"), + "number": Style(color="#bd93f9"), + "float": Style(color="#bd93f9"), + "class": Style(color="#50fa7b"), + "type.class": Style(color="#50fa7b"), + "function": Style(color="#50fa7b"), + "function.call": Style(color="#50fa7b"), + "method": Style(color="#50fa7b"), + "method.call": Style(color="#50fa7b"), + "boolean": Style(color="#bd93f9"), + "json.null": Style(color="#bd93f9"), + "regex.punctuation.bracket": Style(color="#ff79c6"), + "regex.operator": Style(color="#ff79c6"), + "html.end_tag_error": Style(color="#F83333", underline=True), + "tag": Style(color="#ff79c6"), + "yaml.field": Style(color="#ff79c6", bold=True), + "json.label": Style(color="#ff79c6", bold=True), + "toml.type": Style(color="#ff79c6"), + "toml.datetime": Style(color="#bd93f9"), + "heading": Style(color="#ff79c6", bold=True), + "bold": Style(bold=True), + "italic": Style(italic=True), + "strikethrough": Style(strike=True), + "link": Style(color="#bd93f9", underline=True), + "inline_code": Style(color="#f1fa8c"), + }, +) + +_DARK_VS = TextAreaTheme( + name="vscode_dark", + base_style=Style(color="#CCCCCC", bgcolor="#1F1F1F"), + gutter_style=Style(color="#6E7681", bgcolor="#1F1F1F"), + cursor_style=Style(color="#1e1e1e", bgcolor="#f0f0f0"), + cursor_line_style=Style(bgcolor="#2b2b2b"), + bracket_matching_style=Style(bgcolor="#3a3a3a", bold=True), + cursor_line_gutter_style=Style(color="#CCCCCC", bgcolor="#2b2b2b"), + selection_style=Style(bgcolor="#264F78"), + syntax_styles={ + "string": Style(color="#ce9178"), + "string.documentation": Style(color="#ce9178"), + "comment": Style(color="#6A9955"), + "keyword": Style(color="#569cd6"), + "operator": Style(color="#569cd6"), + "conditional": Style(color="#569cd6"), + "keyword.function": Style(color="#569cd6"), + "keyword.return": Style(color="#569cd6"), + "keyword.operator": Style(color="#569cd6"), + "repeat": Style(color="#569cd6"), + "exception": Style(color="#569cd6"), + "include": Style(color="#569cd6"), + "number": Style(color="#b5cea8"), + "float": Style(color="#b5cea8"), + "class": Style(color="#4EC9B0"), + "type.class": Style(color="#4EC9B0"), + "function": Style(color="#4EC9B0"), + "function.call": Style(color="#4EC9B0"), + "method": Style(color="#4EC9B0"), + "method.call": Style(color="#4EC9B0"), + "boolean": Style(color="#7DAF9C"), + "json.null": Style(color="#7DAF9C"), + "tag": Style(color="#EFCB43"), + "yaml.field": Style(color="#569cd6", bold=True), + "json.label": Style(color="#569cd6", bold=True), + "toml.type": Style(color="#569cd6"), + "heading": Style(color="#569cd6", bold=True), + "bold": Style(bold=True), + "italic": Style(italic=True), + "strikethrough": Style(strike=True), + "link": Style(color="#40A6FF", underline=True), + "inline_code": Style(color="#ce9178"), + "info_string": Style(color="#ce9178", bold=True, italic=True), + }, +) + +_GITHUB_LIGHT = TextAreaTheme( + name="github_light", + base_style=Style(color="#24292e", bgcolor="#f0f0f0"), + gutter_style=Style(color="#BBBBBB", bgcolor="#f0f0f0"), + cursor_style=Style(color="#fafbfc", bgcolor="#24292e"), + cursor_line_style=Style(bgcolor="#ebebeb"), + bracket_matching_style=Style(color="#24292e", underline=True), + cursor_line_gutter_style=Style(color="#A4A4A4", bgcolor="#ebebeb"), + selection_style=Style(bgcolor="#c8c8fa"), + syntax_styles={ + "string": Style(color="#093069"), + "string.documentation": Style(color="#093069"), + "comment": Style(color="#6a737d"), + "keyword": Style(color="#d73a49"), + "operator": Style(color="#0450AE"), + "conditional": Style(color="#CF222E"), + "keyword.function": Style(color="#CF222E"), + "keyword.return": Style(color="#CF222E"), + "keyword.operator": Style(color="#CF222E"), + "repeat": Style(color="#CF222E"), + "exception": Style(color="#CF222E"), + "include": Style(color="#CF222E"), + "number": Style(color="#d73a49"), + "float": Style(color="#d73a49"), + "parameter": Style(color="#24292e"), + "class": Style(color="#963800"), + "variable": Style(color="#e36209"), + "function": Style(color="#6639BB"), + "method": Style(color="#6639BB"), + "boolean": Style(color="#7DAF9C"), + "tag": Style(color="#6639BB"), + "yaml.field": Style(color="#6639BB"), + "json.label": Style(color="#6639BB"), + "toml.type": Style(color="#6639BB"), + "heading": Style(color="#24292e", bold=True), + "bold": Style(bold=True), + "italic": Style(italic=True), + "strikethrough": Style(strike=True), + "link": Style(color="#40A6FF", underline=True), + "inline_code": Style(color="#093069"), + }, +) + +_BUILTIN_THEMES = { + "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/_tree_sitter.py b/src/textual/_tree_sitter.py new file mode 100644 index 0000000000..01e300115c --- /dev/null +++ b/src/textual/_tree_sitter.py @@ -0,0 +1,10 @@ +from __future__ import annotations + +try: + from tree_sitter import Language, Parser, Tree + from tree_sitter.binding import Query + from tree_sitter_languages import get_language, get_parser + + TREE_SITTER = True +except ImportError: + TREE_SITTER = False diff --git a/src/textual/_types.py b/src/textual/_types.py index 03f83f619d..b1ad7972f3 100644 --- a/src/textual/_types.py +++ b/src/textual/_types.py @@ -26,6 +26,10 @@ def post_message(self, message: "Message") -> bool: ... +class UnusedParameter: + """Helper type for a parameter that isn't specified in a method call.""" + + SegmentLines = List[List["Segment"]] CallbackType = Union[Callable[[], Awaitable[None]], Callable[[], None]] """Type used for arbitrary callables used in callbacks.""" diff --git a/src/textual/_work_decorator.py b/src/textual/_work_decorator.py index dbfdba185e..cf4bc624df 100644 --- a/src/textual/_work_decorator.py +++ b/src/textual/_work_decorator.py @@ -1,5 +1,4 @@ """ - A decorator used to create [workers](/guide/workers). """ diff --git a/src/textual/_xterm_parser.py b/src/textual/_xterm_parser.py index edf587a3f9..1b2b47a64f 100644 --- a/src/textual/_xterm_parser.py +++ b/src/textual/_xterm_parser.py @@ -84,6 +84,13 @@ def parse_mouse_code(self, code: str) -> events.Event | None: return event return None + _reissued_sequence_debug_book: Callable[[str], None] | None = None + """INTERNAL USE ONLY! + + If this property is set to a callable, it will be called *instead* of + the reissued sequence being emitted as key events. + """ + def parse(self, on_token: TokenCallback) -> Generator[Awaitable, str, None]: ESC = "\x1b" read1 = self.read1 @@ -94,6 +101,9 @@ def parse(self, on_token: TokenCallback) -> Generator[Awaitable, str, None]: use_prior_escape = False def reissue_sequence_as_keys(reissue_sequence: str) -> None: + if self._reissued_sequence_debug_book is not None: + self._reissued_sequence_debug_book(reissue_sequence) + return for character in reissue_sequence: key_events = sequence_to_key_events(character) for event in key_events: @@ -233,10 +243,20 @@ def _sequence_to_key_events( Keys """ keys = ANSI_SEQUENCES_KEYS.get(sequence) - if keys is not None: + if isinstance(keys, tuple): + # If the sequence mapped to a tuple, then it's values from the + # `Keys` enum. Raise key events from what we find in the tuple. for key in keys: yield events.Key(key.value, sequence if len(sequence) == 1 else None) - elif len(sequence) == 1: + return + # If keys is a string, the intention is that it's a mapping to a + # character, which should really be treated as the sequence for the + # purposes of the next step... + if isinstance(keys, str): + sequence = keys + # If the sequence is a single character, attempt to process it as a + # key. + if len(sequence) == 1: try: if not sequence.isalnum(): name = _character_to_key(sequence) diff --git a/src/textual/app.py b/src/textual/app.py index f98d98f482..c900f7b2ba 100644 --- a/src/textual/app.py +++ b/src/textual/app.py @@ -15,9 +15,8 @@ import platform import sys import threading -import unicodedata import warnings -from asyncio import Task +from asyncio import Task, create_task from concurrent.futures import Future from contextlib import ( asynccontextmanager, @@ -53,14 +52,13 @@ 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 rich.traceback import Traceback from . import Logger, LogGroup, LogVerbosity, actions, constants, events, log, messages from ._animator import DEFAULT_EASING, Animatable, Animator, EasingFunction from ._ansi_sequences import SYNC_END, SYNC_START -from ._asyncio import create_task from ._callback import invoke from ._compose import compose from ._compositor import CompositorUpdate @@ -68,7 +66,6 @@ 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 ._system_commands import SystemCommands from ._wait import wait_for_idle from ._worker_manager import WorkerManager from .actions import ActionParseResult, SkipAction @@ -76,14 +73,14 @@ from .binding import Binding, BindingType, _Bindings from .command import CommandPalette, Provider from .css.query import NoMatches -from .css.stylesheet import Stylesheet +from .css.stylesheet import RulesMap, Stylesheet from .design import ColorSystem from .dom import DOMNode 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, LineFilter, Monochrome from .geometry import Offset, Region, Size from .keys import ( REPLACED_KEYS, @@ -95,18 +92,26 @@ from .notifications import Notification, Notifications, Notify, SeverityLevel from .reactive import Reactive from .renderables.blank import Blank -from .screen import Screen, ScreenResultCallbackType, ScreenResultType +from .screen import ( + Screen, + ScreenResultCallbackType, + ScreenResultType, + _SystemModalScreen, +) 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, TypeAlias + from typing_extensions import Coroutine, Literal, TypeAlias + from ._system_commands import SystemCommands from ._types import MessageTarget # Unused & ignored imports are needed for the docs to link to these objects: from .css.query import WrongType # type: ignore # noqa: F401 + from .filter import LineFilter from .message import Message from .pilot import Pilot from .widget import MountError # type: ignore # noqa: F401 @@ -153,6 +158,17 @@ """Signature for valid callbacks that can be used to control apps.""" +def get_system_commands() -> type[SystemCommands]: + """Callable to lazy load the system commands. + + Returns: + System commands class. + """ + from ._system_commands import SystemCommands + + return SystemCommands + + class AppError(Exception): """Base class for general App related exceptions.""" @@ -254,17 +270,14 @@ class App(Generic[ReturnType], DOMNode): and therefore takes priority in the event of a specificity clash.""" # Default (the lowest priority) CSS - DEFAULT_CSS: ClassVar[ - str - ] = """ + DEFAULT_CSS: ClassVar[str] + DEFAULT_CSS = """ App { background: $background; color: $text; } - *:disabled:can-focus { opacity: 0.7; - } """ @@ -297,7 +310,7 @@ class MyApp(App[None]): ... ``` """ - SCREENS: ClassVar[dict[str, Screen | Callable[[], Screen]]] = {} + SCREENS: ClassVar[dict[str, Screen[Any] | Callable[[], Screen[Any]]]] = {} """Screens associated with the app for the lifetime of the app.""" AUTO_FOCUS: ClassVar[str | None] = "*" @@ -328,8 +341,10 @@ class MyApp(App[None]): ENABLE_COMMAND_PALETTE: ClassVar[bool] = True """Should the [command palette][textual.command.CommandPalette] be enabled for the application?""" - COMMANDS: ClassVar[set[type[Provider]]] = {SystemCommands} - """Command providers used by the [command palette](/guide/command). + COMMANDS: ClassVar[set[type[Provider] | Callable[[], type[Provider]]]] = { + get_system_commands + } + """Command providers used by the [command palette](/guide/command_palette). Should be a set of [command.Provider][textual.command.Provider] classes. """ @@ -352,6 +367,12 @@ class MyApp(App[None]): self.app.dark = not self.app.dark # Toggle dark mode ``` """ + app_focus = Reactive(True, compute=False) + """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. + """ def __init__( self, @@ -373,6 +394,7 @@ def __init__( Raises: CssPathError: When the supplied CSS path(s) are an unexpected type. """ + self._start_time = perf_counter() super().__init__() self.features: frozenset[FeatureFlag] = parse_features(os.getenv("TEXTUAL", "")) @@ -380,11 +402,15 @@ def __init__( 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()) @@ -418,6 +444,15 @@ def __init__( self._animate = self._animator.bind(self) self.mouse_position = Offset(0, 0) + self._mouse_down_widget: Widget | None = None + """The widget that was most recently mouse downed (used to create click events).""" + + self.cursor_position = Offset(0, 0) + """The position of the terminal cursor in screen-space. + + This can be set by widgets and is useful for controlling the + positioning of OS IME and emoji popup menus.""" + self._exception: Exception | None = None """The unhandled exception which is leading to the app shutting down, or None if the app is still running with no unhandled exceptions.""" @@ -515,7 +550,7 @@ def __init__( self.css_monitor = ( FileMonitor(self.css_path, self._on_css_change) - if ((watch_css or self.debug) and self.css_path) + if watch_css or self.debug else None ) self._screenshot: str | None = None @@ -527,8 +562,15 @@ def __init__( 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.""" self._capture_stderr = _PrintCapture(self, stderr=True) + """File-like object capturing data written to stderr.""" + self._original_stdout = sys.__stdout__ + """The original stdout stream (before redirection etc).""" + self._original_stderr = sys.__stderr__ + """The original stderr stream (before redirection etc).""" self.set_class(self.dark, "-dark-mode") self.set_class(not self.dark, "-light-mode") @@ -543,7 +585,7 @@ def validate_sub_title(self, sub_title: Any) -> str: @property def workers(self) -> WorkerManager: - """The [worker](guide/workers/) manager. + """The [worker](/guide/workers/) manager. Returns: An object to manage workers. @@ -587,8 +629,14 @@ def children(self) -> Sequence["Widget"]: A sequence of widgets. """ try: - return (self.screen,) - except ScreenError: + return ( + next( + screen + for screen in reversed(self._screen_stack) + if not isinstance(screen, _SystemModalScreen) + ), + ) + except StopIteration: return () @contextmanager @@ -728,7 +776,10 @@ def focused(self) -> Widget | None: Returns: The currently focused widget, or `None` if nothing is focused. """ - return self.screen.focused + focused = self.screen.focused + if focused is not None and focused.loading: + return None + return focused @property def namespace_bindings(self) -> dict[str, tuple[DOMNode, Binding]]: @@ -778,8 +829,8 @@ def watch_dark(self, dark: bool) -> None: This method handles the transition between light and dark mode when you change the [dark][textual.app.App.dark] attribute. """ - self.set_class(dark, "-dark-mode") - self.set_class(not dark, "-light-mode") + self.set_class(dark, "-dark-mode", update=False) + self.set_class(not dark, "-light-mode", update=False) self.call_later(self.refresh_css) def get_driver_class(self) -> Type[Driver]: @@ -945,11 +996,23 @@ def _log( except Exception as error: self._handle_exception(error) + def get_loading_widget(self) -> Widget: + """Get a widget to be used as a loading indicator. + + Extend this method if you want to display the loading state a little differently. + + Returns: + A widget to display a loading state. + """ + from .widgets import LoadingIndicator + + return LoadingIndicator() + def call_from_thread( self, callback: Callable[..., CallThreadReturnType | Awaitable[CallThreadReturnType]], - *args: object, - **kwargs: object, + *args: Any, + **kwargs: Any, ) -> CallThreadReturnType: """Run a callable from another thread, and return the result. @@ -1113,14 +1176,18 @@ def get_key_display(self, key: str) -> str: async def _press_keys(self, keys: Iterable[str]) -> None: """A task to send key events.""" + import unicodedata + app = self driver = app._driver assert driver is not None for key in keys: if key.startswith("wait:"): _, wait_ms = key.split(":") - print(f"(pause {wait_ms}ms)") 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) @@ -1130,7 +1197,6 @@ async def _press_keys(self, keys: Iterable[str]) -> None: char = unicodedata.lookup(_get_unicode_name_from_key(original_key)) except KeyError: char = key if len(key) == 1 else None - print(f"press {key!r} (char={char!r})") key_event = events.Key(key, char) key_event._set_sender(app) driver.send_event(key_event) @@ -1149,14 +1215,27 @@ def _flush(self, stderr: bool = False) -> None: self._devtools_redirector.flush() def _print(self, text: str, stderr: bool = False) -> None: - """Called with capture print. + """Called with captured print. + + Dispatches printed content to appropriate destinations: devtools, + widgets currently capturing output, stdout/stderr. Args: text: Text that has been printed. stderr: True if the print was to stderr, or False for stdout. """ if self._devtools_redirector is not None: - self._devtools_redirector.write(text) + current_frame = inspect.currentframe() + self._devtools_redirector.write( + text, current_frame.f_back if current_frame is not None else None + ) + + # If we're in headless mode, we want printed text to still reach stdout/stderr. + if self.is_headless: + target_stream = self._original_stderr if stderr else self._original_stdout + target_stream.write(text) + + # Send Print events to all widgets that are currently capturing output. for target, (_stdout, _stderr) in self._capture_print.items(): if (_stderr and stderr) or (_stdout and not stderr): target.post_message(events.Print(text, stderr=stderr)) @@ -1166,7 +1245,7 @@ def begin_capture_print( ) -> None: """Capture content that is printed (or written to stdout / stderr). - If printing is captured, the `target` will be send an [events.Print][textual.events.Print] message. + If printing is captured, the `target` will be sent an [events.Print][textual.events.Print] message. Args: target: The widget where print content will be sent. @@ -1195,10 +1274,14 @@ async def run_test( tooltips: bool = False, notifications: bool = False, message_hook: Callable[[Message], None] | None = None, - ) -> AsyncGenerator[Pilot, None]: - """An asynchronous context manager for testing app. + ) -> AsyncGenerator[Pilot[ReturnType], None]: + """An asynchronous context manager for testing apps. - Use this to run your app in "headless" (no output) mode and driver the app via a [Pilot][textual.pilot.Pilot] object. + !!! tip + + See the guide for [testing](/guide/testing) Textual apps. + + Use this to run your app in "headless" mode (no output) and drive the app via a [Pilot][textual.pilot.Pilot] object. Example: @@ -1214,7 +1297,8 @@ async def run_test( or None to auto-detect. tooltips: Enable tooltips when testing. notifications: Enable notifications when testing. - message_hook: An optional callback that will called with every message going through the app. + message_hook: An optional callback that will be called each time any message arrives at any + message pump in the app. """ from .pilot import Pilot @@ -1318,6 +1402,7 @@ async def run_auto_pilot( try: app._loop = asyncio.get_running_loop() app._thread_id = threading.get_ident() + await app._process_messages( ready_callback=None if auto_pilot is None else app_ready, headless=headless, @@ -1375,8 +1460,10 @@ async def run_app() -> None: return self.return_value async def _on_css_change(self) -> None: - """Called when the CSS changes (if watch_css is True).""" - css_paths = self.css_path + """Callback for the file monitor, called when CSS files change.""" + css_paths = ( + self.css_monitor._paths if self.css_monitor is not None else self.css_path + ) if css_paths: try: time = perf_counter() @@ -1502,7 +1589,6 @@ def update_styles(self, node: DOMNode) -> None: will be added, and this method is called to apply the corresponding :hover styles. """ - descendants = node.walk_children(with_self=True) self.stylesheet.update_nodes(descendants, animate=True) @@ -1565,28 +1651,40 @@ def mount_all( """ return self.mount(*widgets, before=before, after=after) - def _init_mode(self, mode: str) -> None: + def _init_mode(self, mode: str) -> AwaitMount: """Do internal initialisation of a new screen stack mode. Args: mode: Name of the mode. + + Returns: + An optionally awaitable object which can be awaited until the screen + associated with the mode has been mounted. """ stack = self._screen_stacks.get(mode, []) - if not stack: + if stack: + await_mount = AwaitMount(stack[0], []) + else: _screen = self.MODES[mode] new_screen: Screen | str = _screen() if callable(_screen) else _screen - screen, _ = self._get_screen(new_screen) + screen, await_mount = self._get_screen(new_screen) stack.append(screen) self._load_screen_css(screen) + self._screen_stacks[mode] = stack + return await_mount - def switch_mode(self, mode: str) -> None: + def switch_mode(self, mode: str) -> AwaitMount: """Switch to a given mode. Args: mode: The mode to switch to. + Returns: + An optionally awaitable object which waits for the screen associated + with the mode to be mounted. + Raises: UnknownModeError: If trying to switch to an unknown mode. """ @@ -1597,13 +1695,19 @@ def switch_mode(self, mode: str) -> None: self.screen.refresh() if mode not in self._screen_stacks: - self._init_mode(mode) + await_mount = self._init_mode(mode) + else: + await_mount = AwaitMount(self.screen, []) + self._current_mode = mode self.screen._screen_resized(self.size) self.screen.post_message(events.ScreenResume()) + self.log.system(f"{self._current_mode!r} is the current mode") self.log.system(f"{self.screen} is active") + return await_mount + def add_mode( self, mode: str, base_screen: str | Screen | Callable[[], Screen] ) -> None: @@ -1721,19 +1825,22 @@ def _load_screen_css(self, screen: Screen): update = False for path in screen.css_path: - if not self.stylesheet.has_source(path): + if not self.stylesheet.has_source(str(path), ""): self.stylesheet.read(path) update = True if screen.CSS: try: - screen_css_path = ( - f"{inspect.getfile(screen.__class__)}:{screen.__class__.__name__}" - ) + screen_path = inspect.getfile(screen.__class__) except (TypeError, OSError): - screen_css_path = f"{screen.__class__.__name__}" - if not self.stylesheet.has_source(screen_css_path): + screen_path = "" + screen_class_var = f"{screen.__class__.__name__}.CSS" + read_from = (screen_path, screen_class_var) + if not self.stylesheet.has_source(screen_path, screen_class_var): self.stylesheet.add_source( - screen.CSS, path=screen_css_path, is_default_css=False + screen.CSS, + read_from=read_from, + is_default_css=False, + scope=screen._css_type_name if screen.SCOPED_CSS else "", ) update = True if update: @@ -1760,25 +1867,58 @@ def _replace_screen(self, screen: Screen) -> Screen: self.log.system(f"{screen} REMOVED") return screen + @overload def push_screen( self, screen: Screen[ScreenResultType] | str, callback: ScreenResultCallbackType[ScreenResultType] | None = None, + wait_for_dismiss: Literal[False] = False, ) -> AwaitMount: + ... + + @overload + def push_screen( + self, + screen: Screen[ScreenResultType] | str, + callback: ScreenResultCallbackType[ScreenResultType] | None = None, + wait_for_dismiss: Literal[True] = True, + ) -> asyncio.Future[ScreenResultType]: + ... + + def push_screen( + self, + screen: Screen[ScreenResultType] | str, + callback: ScreenResultCallbackType[ScreenResultType] | None = None, + wait_for_dismiss: bool = False, + ) -> AwaitMount | asyncio.Future[ScreenResultType]: """Push a new [screen](/guide/screens) on the screen stack, making it the current screen. Args: screen: A Screen instance or the name of an installed screen. callback: An optional callback function that will be called if the screen is [dismissed][textual.screen.Screen.dismiss] with a result. + wait_for_dismiss: If `True`, awaiting this method will return the dismiss value from the screen. When set to `False`, awaiting + this method will wait for the screen to be mounted. Note that `wait_for_dismiss` should only be set to `True` when running in a worker. + + Raises: + NoActiveWorker: If using `wait_for_dismiss` outside of a worker. Returns: - An optional awaitable that awaits the mounting of the screen and its children. + An optional awaitable that awaits the mounting of the screen and its children, or an asyncio Future + to await the result of the screen. """ if not isinstance(screen, (Screen, str)): raise TypeError( f"push_screen requires a Screen instance or str; not {screen!r}" ) + try: + loop = asyncio.get_running_loop() + except RuntimeError: + # Mainly for testing, when push_screen isn't called in an async context + future: asyncio.Future[ScreenResultType] = asyncio.Future() + else: + future = loop.create_future() + if self._screen_stack: self.screen.post_message(events.ScreenSuspend()) self.screen.refresh() @@ -1787,13 +1927,46 @@ def push_screen( message_pump = active_message_pump.get() except LookupError: message_pump = self.app - next_screen._push_result_callback(message_pump, callback) + + next_screen._push_result_callback(message_pump, callback, future) self._load_screen_css(next_screen) self._screen_stack.append(next_screen) self.stylesheet.update(next_screen) next_screen.post_message(events.ScreenResume()) self.log.system(f"{self.screen} is current (PUSHED)") - return await_mount + if wait_for_dismiss: + try: + get_current_worker() + except NoActiveWorker: + raise NoActiveWorker( + "push_screen must be run from a worker when `wait_for_dismiss` is True" + ) from None + return future + else: + return await_mount + + @overload + async def push_screen_wait( + self, screen: Screen[ScreenResultType] + ) -> ScreenResultType: + ... + + @overload + async def push_screen_wait(self, screen: str) -> Any: + ... + + async def push_screen_wait( + self, screen: Screen[ScreenResultType] | str + ) -> ScreenResultType | Any: + """Push a screen and wait for the result (received from [`Screen.dismiss`][textual.screen.Screen.dismiss]). + + Args: + screen: A screen or the name of an installed screen. + + Returns: + The screen's result. + """ + return await self.push_screen(screen, wait_for_dismiss=True) def switch_screen(self, screen: Screen | str) -> AwaitMount: """Switch to another [screen](/guide/screens) by replacing the top of the screen stack with a new screen. @@ -1841,7 +2014,7 @@ def install_screen(self, screen: Screen, name: str) -> None: raise ScreenError(f"Can't install screen; {name!r} is already installed") if screen in self._installed_screens.values(): raise ScreenError( - "Can't install screen; {screen!r} has already been installed" + f"Can't install screen; {screen!r} has already been installed" ) self._installed_screens[name] = screen self.log.system(f"{screen} INSTALLED name={name!r}") @@ -1951,7 +2124,6 @@ def panic(self, *renderables: RenderableType) -> None: Args: *renderables: Text or Rich renderable(s) to display on exit. """ - assert all( is_renderable(renderable) for renderable in renderables ), "Can only call panic with strings or Rich renderables" @@ -1963,6 +2135,7 @@ def render(renderable: RenderableType) -> list[Segment]: pre_rendered = [Segments(render(renderable)) for renderable in renderables] self._exit_renderables.extend(pre_rendered) + self._close_messages_no_wait() def _handle_exception(self, error: Exception) -> None: @@ -1989,6 +2162,8 @@ def _handle_exception(self, error: Exception) -> None: def _fatal_error(self) -> None: """Exits the app after an unhandled exception.""" + from rich.traceback import Traceback + self.bell() traceback = Traceback( show_locals=True, width=None, locals_max_length=5, suppress=[rich] @@ -2012,7 +2187,7 @@ def _print_error_renderables(self) -> None: self.error_console.print(self._exit_renderables[0]) if error_count > 1: self.error_console.print( - f"\n[b]NOTE:[/b] 1 of {error_count} errors shown. Run with [b]--dev[/] to see all errors.", + f"\n[b]NOTE:[/b] 1 of {error_count} errors shown. Run with [b]textual run --dev[/] to see all errors.", markup=True, ) @@ -2049,19 +2224,22 @@ async def _process_messages( try: if self.css_path: self.stylesheet.read_all(self.css_path) - for path, css, tie_breaker in self._get_default_css(): + for read_from, css, tie_breaker, scope in self._get_default_css(): self.stylesheet.add_source( - css, path=path, is_default_css=True, tie_breaker=tie_breaker + css, + read_from=read_from, + is_default_css=True, + tie_breaker=tie_breaker, + scope=scope, ) if self.CSS: try: - app_css_path = ( - f"{inspect.getfile(self.__class__)}:{self.__class__.__name__}" - ) + app_path = inspect.getfile(self.__class__) except (TypeError, OSError): - app_css_path = f"{self.__class__.__name__}" + app_path = "" + read_from = (app_path, f"{self.__class__.__name__}.CSS") self.stylesheet.add_source( - self.CSS, path=app_css_path, is_default_css=False + self.CSS, read_from=read_from, is_default_css=False ) except Exception as error: self._handle_exception(error) @@ -2090,12 +2268,13 @@ async def invoke_ready_callback() -> None: self.check_idle() finally: self._mounted_event.set() + self._is_mounted = True Reactive._initialize_object(self) - self.stylesheet.update(self) + self.stylesheet.apply(self) if self.screen is not default_screen: - self.stylesheet.update(default_screen) + self.stylesheet.apply(default_screen) await self.animator.start() @@ -2149,8 +2328,9 @@ async def invoke_ready_callback() -> None: except Exception as error: self._handle_exception(error) - async def _pre_process(self) -> None: - pass + async def _pre_process(self) -> bool: + """Special case for the app, which doesn't need the functionality in MessagePump.""" + return True async def _ready(self) -> None: """Called immediately prior to processing messages. @@ -2158,6 +2338,9 @@ async def _ready(self) -> None: May be used as a hook for any operations that should run first. """ + ready_time = (perf_counter() - self._start_time) * 1000 + self.log.system(f"ready in {ready_time:0.0f} milliseconds") + async def take_screenshot() -> None: """Take a screenshot and exit.""" self.save_screenshot() @@ -2169,6 +2352,7 @@ async def take_screenshot() -> None: ) async def _on_compose(self) -> None: + _rich_traceback_omit = True try: widgets = [*self.screen._nodes, *compose(self)] except TypeError as error: @@ -2234,6 +2418,7 @@ def _register( *widgets: Widget, before: int | None = None, after: int | None = None, + cache: dict[tuple, RulesMap] | None = None, ) -> list[Widget]: """Register widget(s) so they may receive events. @@ -2242,6 +2427,7 @@ def _register( *widgets: The widget(s) to register. before: A location to mount before. after: A location to mount after. + cache: Optional rules map cache. Returns: List of modified widgets. @@ -2250,6 +2436,8 @@ def _register( if not widgets: return [] + if cache is None: + cache = {} widget_list: Iterable[Widget] if before is not None or after is not None: # There's a before or after, which means there's going to be an @@ -2266,8 +2454,8 @@ def _register( if widget not in self._registry: self._register_child(parent, widget, before, after) if widget._nodes: - self._register(widget, *widget._nodes) - apply_stylesheet(widget) + self._register(widget, *widget._nodes, cache=cache) + apply_stylesheet(widget, cache=cache) if not self._running: # If the app is not running, prevent awaiting of the widget tasks @@ -2412,17 +2600,38 @@ def _display(self, screen: Screen, renderable: RenderableType | None) -> None: try: try: if isinstance(renderable, CompositorUpdate): + cursor_x, cursor_y = self.cursor_position terminal_sequence = renderable.render_segments(console) + terminal_sequence += Control.move_to( + cursor_x, cursor_y + ).segment.text else: segments = console.render(renderable) terminal_sequence = console._render_buffer(segments) except Exception as error: self._handle_exception(error) else: - self._driver.write(terminal_sequence) + if WINDOWS: + # Combat a problem with Python on Windows. + # + # https://github.com/Textualize/textual/issues/2548 + # https://github.com/python/cpython/issues/82052 + CHUNK_SIZE = 8192 + write = self._driver.write + for chunk in ( + terminal_sequence[offset : offset + CHUNK_SIZE] + for offset in range( + 0, len(terminal_sequence), CHUNK_SIZE + ) + ): + write(chunk) + else: + self._driver.write(terminal_sequence) finally: self._end_update() + self._driver.flush() + finally: self.post_display_hook() @@ -2507,17 +2716,44 @@ async def on_event(self, event: events.Event) -> None: # Handle input events that haven't been forwarded # If the event has been forwarded it may have bubbled up back to the App if isinstance(event, events.Compose): - screen = Screen(id=f"_default") + screen: Screen[Any] = Screen(id=f"_default") self._register(self, screen) self._screen_stack.append(screen) screen.post_message(events.ScreenResume()) await super().on_event(event) elif isinstance(event, events.InputEvent) and not event.is_forwarded: + if not self.app_focus and isinstance(event, (events.Key, events.MouseDown)): + self.app_focus = True if isinstance(event, events.MouseEvent): # Record current mouse position on App self.mouse_position = Offset(event.x, event.y) + + if isinstance(event, events.MouseDown): + try: + self._mouse_down_widget, _ = self.get_widget_at( + event.x, event.y + ) + except NoWidget: + # Shouldn't occur, since at the very least this will find the Screen + self._mouse_down_widget = None + self.screen._forward_event(event) + + if ( + isinstance(event, events.MouseUp) + and self._mouse_down_widget is not None + ): + try: + if ( + self.get_widget_at(event.x, event.y)[0] + is self._mouse_down_widget + ): + click_event = events.Click.from_event(event) + self.screen._forward_event(click_event) + except NoWidget: + pass + elif isinstance(event, events.Key): if not await self.check_bindings(event.key, priority=True): forward_target = self.focused or self.screen @@ -2585,7 +2821,7 @@ async def _dispatch_action( """ _rich_traceback_guard = True - log( + log.system( "", namespace=namespace, action_name=action_name, @@ -2601,13 +2837,13 @@ async def _dispatch_action( if callable(public_method): await invoke(public_method, *params) return True - log( + log.system( f" {action_name!r} has no target." f" Could not find methods '_action_{action_name}' or 'action_{action_name}'" ) except SkipAction: # The action method raised this to explicitly not handle the action - log(f" {action_name!r} skipped.") + log.system(f" {action_name!r} skipped.") return False async def _broker_event( @@ -2652,7 +2888,7 @@ async def _on_key(self, event: events.Key) -> None: await self.dispatch_key(event) async def _on_shutdown_request(self, event: events.ShutdownRequest) -> None: - log("shutdown request") + log.system("shutdown request") await self._close_messages() async def _on_resize(self, event: events.Resize) -> None: @@ -2661,6 +2897,16 @@ async def _on_resize(self, event: events.Resize) -> None: for screen in self._background_screens: screen.post_message(event) + async def _on_app_focus(self, event: events.AppFocus) -> None: + """App has focus.""" + # Required by textual-web to manage focus in a web page. + self.app_focus = True + + async def _on_app_blur(self, event: events.AppBlur) -> None: + """App has lost focus.""" + # Required by textual-web to manage focus in a web page. + self.app_focus = False + def _detach_from_dom(self, widgets: list[Widget]) -> list[Widget]: """Detach a list of widgets from the DOM. @@ -2818,6 +3064,15 @@ async def _prune_node(self, root: Widget) -> None: await root._close_messages(wait=True) self._unregister(root) + 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) + else: + self.screen.set_focus(None) + async def action_check_bindings(self, key: str) -> None: """An [action](/guide/actions) to handle a key press using the binding system. @@ -2937,10 +3192,14 @@ def _end_update(self) -> None: def _refresh_notifications(self) -> None: """Refresh the notifications on the current screen, if one is available.""" # If we've got a screen to hand... - if self.screen is not None: + try: + screen = self.screen + except ScreenStackError: + pass + else: try: # ...see if it has a toast rack. - toast_rack = self.screen.get_child_by_type(ToastRack) + toast_rack = screen.get_child_by_type(ToastRack) except NoMatches: # It doesn't. That's fine. Either there won't ever be one, # or one will turn up. Things will work out later. diff --git a/src/textual/await_complete.py b/src/textual/await_complete.py new file mode 100644 index 0000000000..51d807f6d2 --- /dev/null +++ b/src/textual/await_complete.py @@ -0,0 +1,48 @@ +from __future__ import annotations + +from asyncio import Future, gather +from typing import Any, Coroutine, Iterator, TypeVar + +import rich.repr + +ReturnType = TypeVar("ReturnType") + + +@rich.repr.auto(angular=True) +class AwaitComplete: + """An 'optionally-awaitable' object.""" + + def __init__(self, *coroutines: Coroutine[Any, Any, Any]) -> None: + """Create an AwaitComplete. + + Args: + coroutines: One or more coroutines to execute. + """ + self.coroutines: tuple[Coroutine[Any, Any, Any], ...] = coroutines + self._future: Future = gather(*self.coroutines) + + async def __call__(self) -> Any: + return await self + + def __await__(self) -> Iterator[None]: + return self._future.__await__() + + @property + def is_done(self) -> bool: + """Returns 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.""" + if self._future.done(): + return self._future.exception() + return None + + @classmethod + def nothing(cls): + """Returns an already completed instance of AwaitComplete.""" + instance = cls() + instance._future = Future() + instance._future.set_result(None) # Mark it as completed with no result + return instance diff --git a/src/textual/await_remove.py b/src/textual/await_remove.py index 854703623a..f02fe5b840 100644 --- a/src/textual/await_remove.py +++ b/src/textual/await_remove.py @@ -1,5 +1,4 @@ """ - An *optionally* awaitable object returned by methods that remove widgets. """ diff --git a/src/textual/canvas.py b/src/textual/canvas.py new file mode 100644 index 0000000000..5cc5013f86 --- /dev/null +++ b/src/textual/canvas.py @@ -0,0 +1,278 @@ +""" +A Canvas class used to render keylines. + +!!! note + This API is experimental, and may change in the near future. + +""" + +from __future__ import annotations + +from array import array +from collections import defaultdict +from dataclasses import dataclass +from operator import itemgetter +from typing import NamedTuple, Sequence + +from rich.segment import Segment +from rich.style import Style +from typing_extensions import Literal, TypeAlias + +from ._box_drawing import BOX_CHARACTERS, Quad, combine_quads +from .color import Color +from .geometry import Offset, clamp +from .strip import Strip, StripRenderable + +CanvasLineType: TypeAlias = Literal["thin", "heavy", "double"] + + +_LINE_TYPE_INDEX = {"thin": 1, "heavy": 2, "double": 3} + + +class _Span(NamedTuple): + """Associates a sequence of character indices with a color.""" + + start: int + end: int # exclusive + color: Color + + +class Primitive: + """Base class for a canvas primitive.""" + + def render(self, canvas: Canvas) -> None: + """Render to the canvas. + + Args: + canvas: Canvas instance. + """ + raise NotImplementedError() + + +@dataclass +class HorizontalLine(Primitive): + """A horizontal line.""" + + origin: Offset + length: int + color: Color + line_type: CanvasLineType = "thin" + + def render(self, canvas: Canvas) -> None: + x, y = self.origin + if y < 0 or y > canvas.height - 1: + return + box = canvas.box + box_line = box[y] + + line_type_index = _LINE_TYPE_INDEX[self.line_type] + _combine_quads = combine_quads + + right = x + self.length - 1 + + x_range = canvas.x_range(x, x + self.length) + + if x in x_range: + box_line[x] = _combine_quads(box_line[x], (0, line_type_index, 0, 0)) + if right in x_range: + box_line[right] = _combine_quads( + box_line[right], (0, 0, 0, line_type_index) + ) + + line_quad = (0, line_type_index, 0, line_type_index) + for box_x in canvas.x_range(x + 1, x + self.length - 1): + box_line[box_x] = _combine_quads(box_line[box_x], line_quad) + + canvas.spans[y].append(_Span(x, x + self.length, self.color)) + + +@dataclass +class VerticalLine(Primitive): + """A vertical line.""" + + origin: Offset + length: int + color: Color + line_type: CanvasLineType = "thin" + + def render(self, canvas: Canvas) -> None: + x, y = self.origin + if x < 0 or x >= canvas.width: + return + line_type_index = _LINE_TYPE_INDEX[self.line_type] + box = canvas.box + _combine_quads = combine_quads + + y_range = canvas.y_range(y, y + self.length) + + if y in y_range: + box[y][x] = _combine_quads(box[y][x], (0, 0, line_type_index, 0)) + bottom = y + self.length - 1 + + if bottom in y_range: + box[bottom][x] = _combine_quads(box[bottom][x], (line_type_index, 0, 0, 0)) + line_quad = (line_type_index, 0, line_type_index, 0) + + for box_y in canvas.y_range(y + 1, y + self.length - 1): + box[box_y][x] = _combine_quads(box[box_y][x], line_quad) + + spans = canvas.spans + span = _Span(x, x + 1, self.color) + for y in y_range: + spans[y].append(span) + + +@dataclass +class Rectangle(Primitive): + """A rectangle.""" + + origin: Offset + width: int + height: int + color: Color + line_type: CanvasLineType = "thin" + + def render(self, canvas: Canvas) -> None: + origin = self.origin + width = self.width + height = self.height + color = self.color + line_type = self.line_type + HorizontalLine(origin, width, color, line_type).render(canvas) + HorizontalLine(origin + (0, height - 1), width, color, line_type).render(canvas) + VerticalLine(origin, height, color, line_type).render(canvas) + VerticalLine(origin + (width - 1, 0), height, color, line_type).render(canvas) + + +class Canvas: + """A character canvas.""" + + def __init__(self, width: int, height: int) -> None: + """ + + Args: + width: Width of the canvas (in cells). + height Height of the canvas (in cells). + """ + self._width = width + self._height = height + blank_line = " " * width + self.lines: list[array[str]] = [array("u", blank_line) for _ in range(height)] + self.box: list[defaultdict[int, Quad]] = [ + defaultdict(lambda: (0, 0, 0, 0)) for _ in range(height) + ] + self.spans: list[list[_Span]] = [[] for _ in range(height)] + + @property + def width(self) -> int: + """The canvas width.""" + return self._width + + @property + def height(self) -> int: + """The canvas height.""" + return self._height + + def x_range(self, start: int, end: int) -> range: + """Range of x values, clipped to the canvas dimensions. + + Args: + start: Start index. + end: End index. + + Returns: + A range object. + """ + return range( + clamp(start, 0, self._width), + clamp(end, 0, self._width), + ) + + def y_range(self, start: int, end: int) -> range: + """Range of y values, clipped to the canvas dimensions. + + Args: + start: Start index. + end: End index. + + Returns: + A range object. + """ + return range( + clamp(start, 0, self._height), + clamp(end, 0, self._height), + ) + + def render( + self, primitives: Sequence[Primitive], base_style: Style + ) -> StripRenderable: + """Render the canvas. + + Args: + primitives: A sequence of primitives. + base_style: The base style of the canvas. + + Returns: + A Rich renderable for the canvas. + """ + for primitive in primitives: + primitive.render(self) + + get_box = BOX_CHARACTERS.__getitem__ + for box, line in zip(self.box, self.lines): + for offset, quad in box.items(): + line[offset] = get_box(quad) + + width = self._width + span_sort_key = itemgetter(0, 1) + strips: list[Strip] = [] + color = ( + Color.from_rich_color(base_style.bgcolor) + if base_style.bgcolor + else Color.parse("transparent") + ) + _Segment = Segment + for raw_spans, line in zip(self.spans, self.lines): + text = line.tounicode() + + if raw_spans: + segments: list[Segment] = [] + colors = [color] + [span.color for span in raw_spans] + spans = [ + (0, False, 0), + *( + (span.start, False, index) + for index, span in enumerate(raw_spans, 1) + ), + *( + (span.end, True, index) + for index, span in enumerate(raw_spans, 1) + ), + (width, True, 0), + ] + spans.sort(key=span_sort_key) + color_indices: set[int] = set() + color_remove = color_indices.discard + color_add = color_indices.add + for (offset, leaving, style_id), (next_offset, _, _) in zip( + spans, spans[1:] + ): + if leaving: + color_remove(style_id) + else: + color_add(style_id) + if next_offset > offset: + segments.append( + _Segment( + text[offset:next_offset], + base_style + + Style.from_color( + colors[max(color_indices)].rich_color + ), + ) + ) + strips.append(Strip(segments, width)) + else: + strips.append(Strip([_Segment(text, base_style)], width)) + + return StripRenderable(strips, width) diff --git a/src/textual/command.py b/src/textual/command.py index 2aafa10abe..9136bf9d5b 100644 --- a/src/textual/command.py +++ b/src/textual/command.py @@ -7,11 +7,20 @@ from __future__ import annotations from abc import ABC, abstractmethod -from asyncio import CancelledError, Queue, Task, TimeoutError, wait, wait_for +from asyncio import ( + CancelledError, + Queue, + Task, + TimeoutError, + create_task, + wait, + wait_for, +) from dataclasses import dataclass from functools import total_ordering +from inspect import isclass from time import monotonic -from typing import TYPE_CHECKING, Any, AsyncGenerator, AsyncIterator, ClassVar +from typing import TYPE_CHECKING, Any, AsyncGenerator, AsyncIterator, ClassVar, Iterable import rich.repr from rich.align import Align @@ -19,17 +28,15 @@ from rich.emoji import Emoji from rich.style import Style from rich.text import Text -from rich.traceback import Traceback from typing_extensions import Final, TypeAlias from . import on, work -from ._asyncio import create_task from .binding import Binding, BindingType from .containers import Horizontal, Vertical from .events import Click, Mount from .fuzzy import Matcher from .reactive import var -from .screen import ModalScreen, Screen +from .screen import Screen, _SystemModalScreen from .timer import Timer from .types import CallbackType, IgnoreReturnCallbackType from .widget import Widget @@ -165,6 +172,8 @@ async def post_init_task() -> None: try: await self.startup() except Exception: + from rich.traceback import Traceback + self.app.log.error(Traceback()) else: self._init_success = True @@ -211,6 +220,8 @@ async def _shutdown(self) -> None: try: await self.shutdown() except Exception: + from rich.traceback import Traceback + self.app.log.error(Traceback()) async def shutdown(self) -> None: @@ -329,7 +340,7 @@ class CommandInput(Input): """ -class CommandPalette(ModalScreen[CallbackType], inherit_css=False): +class CommandPalette(_SystemModalScreen[CallbackType]): """The Textual command palette.""" COMPONENT_CLASSES: ClassVar[set[str]] = { @@ -354,7 +365,7 @@ class CommandPalette(ModalScreen[CallbackType], inherit_css=False): color: $text-muted; } - App.-dark-mode CommandPalette > .command-palette--highlight { + CommandPalette:dark > .command-palette--highlight { text-style: bold; color: $warning; } @@ -458,6 +469,8 @@ def __init__(self) -> 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.""" + self._no_matches_timer: Timer | None = None + """Keeps track of if there are 'No matches found' message waiting to be displayed.""" self._providers: list[Provider] = [] """List of Provider instances involved in searches.""" @@ -481,10 +494,27 @@ def _provider_classes(self) -> set[type[Provider]]: application][textual.app.App.COMMANDS] and those [defined in the current screen][textual.screen.Screen.COMMANDS]. """ + + def get_providers(root: App | Screen) -> Iterable[type[Provider]]: + """Get providers from app or screen. + + Args: + root: The app or screen. + + Returns: + An iterable of providers. + """ + for provider in root.COMMANDS: + if isclass(provider) and issubclass(provider, Provider): + yield provider + else: + # Lazy loaded providers + yield provider() # type: ignore + return ( set() if self._calling_screen is None - else self.app.COMMANDS | self._calling_screen.COMMANDS + else {*get_providers(self.app), *get_providers(self._calling_screen)} ) def compose(self) -> ComposeResult: @@ -513,11 +543,11 @@ def _on_click(self, event: Click) -> None: method of dismissing the palette. """ if self.get_widget_at(event.screen_x, event.screen_y)[0] is self: - self.workers.cancel_all() + self._cancel_gather_commands() self.dismiss() - def on_mount(self, _: Mount) -> None: - """Capture the calling screen.""" + def _on_mount(self, _: Mount) -> None: + """Configure the command palette once the DOM is ready.""" self._calling_screen = self.app.screen_stack[-2] match_style = self.get_component_rich_style( @@ -532,7 +562,7 @@ def on_mount(self, _: Mount) -> None: for provider in self._providers: provider._post_init() - async def on_unmount(self) -> None: + async def _on_unmount(self) -> None: """Shutdown providers when command palette is closed.""" if self._providers: await wait( @@ -559,6 +589,38 @@ def _become_busy() -> None: self._busy_timer = self.set_timer(self._BUSY_COUNTDOWN, _become_busy) + def _stop_no_matches_countdown(self) -> None: + """Stop any 'No matches' countdown that's in effect.""" + if self._no_matches_timer is not None: + self._no_matches_timer.stop() + self._no_matches_timer = 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: + """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. + """ + 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, + ) + ) + self._list_visible = True + + self._no_matches_timer = self.set_timer( + self._NO_MATCHES_COUNTDOWN, + _show_no_matches, + ) + def _watch__list_visible(self) -> None: """React to the list visible flag being toggled.""" self.query_one(CommandList).set_class(self._list_visible, "--visible") @@ -647,6 +709,8 @@ async def _search_for(self, search_value: str) -> AsyncGenerator[Hit, bool]: if search.done(): exception = search.exception() if exception is not None: + from rich.traceback import Traceback + self.log.error( Traceback.from_exception( type(exception), exception, exception.__traceback__ @@ -732,6 +796,7 @@ def _refresh_command_list( command_list.clear_options().add_options(sorted(commands, reverse=True)) if highlighted is not None: command_list.highlighted = command_list.get_option_index(highlighted.id) + self._list_visible = bool(command_list.option_count) _RESULT_BATCH_TIME: Final[float] = 0.25 """How long to wait before adding commands to the command list.""" @@ -739,7 +804,10 @@ def _refresh_command_list( _NO_MATCHES: Final[str] = "--no-matches" """The ID to give the disabled option that shows there were no matches.""" - @work(exclusive=True) + _GATHER_COMMANDS_GROUP: Final[str] = "--textual-command-palette-gather-commands" + """The group name of the command gathering worker.""" + + @work(exclusive=True, group=_GATHER_COMMANDS_GROUP) async def _gather_commands(self, search_value: str) -> None: """Gather up all of the commands that match the search value. @@ -780,10 +848,7 @@ async def _gather_commands(self, search_value: str) -> None: # grab a reference to that. worker = get_current_worker() - # We're ready to show results, ensure the list is visible. - self._list_visible = True - - # Go into a busy mode. + # Reset busy mode. self._show_busy = False # A flag to keep track of if the current content of the command hit @@ -861,13 +926,11 @@ 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: - command_list.add_option( - Option( - Align.center(Text("No matches found")), - disabled=True, - id=self._NO_MATCHES, - ) - ) + self._start_no_matches_countdown() + + def _cancel_gather_commands(self) -> None: + """Cancel any operation that is gather commands.""" + self.workers.cancel_group(self, self._GATHER_COMMANDS_GROUP) @on(Input.Changed) def _input(self, event: Input.Changed) -> None: @@ -877,7 +940,9 @@ def _input(self, event: Input.Changed) -> None: event: The input event. """ event.stop() - self.workers.cancel_all() + self._cancel_gather_commands() + self._stop_no_matches_countdown() + search_value = event.value.strip() if search_value: self._gather_commands(search_value) @@ -893,7 +958,7 @@ def _select_command(self, event: OptionList.OptionSelected) -> None: event: The option selection event. """ event.stop() - self.workers.cancel_all() + self._cancel_gather_commands() input = self.query_one(CommandInput) with self.prevent(Input.Changed): assert isinstance(event.option, Command) @@ -930,15 +995,20 @@ def _select_or_command( if self._selected_command is not None: # ...we should return it to the parent screen and let it # decide what to do with it (hopefully it'll run it). - self.workers.cancel_all() + self._cancel_gather_commands() self.dismiss(self._selected_command.command) + @on(OptionList.OptionHighlighted) + def _stop_event_leak(self, event: OptionList.OptionHighlighted) -> None: + """Stop any unused events so they don't leak to the application.""" + event.stop() + def _action_escape(self) -> None: """Handle a request to escape out of the command palette.""" if self._list_visible: self._list_visible = False else: - self.workers.cancel_all() + self._cancel_gather_commands() self.dismiss() def _action_command_list(self, action: str) -> None: diff --git a/src/textual/containers.py b/src/textual/containers.py index 6ba1d2966f..c8fe96194e 100644 --- a/src/textual/containers.py +++ b/src/textual/containers.py @@ -26,7 +26,7 @@ class Container(Widget): """ -class ScrollableContainer(Widget, inherit_bindings=False): +class ScrollableContainer(Widget, can_focus=True, inherit_bindings=False): """A scrollable container with vertical layout, and auto scrollbars on both axis.""" DEFAULT_CSS = """ @@ -76,7 +76,7 @@ class Vertical(Widget, inherit_bindings=False): """ -class VerticalScroll(ScrollableContainer, can_focus=True): +class VerticalScroll(ScrollableContainer): """A container with vertical layout and an automatic scrollbar on the Y axis.""" DEFAULT_CSS = """ @@ -101,7 +101,7 @@ class Horizontal(Widget, inherit_bindings=False): """ -class HorizontalScroll(ScrollableContainer, can_focus=True): +class HorizontalScroll(ScrollableContainer): """A container with horizontal layout and an automatic scrollbar on the Y axis.""" DEFAULT_CSS = """ diff --git a/src/textual/css/_help_renderables.py b/src/textual/css/_help_renderables.py index bce6a9a60a..5957162f53 100644 --- a/src/textual/css/_help_renderables.py +++ b/src/textual/css/_help_renderables.py @@ -7,7 +7,6 @@ from rich.highlighter import ReprHighlighter from rich.markup import render from rich.text import Text -from rich.tree import Tree _highlighter = ReprHighlighter() @@ -89,6 +88,8 @@ def __str__(self) -> str: def __rich_console__( self, console: Console, options: ConsoleOptions ) -> RenderResult: + from rich.tree import Tree + tree = Tree(_markup_and_highlight(f"[b blue]{self.summary}"), guide_style="dim") if self.bullets is not None: for bullet in self.bullets: diff --git a/src/textual/css/_help_text.py b/src/textual/css/_help_text.py index e873bd8a00..fd0788359f 100644 --- a/src/textual/css/_help_text.py +++ b/src/textual/css/_help_text.py @@ -14,6 +14,7 @@ VALID_ALIGN_HORIZONTAL, VALID_ALIGN_VERTICAL, VALID_BORDER, + VALID_KEYLINE, VALID_LAYOUT, VALID_STYLE_FLAGS, VALID_TEXT_ALIGN, @@ -149,7 +150,7 @@ def property_invalid_value_help_text( suggested_property_name = _contextualize_property_name( suggested_property_name, context ) - summary += f'. Did you mean "{suggested_property_name}"?' + summary += f". Did you mean '{suggested_property_name}'?" return HelpText(summary) @@ -323,7 +324,7 @@ def color_property_help_text( error.suggested_color if error and isinstance(error, ColorParseError) else None ) if suggested_color: - summary += f'. Did you mean "{suggested_color}"?' + summary += f". Did you mean '{suggested_color}'?" return HelpText( summary=summary, bullets=[ @@ -587,9 +588,7 @@ def scrollbar_size_property_help_text(context: StylingContext) -> HelpText: ), ], ).get_by_context(context), - Bullet( - " and must be positive integers, greater than zero" - ), + Bullet(" and must be non-negative integers."), ], ) @@ -669,6 +668,26 @@ def align_help_text() -> HelpText: ) +def keyline_help_text() -> HelpText: + """Help text to show when the user supplies an invalid value for a `keyline`. + + Returns: + Renderable for displaying the help text for this property. + """ + return HelpText( + summary="Invalid value for [i]keyline[/] property", + bullets=[ + Bullet( + markup="The [i]keyline[/] property expects exactly 2 values", + examples=[ + Example("keyline: "), + ], + ), + Bullet(f"Valid values for are {friendly_list(VALID_KEYLINE)}"), + ], + ) + + def text_align_help_text() -> HelpText: """Help text to show when the user supplies an invalid value for the text-align property. diff --git a/src/textual/css/_style_properties.py b/src/textual/css/_style_properties.py index 658932165e..efcc309930 100644 --- a/src/textual/css/_style_properties.py +++ b/src/textual/css/_style_properties.py @@ -18,7 +18,7 @@ from .._border import normalize_border_value from ..color import Color, ColorParseError -from ..geometry import Spacing, SpacingDimensions, clamp +from ..geometry import NULL_SPACING, Spacing, SpacingDimensions, clamp from ._error_tools import friendly_list from ._help_text import ( border_property_help_text, @@ -31,7 +31,7 @@ string_enum_help_text, style_flags_property_help_text, ) -from .constants import NULL_SPACING, VALID_STYLE_FLAGS +from .constants import VALID_STYLE_FLAGS from .errors import StyleTypeError, StyleValueError from .scalar import ( NULL_SCALAR, @@ -46,8 +46,9 @@ from .transition import Transition if TYPE_CHECKING: + from ..canvas import CanvasLineType from .._layout import Layout - from .styles import Styles, StylesBase + from .styles import StylesBase from .types import AlignHorizontal, AlignVertical, DockEdge, EdgeType @@ -497,6 +498,26 @@ def check_refresh() -> None: check_refresh() +class KeylineProperty: + """Descriptor for getting and setting keyline information.""" + + def __get__( + self, obj: StylesBase, objtype: type[StylesBase] | None = None + ) -> tuple[CanvasLineType, Color]: + return cast( + "tuple[CanvasLineType, Color]", + obj.get_rule("keyline", ("none", Color.parse("transparent"))), + ) + + def __set__(self, obj: StylesBase, keyline: tuple[str, Color] | None): + if keyline is None: + if obj.clear_rule("keyline"): + obj.refresh(layout=True) + else: + if obj.set_rule("keyline", keyline): + obj.refresh(layout=True) + + class SpacingProperty: """Descriptor for getting and setting spacing properties (e.g. padding and margin).""" @@ -740,7 +761,7 @@ def __get__(self, obj: StylesBase, objtype: type[StylesBase] | None = None) -> s Returns: The string property value. """ - return cast(str, obj.get_rule(self.name, self._default)) + return obj.get_rule(self.name, self._default) # type: ignore def _before_refresh(self, obj: StylesBase, value: str | None) -> None: """Do any housekeeping before asking for a layout refresh after a value change.""" @@ -1063,8 +1084,8 @@ def __set__(self, obj: StylesBase, value: float | str | None) -> None: obj.refresh(children=self.children) return - if isinstance(value, float): - float_value = value + if isinstance(value, (int, float)): + float_value = float(value) elif isinstance(value, str) and value.endswith("%"): float_value = float(Scalar.parse(value).value) / 100 else: diff --git a/src/textual/css/_styles_builder.py b/src/textual/css/_styles_builder.py index 641eb36e3c..8b66fec39b 100644 --- a/src/textual/css/_styles_builder.py +++ b/src/textual/css/_styles_builder.py @@ -20,6 +20,7 @@ dock_property_help_text, fractional_property_help_text, integer_help_text, + keyline_help_text, layout_property_help_text, offset_property_help_text, offset_single_axis_help_text, @@ -42,6 +43,7 @@ VALID_CONSTRAIN, VALID_DISPLAY, VALID_EDGE, + VALID_KEYLINE, VALID_OVERFLOW, VALID_OVERLAY, VALID_SCROLLBAR_GUTTER, @@ -65,19 +67,6 @@ from .types import BoxSizing, Display, EdgeType, Overflow, Visibility -def _join_tokens(tokens: Iterable[Token], joiner: str = "") -> str: - """Convert tokens into a string by joining their values - - Args: - tokens: Tokens to join - joiner: String to join on. - - Returns: - The tokens, joined together to form a string. - """ - return joiner.join(token.value for token in tokens) - - class StylesBuilder: """ The StylesBuilder object takes tokens parsed from the CSS and converts @@ -97,9 +86,17 @@ def error(self, name: str, token: Token, message: str | HelpText) -> NoReturn: raise DeclarationError(name, token, message) def add_declaration(self, declaration: Declaration) -> None: - if not declaration.tokens: + if not declaration.name: return rule_name = declaration.name.replace("-", "_") + + if not declaration.tokens: + self.error( + rule_name, + declaration.token, + f"Missing property value for '{declaration.name}:'", + ) + process_method = getattr(self, f"process_{rule_name}", None) if process_method is None: @@ -122,6 +119,13 @@ def add_declaration(self, declaration: Declaration) -> None: if important: tokens = tokens[:-1] self.styles.important.add(rule_name) + + # Check for special token(s) + if tokens[0].name == "token": + value = tokens[0].value + if value == "initial": + self.styles._rules[rule_name] = None + return try: process_method(declaration.name, tokens) except DeclarationError: @@ -259,7 +263,7 @@ def _distribute_importance(self, prefix: str, suffixes: tuple[str, ...]) -> None Args: prefix: The prefix of the style. - siffixes: The suffixes to distribute amongst. + suffixes: The suffixes to distribute amongst. A number of styles can be set with the 'prefix' of the style, providing the values as a series of parameters; or they can be set @@ -543,6 +547,33 @@ def process_outline_bottom(self, name: str, tokens: list[Token]) -> None: def process_outline_left(self, name: str, tokens: list[Token]) -> None: self._process_outline("left", name, tokens) + def process_keyline(self, name: str, tokens: list[Token]) -> None: + if not tokens: + return + if len(tokens) > 2: + self.error(name, tokens[0], keyline_help_text()) + keyline_style = "none" + keyline_color = Color.parse("green") + for token in tokens: + if token.name == "color": + try: + keyline_color = Color.parse(token.value) + except Exception as error: + self.error( + name, + token, + color_property_help_text(name, context="css", error=error), + ) + elif token.name == "token": + try: + keyline_color = Color.parse(token.value) + except Exception as error: + keyline_style = token.value + if keyline_style not in VALID_KEYLINE: + self.error(name, token, keyline_help_text()) + + self.styles._rules["keyline"] = (keyline_style, keyline_color) + def process_offset(self, name: str, tokens: list[Token]) -> None: def offset_error(name: str, token: Token) -> None: self.error(name, token, offset_property_help_text(context="css")) @@ -659,8 +690,8 @@ def process_color(self, name: str, tokens: list[Token]) -> None: process_link_color = process_color process_link_background = process_color - process_link_hover_color = process_color - process_link_hover_background = process_color + process_link_color_hover = process_color + process_link_background_hover = process_color process_border_title_color = process_color process_border_title_background = process_color @@ -681,7 +712,7 @@ def process_text_style(self, name: str, tokens: list[Token]) -> None: self.styles._rules[name.replace("-", "_")] = style_definition # type: ignore process_link_style = process_text_style - process_link_hover_style = process_text_style + process_link_style_hover = process_text_style process_border_title_style = process_text_style process_border_subtitle_style = process_text_style @@ -876,11 +907,7 @@ def scrollbar_size_error(name: str, token: Token) -> None: scrollbar_size_error(name, token2) horizontal = int(token1.value) - if horizontal == 0: - scrollbar_size_error(name, token1) vertical = int(token2.value) - if vertical == 0: - scrollbar_size_error(name, token2) self.styles._rules["scrollbar_size_horizontal"] = horizontal self.styles._rules["scrollbar_size_vertical"] = vertical self._distribute_importance("scrollbar_size", ("horizontal", "vertical")) @@ -895,8 +922,6 @@ def process_scrollbar_size_vertical(self, name: str, tokens: list[Token]) -> Non if token.name != "number" or not token.value.isdigit(): self.error(name, token, scrollbar_size_single_axis_help_text(name)) value = int(token.value) - if value == 0: - self.error(name, token, scrollbar_size_single_axis_help_text(name)) self.styles._rules["scrollbar_size_vertical"] = value def process_scrollbar_size_horizontal(self, name: str, tokens: list[Token]) -> None: @@ -909,8 +934,6 @@ def process_scrollbar_size_horizontal(self, name: str, tokens: list[Token]) -> N if token.name != "number" or not token.value.isdigit(): self.error(name, token, scrollbar_size_single_axis_help_text(name)) value = int(token.value) - if value == 0: - self.error(name, token, scrollbar_size_single_axis_help_text(name)) self.styles._rules["scrollbar_size_horizontal"] = value def _process_grid_rows_or_columns(self, name: str, tokens: list[Token]) -> None: diff --git a/src/textual/css/constants.py b/src/textual/css/constants.py index 95487e8704..52c13cb067 100644 --- a/src/textual/css/constants.py +++ b/src/textual/css/constants.py @@ -2,8 +2,6 @@ import typing -from ..geometry import Spacing - if typing.TYPE_CHECKING: from typing_extensions import Final @@ -64,13 +62,14 @@ VALID_PSEUDO_CLASSES: Final = { "blur", "can-focus", + "dark", "disabled", "enabled", "focus-within", "focus", "hover", + "light", } VALID_OVERLAY: Final = {"none", "screen"} VALID_CONSTRAIN: Final = {"x", "y", "both", "inflect", "none"} - -NULL_SPACING: Final = Spacing.all(0) +VALID_KEYLINE: Final = {"none", "thin", "heavy", "double"} diff --git a/src/textual/css/errors.py b/src/textual/css/errors.py index 2daa7e3b34..815b11394e 100644 --- a/src/textual/css/errors.py +++ b/src/textual/css/errors.py @@ -1,7 +1,6 @@ from __future__ import annotations from rich.console import Console, ConsoleOptions, RenderResult -from rich.traceback import Traceback from ._help_renderables import HelpText from .tokenizer import Token, TokenError @@ -38,6 +37,8 @@ def __init__(self, *args: object, help_text: HelpText | None = None): def __rich_console__( self, console: Console, options: ConsoleOptions ) -> RenderResult: + from rich.traceback import Traceback + yield Traceback.from_exception(type(self), self, self.__traceback__) if self.help_text is not None: yield "" diff --git a/src/textual/css/model.py b/src/textual/css/model.py index 3766606de1..7bcad93775 100644 --- a/src/textual/css/model.py +++ b/src/textual/css/model.py @@ -20,6 +20,7 @@ class SelectorType(Enum): TYPE = 2 CLASS = 3 ID = 4 + NESTED = 5 class CombinatorType(Enum): @@ -106,7 +107,7 @@ def _check_class(self, node: DOMNode) -> bool: return True def _check_id(self, node: DOMNode) -> bool: - if not node.id == self.name: + if node.id != self.name: return False if self.pseudo_classes and not node.has_pseudo_class(*self.pseudo_classes): return False @@ -161,6 +162,7 @@ class RuleSet: is_default_rules: bool = False tie_breaker: int = 0 selector_names: set[str] = field(default_factory=set) + pseudo_classes: set[str] = field(default_factory=set) def __hash__(self): return id(self) @@ -174,6 +176,7 @@ def _selector_to_css(cls, selectors: list[Selector]) -> str: elif selector.combinator == CombinatorType.CHILD: tokens.append(" > ") tokens.append(selector.css) + return "".join(tokens).strip() @property @@ -203,31 +206,20 @@ def _post_parse(self) -> None: type_type = SelectorType.TYPE universal_type = SelectorType.UNIVERSAL - update_selectors = self.selector_names.update + add_selector = self.selector_names.add + add_pseudo_classes = self.pseudo_classes.update for selector_set in self.selector_set: - update_selectors( - "*" - for selector in selector_set.selectors - if selector.type == universal_type - ) - update_selectors( - selector.name - for selector in selector_set.selectors - if selector.type == type_type - ) - update_selectors( - f".{selector.name}" - for selector in selector_set.selectors - if selector.type == class_type - ) - update_selectors( - f"#{selector.name}" - for selector in selector_set.selectors - if selector.type == id_type - ) - update_selectors( - f":{pseudo_class}" - for selector in selector_set.selectors - for pseudo_class in selector.pseudo_classes - ) + for selector in selector_set.selectors: + add_pseudo_classes(selector.pseudo_classes) + + selector = selector_set.selectors[-1] + selector_type = selector.type + if selector_type == universal_type: + add_selector("*") + elif selector_type == type_type: + add_selector(selector.name) + elif selector_type == class_type: + add_selector(f".{selector.name}") + elif selector_type == id_type: + add_selector(f"#{selector.name}") diff --git a/src/textual/css/parse.py b/src/textual/css/parse.py index 1b31e8b66b..d79a12745c 100644 --- a/src/textual/css/parse.py +++ b/src/textual/css/parse.py @@ -1,7 +1,7 @@ from __future__ import annotations +import dataclasses from functools import lru_cache -from pathlib import PurePath from typing import Iterable, Iterator, NoReturn from ..suggestions import get_suggestion @@ -19,7 +19,7 @@ from .styles import Styles from .tokenize import Token, tokenize, tokenize_declarations, tokenize_values from .tokenizer import EOFError, ReferencedBy -from .types import Specificity3 +from .types import CSSLocation, Specificity3 SELECTOR_MAP: dict[str, tuple[SelectorType, Specificity3]] = { "selector": (SelectorType.TYPE, (0, 0, 1)), @@ -30,15 +30,32 @@ "selector_start_id": (SelectorType.ID, (1, 0, 0)), "selector_universal": (SelectorType.UNIVERSAL, (0, 0, 0)), "selector_start_universal": (SelectorType.UNIVERSAL, (0, 0, 0)), + "nested": (SelectorType.NESTED, (0, 0, 0)), } +def _add_specificity( + specificity1: Specificity3, specificity2: Specificity3 +) -> Specificity3: + """Add specificity tuples together. + + Args: + specificity1: Specificity triple. + specificity2: Specificity triple. + + Returns: + Combined specificity. + """ + a1, b1, c1 = specificity1 + a2, b2, c2 = specificity2 + return (a1 + a2, b1 + b2, c1 + c2) + + @lru_cache(maxsize=1024) def parse_selectors(css_selectors: str) -> tuple[SelectorSet, ...]: if not css_selectors.strip(): return () - - tokens = iter(tokenize(css_selectors, "")) + tokens = iter(tokenize(css_selectors, ("", ""))) get_selector = SELECTOR_MAP.get combinator: CombinatorType | None = CombinatorType.DESCENDENT @@ -47,10 +64,13 @@ def parse_selectors(css_selectors: str) -> tuple[SelectorSet, ...]: while True: try: - token = next(tokens) + token = next(tokens, None) except EOFError: break + if token is None: + break token_name = token.name + if token_name == "pseudo_class": selectors[-1]._add_pseudo_class(token.value.lstrip(":")) elif token_name == "whitespace": @@ -85,6 +105,7 @@ def parse_selectors(css_selectors: str) -> tuple[SelectorSet, ...]: def parse_rule_set( + scope: str, tokens: Iterator[Token], token: Token, is_default_rules: bool = False, @@ -127,23 +148,103 @@ def parse_rule_set( token = next(tokens) if selectors: + if scope and selectors[0].name != scope: + scope_selector, scope_specificity = get_selector( + scope, (SelectorType.TYPE, (0, 0, 0)) + ) + selectors.insert( + 0, + Selector( + name=scope, + combinator=CombinatorType.DESCENDENT, + type=scope_selector, + specificity=scope_specificity, + ), + ) rule_selectors.append(selectors[:]) declaration = Declaration(token, "") - errors: list[tuple[Token, str | HelpText]] = [] + nested_rules: list[RuleSet] = [] while True: token = next(tokens) + token_name = token.name if token_name in ("whitespace", "declaration_end"): continue + if token_name in { + "selector_start_id", + "selector_start_class", + "selector_start_universal", + "selector_start", + "nested", + }: + recursive_parse: list[RuleSet] = list( + parse_rule_set( + "", + tokens, + token, + is_default_rules=is_default_rules, + tie_breaker=tie_breaker, + ) + ) + + def combine_selectors( + selectors1: list[Selector], selectors2: list[Selector] + ) -> list[Selector]: + """Combine lists of selectors together, processing any nesting. + + Args: + selectors1: List of selectors. + selectors2: Second list of selectors. + + Returns: + Combined selectors. + """ + if selectors2 and selectors2[0].type == SelectorType.NESTED: + final_selector = selectors1[-1] + nested_selector = selectors2[0] + merged_selector = dataclasses.replace( + final_selector, + pseudo_classes=list( + set( + final_selector.pseudo_classes + + nested_selector.pseudo_classes + ) + ), + specificity=_add_specificity( + final_selector.specificity, nested_selector.specificity + ), + ) + return [*selectors1[:-1], merged_selector, *selectors2[1:]] + else: + return selectors1 + selectors2 + + for rule_selector in rule_selectors: + for rule_set in recursive_parse: + nested_rule_set = RuleSet( + [ + SelectorSet( + combine_selectors( + rule_selector, recursive_selectors.selectors + ), + (recursive_selectors.specificity), + ) + for recursive_selectors in rule_set.selector_set + ], + rule_set.styles, + rule_set.errors, + rule_set.is_default_rules, + rule_set.tie_breaker + tie_breaker, + ) + nested_rules.append(nested_rule_set) + continue if token_name == "declaration_name": - if declaration.tokens: - try: - styles_builder.add_declaration(declaration) - except DeclarationError as error: - errors.append((error.token, error.message)) + try: + styles_builder.add_declaration(declaration) + except DeclarationError as error: + errors.append((error.token, error.message)) declaration = Declaration(token, "") declaration.name = token.value.rstrip(":") elif token_name == "declaration_set_end": @@ -151,11 +252,10 @@ def parse_rule_set( else: declaration.tokens.append(token) - if declaration.tokens: - try: - styles_builder.add_declaration(declaration) - except DeclarationError as error: - errors.append((error.token, error.message)) + try: + styles_builder.add_declaration(declaration) + except DeclarationError as error: + errors.append((error.token, error.message)) rule_set = RuleSet( list(SelectorSet.from_selectors(rule_selectors)), @@ -164,27 +264,31 @@ def parse_rule_set( is_default_rules=is_default_rules, tie_breaker=tie_breaker, ) + rule_set._post_parse() yield rule_set + for nested_rule_set in nested_rules: + nested_rule_set._post_parse() + yield nested_rule_set + -def parse_declarations(css: str, path: str) -> Styles: +def parse_declarations(css: str, read_from: CSSLocation) -> Styles: """Parse declarations and return a Styles object. Args: css: String containing CSS. - path: Path to the CSS, or something else to identify the location. + read_from: The location where the CSS was read from. Returns: A styles object. """ - tokens = iter(tokenize_declarations(css, path)) + tokens = iter(tokenize_declarations(css, read_from)) styles_builder = StylesBuilder() declaration: Declaration | None = None errors: list[tuple[Token, str | HelpText]] = [] - while True: token = next(tokens, None) if token is None: @@ -193,7 +297,7 @@ def parse_declarations(css: str, path: str) -> Styles: if token_name in ("whitespace", "declaration_end", "eof"): continue if token_name == "declaration_name": - if declaration and declaration.tokens: + if declaration: try: styles_builder.add_declaration(declaration) except DeclarationError as error: @@ -207,7 +311,7 @@ def parse_declarations(css: str, path: str) -> Styles: if declaration: declaration.tokens.append(token) - if declaration and declaration.tokens: + if declaration: try: styles_builder.add_declaration(declaration) except DeclarationError as error: @@ -234,7 +338,7 @@ def _unresolved(variable_name: str, variables: Iterable[str], token: Token) -> N message += f"; did you mean '${suggested_variable}'?" raise UnresolvedVariableError( - token.path, + token.read_from, token.code, token.start, message, @@ -260,7 +364,6 @@ def substitute_references( attribute populated with information about where the tokens are being substituted to. """ variables: dict[str, list[Token]] = css_variables.copy() if css_variables else {} - iter_tokens = iter(tokens) while True: @@ -328,8 +431,9 @@ def substitute_references( def parse( + scope: str, css: str, - path: str | PurePath, + read_from: CSSLocation, variables: dict[str, str] | None = None, variable_tokens: dict[str, list[Token]] | None = None, is_default_rules: bool = False, @@ -339,24 +443,25 @@ def parse( and generating rule sets from it. Args: - css: The input CSS - path: Path to the CSS + scope: CSS type name. + css: The input CSS. + read_from: The source location of the CSS. variables: Substitution variables to substitute tokens for. is_default_rules: True if the rules we're extracting are default (i.e. in Widget.DEFAULT_CSS) rules. False if they're from user defined CSS. """ - reference_tokens = tokenize_values(variables) if variables is not None else {} if variable_tokens: reference_tokens.update(variable_tokens) - tokens = iter(substitute_references(tokenize(css, path), variable_tokens)) + tokens = iter(substitute_references(tokenize(css, read_from), variable_tokens)) while True: token = next(tokens, None) if token is None: break if token.name.startswith("selector_start"): yield from parse_rule_set( + scope, tokens, token, is_default_rules=is_default_rules, diff --git a/src/textual/css/query.py b/src/textual/css/query.py index ce966d6b18..7df103e7a3 100644 --- a/src/textual/css/query.py +++ b/src/textual/css/query.py @@ -49,6 +49,9 @@ class WrongType(QueryError): QueryType = TypeVar("QueryType", bound="Widget") +"""Type variable used to type generic queries.""" +ExpectType = TypeVar("ExpectType") +"""Type variable used to further restrict queries.""" @rich.repr.auto(angular=True) @@ -187,10 +190,8 @@ def exclude(self, selector: str) -> DOMQuery[QueryType]: """ return DOMQuery(self.node, exclude=selector, parent=self) - ExpectType = TypeVar("ExpectType") - @overload - def first(self) -> Widget: + def first(self) -> QueryType: ... @overload @@ -226,7 +227,7 @@ def first( raise NoMatches(f"No nodes match {self!r}") @overload - def only_one(self) -> Widget: + def only_one(self) -> QueryType: ... @overload @@ -235,7 +236,7 @@ def only_one(self, expect_type: type[ExpectType]) -> ExpectType: def only_one( self, expect_type: type[ExpectType] | None = None - ) -> Widget | ExpectType: + ) -> QueryType | ExpectType: """Get the *only* matching node. Args: @@ -253,7 +254,9 @@ def only_one( _rich_traceback_omit = True # Call on first to get the first item. Here we'll use all of the # testing and checking it provides. - the_one = self.first(expect_type) if expect_type is not None else self.first() + the_one: ExpectType | QueryType = ( + self.first(expect_type) if expect_type is not None else self.first() + ) try: # Now see if we can access a subsequent item in the nodes. There # should *not* be anything there, so we *should* get an @@ -268,10 +271,10 @@ def only_one( # The IndexError was got, that's a good thing in this case. So # we return what we found. pass - return cast("Widget", the_one) + return the_one @overload - def last(self) -> Widget: + def last(self) -> QueryType: ... @overload @@ -304,7 +307,7 @@ def last( return last @overload - def results(self) -> Iterator[Widget]: + def results(self) -> Iterator[QueryType]: ... @overload @@ -313,7 +316,7 @@ def results(self, filter_type: type[ExpectType]) -> Iterator[ExpectType]: def results( self, filter_type: type[ExpectType] | None = None - ) -> Iterator[Widget | ExpectType]: + ) -> Iterator[QueryType | ExpectType]: """Get query results, optionally filtered by a given type. Args: @@ -404,7 +407,7 @@ def set_styles( node.set_styles(**update_styles) if css is not None: try: - new_styles = parse_declarations(css, path="set_styles") + new_styles = parse_declarations(css, read_from=("set_styles", "")) except DeclarationError as error: raise DeclarationError(error.name, error.token, error.message) from None for node in self: diff --git a/src/textual/css/styles.py b/src/textual/css/styles.py index 1cca8364aa..5996918f38 100644 --- a/src/textual/css/styles.py +++ b/src/textual/css/styles.py @@ -23,6 +23,7 @@ DockProperty, FractionalProperty, IntegerProperty, + KeylineProperty, LayoutProperty, NameListProperty, NameProperty, @@ -69,6 +70,7 @@ if TYPE_CHECKING: from .._layout import Layout from ..dom import DOMNode + from .types import CSSLocation class RulesMap(TypedDict, total=False): @@ -108,6 +110,8 @@ class RulesMap(TypedDict, total=False): outline_bottom: tuple[str, Color] outline_left: tuple[str, Color] + keyline: tuple[str, Color] + box_sizing: BoxSizing width: Scalar height: Scalar @@ -166,10 +170,10 @@ class RulesMap(TypedDict, total=False): link_background: Color link_style: Style - link_hover_color: Color - auto_link_hover_color: bool - link_hover_background: Color - link_hover_style: Style + link_color_hover: Color + auto_link_color_hover: bool + link_background_hover: Color + link_style_hover: Style auto_border_title_color: bool border_title_color: Color @@ -223,8 +227,8 @@ class StylesBase(ABC): "scrollbar_background_active", "link_color", "link_background", - "link_hover_color", - "link_hover_background", + "link_color_hover", + "link_background_hover", } node: DOMNode | None = None @@ -264,6 +268,8 @@ class StylesBase(ABC): outline_bottom = BoxProperty(Color(0, 255, 0)) outline_left = BoxProperty(Color(0, 255, 0)) + keyline = KeylineProperty() + box_sizing = StringEnumProperty(VALID_BOX_SIZING, "border-box", layout=True) width = ScalarProperty(percent_unit=Unit.WIDTH) height = ScalarProperty(percent_unit=Unit.HEIGHT) @@ -333,10 +339,10 @@ class StylesBase(ABC): link_background = ColorProperty("transparent") link_style = StyleFlagsProperty() - link_hover_color = ColorProperty("transparent") - auto_link_hover_color = BooleanProperty(False) - link_hover_background = ColorProperty("transparent") - link_hover_style = StyleFlagsProperty() + link_color_hover = ColorProperty("transparent") + auto_link_color_hover = BooleanProperty(False) + link_background_hover = ColorProperty("transparent") + link_style_hover = StyleFlagsProperty() auto_border_title_color = BooleanProperty(default=False) border_title_color = ColorProperty(Color(255, 255, 255, 0)) @@ -534,12 +540,14 @@ def is_animatable(cls, rule: str) -> bool: @classmethod @lru_cache(maxsize=1024) - def parse(cls, css: str, path: str, *, node: DOMNode | None = None) -> Styles: + def parse( + cls, css: str, read_from: CSSLocation, *, node: DOMNode | None = None + ) -> Styles: """Parse CSS and return a Styles object. Args: css: Textual CSS. - path: Path or string indicating source of CSS. + read_from: Location where the CSS was read from. node: Node to associate with the Styles. Returns: @@ -547,7 +555,7 @@ def parse(cls, css: str, path: str, *, node: DOMNode | None = None) -> Styles: """ from .parse import parse_declarations - styles = parse_declarations(css, path) + styles = parse_declarations(css, read_from) styles.node = node return styles @@ -650,7 +658,11 @@ class Styles(StylesBase): def copy(self) -> Styles: """Get a copy of this Styles object.""" - return Styles(node=self.node, _rules=self.get_rules(), important=self.important) + return Styles( + node=self.node, + _rules=self.get_rules(), + important=self.important, + ) def has_rule(self, rule: str) -> bool: assert rule in RULE_NAMES_SET, f"no such rule {rule!r}" @@ -701,13 +713,15 @@ def get_rule(self, rule: str, default: object = None) -> object: def refresh( self, *, layout: bool = False, children: bool = False, parent: bool = False ) -> None: - if parent and self.node and self.node.parent: - self.node.parent.refresh() - if self.node is not None: - self.node.refresh(layout=layout) - if children: - for child in self.node.walk_children(with_self=False, reverse=True): - child.refresh(layout=layout) + node = self.node + if node is None or not node._is_mounted: + return + if parent and node._parent is not None: + node._parent.refresh() + node.refresh(layout=layout) + if children: + for child in node.walk_children(with_self=False, reverse=True): + child.refresh(layout=layout) def reset(self) -> None: """Reset the rules to initial state.""" @@ -745,11 +759,12 @@ def extract_rules( A list containing a tuple of , . """ is_important = self.important.__contains__ - rules = [ + default_rules = 0 if is_default_rules else 1 + rules: list[tuple[str, Specificity6, Any]] = [ ( rule_name, ( - 0 if is_default_rules else 1, + default_rules, 1 if is_important(rule_name) else 0, *specificity, tie_breaker, @@ -758,6 +773,7 @@ def extract_rules( ) for rule_name, rule_value in self._rules.items() ] + return rules def __rich_repr__(self) -> rich.repr.Result: @@ -1011,12 +1027,12 @@ def append_declaration(name: str, value: str) -> None: if "link_style" in rules: append_declaration("link-style", str(self.link_style)) - if "link_hover_color" in rules: - append_declaration("link-hover-color", self.link_hover_color.css) - if "link_hover_background" in rules: - append_declaration("link-hover-background", self.link_hover_background.css) - if "link_hover_style" in rules: - append_declaration("link-hover-style", str(self.link_hover_style)) + if "link_color_hover" in rules: + append_declaration("link-color-hover", self.link_color_hover.css) + if "link_background_hover" in rules: + append_declaration("link-background-hover", self.link_background_hover.css) + if "link_style_hover" in rules: + append_declaration("link-style-hover", str(self.link_style_hover)) if "border_title_color" in rules: append_declaration("title-color", self.border_title_color.css) @@ -1037,6 +1053,10 @@ def append_declaration(name: str, value: str) -> None: append_declaration("overlay", str(self.overlay)) if "constrain" in rules: append_declaration("constrain", str(self.constrain)) + if "keyline" in rules: + keyline_type, keyline_color = self.keyline + if keyline_type != "none": + append_declaration("keyline", f"{keyline_type}, {keyline_color.css}") lines.sort() return lines diff --git a/src/textual/css/stylesheet.py b/src/textual/css/stylesheet.py index 22bfccaa3f..587fa166c8 100644 --- a/src/textual/css/stylesheet.py +++ b/src/textual/css/stylesheet.py @@ -2,6 +2,7 @@ import os from collections import defaultdict +from itertools import chain from operator import itemgetter from pathlib import Path, PurePath from typing import Iterable, NamedTuple, Sequence, cast @@ -11,10 +12,9 @@ from rich.markup import render from rich.padding import Padding from rich.panel import Panel -from rich.style import Style -from rich.syntax import Syntax from rich.text import Text +from .._cache import LRUCache from ..dom import DOMNode from ..widget import Widget from .errors import StylesheetError @@ -24,7 +24,9 @@ from .styles import RulesMap, Styles from .tokenize import Token, tokenize_values from .tokenizer import TokenError -from .types import Specificity3, Specificity6 +from .types import CSSLocation, Specificity3, Specificity6 + +_DEFAULT_STYLES = Styles() class StylesheetParseError(StylesheetError): @@ -42,6 +44,8 @@ def __init__(self, rules: list[RuleSet]) -> None: @classmethod def _get_snippet(cls, code: str, line_no: int) -> RenderableType: + from rich.syntax import Syntax + syntax = Syntax( code, lexer="scss", @@ -57,45 +61,52 @@ def __rich_console__( self, console: Console, options: ConsoleOptions ) -> RenderResult: error_count = 0 - for rule in self.rules: - for token, message in rule.errors: - error_count += 1 - - if token.path: - path = Path(token.path) - filename = path.name - else: - path = None - filename = "" - - if token.referenced_by: - line_idx, col_idx = token.referenced_by.location - else: - line_idx, col_idx = token.location - line_no, col_no = line_idx + 1, col_idx + 1 - path_string = ( - f"{path.absolute() if path else filename}:{line_no}:{col_no}" - ) - link_style = Style( - link=f"file://{path.absolute()}" if path else None, - color="red", - bold=True, - italic=True, - ) + errors = list( + dict.fromkeys(chain.from_iterable(_rule.errors for _rule in self.rules)) + ) - path_text = Text(path_string, style=link_style) - title = Text.assemble(Text("Error at ", style="bold red"), path_text) - yield "" - yield Panel( - self._get_snippet( - token.referenced_by.code if token.referenced_by else token.code, - line_no, - ), - title=title, - title_align="left", - border_style="red", - ) - yield Padding(message, pad=(0, 0, 1, 3)) + for token, message in errors: + error_count += 1 + + if token.referenced_by: + line_idx, col_idx = token.referenced_by.location + else: + line_idx, col_idx = token.location + line_no, col_no = line_idx + 1, col_idx + 1 + + display_path, widget_var = token.read_from + if display_path: + link_path = str(Path(display_path).absolute()) + filename = Path(link_path).name + else: + link_path = "" + filename = "" + # If we have a widget/variable from where the CSS was read, then line/column + # numbers are relative to the inline CSS and we'll display them next to the + # widget/variable. + # Otherwise, they're absolute positions in a TCSS file and we can show them + # next to the file path. + if widget_var: + path_string = link_path or filename + widget_string = f" in {widget_var}:{line_no}:{col_no}" + else: + path_string = f"{link_path or filename}:{line_no}:{col_no}" + widget_string = "" + + title = Text.assemble( + "Error at ", path_string, widget_string, style="bold red" + ) + yield "" + yield Panel( + self._get_snippet( + token.referenced_by.code if token.referenced_by else token.code, + line_no, + ), + title=title, + title_align="left", + border_style="red", + ) + yield Padding(message, pad=(0, 0, 1, 3)) yield "" yield render( @@ -111,11 +122,14 @@ class CssSource(NamedTuple): content: The CSS as a string. is_defaults: True if the CSS is default (i.e. that defined at the widget level). False if it's user CSS (which will override the defaults). + tie_breaker: Specificity tie breaker. + scope: Scope of CSS. """ content: str is_defaults: bool tie_breaker: int = 0 + scope: str = "" @rich.repr.auto(angular=True) @@ -125,9 +139,10 @@ def __init__(self, *, variables: dict[str, str] | None = None) -> None: self._rules_map: dict[str, list[RuleSet]] | None = None self._variables = variables or {} self.__variable_tokens: dict[str, list[Token]] | None = None - self.source: dict[str, CssSource] = {} + self.source: dict[CSSLocation, CssSource] = {} self._require_parse = False self._invalid_css: set[str] = set() + self._parse_cache: LRUCache[tuple, list[RuleSet]] = LRUCache(64) def __rich_repr__(self) -> rich.repr.Result: yield list(self.source.keys()) @@ -189,22 +204,24 @@ def set_variables(self, variables: dict[str, str]) -> None: self._variables = variables self.__variable_tokens = None self._invalid_css = set() + self._parse_cache.clear() def _parse_rules( self, css: str, - path: str | PurePath, + read_from: CSSLocation, is_default_rules: bool = False, tie_breaker: int = 0, + scope: str = "", ) -> list[RuleSet]: """Parse CSS and return rules. Args: - is_default_rules: css: String containing Textual CSS. - path: Path to CSS or unique identifier + read_from: Original CSS location. is_default_rules: True if the rules we're extracting are default (i.e. in Widget.DEFAULT_CSS) rules. False if they're from user defined CSS. + scope: Scope of rules, or empty string for global scope. Raises: StylesheetError: If the CSS is invalid. @@ -212,11 +229,17 @@ def _parse_rules( Returns: List of RuleSets. """ + cache_key = (css, read_from, is_default_rules, tie_breaker, scope) + try: + return self._parse_cache[cache_key] + except KeyError: + pass try: rules = list( parse( + scope, css, - path, + read_from, variable_tokens=self._variable_tokens, is_default_rules=is_default_rules, tie_breaker=tie_breaker, @@ -225,8 +248,9 @@ def _parse_rules( except TokenError: raise except Exception as error: - raise StylesheetError(f"failed to parse css; {error}") + raise StylesheetError(f"failed to parse css; {error}") from None + self._parse_cache[cache_key] = rules return rules def read(self, filename: str | PurePath) -> None: @@ -246,7 +270,7 @@ def read(self, filename: str | PurePath) -> None: path = os.path.abspath(filename) except Exception: raise StylesheetError(f"unable to read CSS file {filename!r}") from None - self.source[str(path)] = CssSource(css, False, 0) + self.source[(str(path), "")] = CssSource(css, False, 0) self._require_parse = True def read_all(self, paths: Sequence[PurePath]) -> None: @@ -262,47 +286,56 @@ def read_all(self, paths: Sequence[PurePath]) -> None: for path in paths: self.read(path) - def has_source(self, path: str | PurePath) -> bool: + def has_source(self, path: str, class_var: str = "") -> bool: """Check if the stylesheet has this CSS source already. + Args: + path: The file path of the source in question. + class_var: The widget class variable we might be reading the CSS from. + Returns: Whether the stylesheet is aware of this CSS source or not. """ - return str(path) in self.source + return (path, class_var) in self.source def add_source( self, css: str, - path: str | PurePath | None = None, + read_from: CSSLocation | None = None, is_default_css: bool = False, tie_breaker: int = 0, + scope: str = "", ) -> None: """Parse CSS from a string. Args: css: String with CSS source. + read_from: The original source location of the CSS. path: The path of the source if a file, or some other identifier. is_default_css: True if the CSS is defined in the Widget, False if the CSS is defined in a user stylesheet. tie_breaker: Integer representing the priority of this source. + scope: CSS type name to limit scope or empty string for no scope. Raises: StylesheetError: If the CSS could not be read. StylesheetParseError: If the CSS is invalid. """ - if path is None: - path = str(hash(css)) - elif isinstance(path, PurePath): - path = str(css) - if path in self.source and self.source[path].content == css: - # Path already in source, and CSS is identical - content, is_defaults, source_tie_breaker = self.source[path] + if read_from is None: + read_from = ("", str(hash(css))) + + if read_from in self.source and self.source[read_from].content == css: + # Location already in source and CSS is identical. + content, is_defaults, source_tie_breaker, scope = self.source[read_from] if source_tie_breaker > tie_breaker: - self.source[path] = CssSource(content, is_defaults, tie_breaker) + self.source[read_from] = CssSource( + content, is_defaults, tie_breaker, scope + ) return - self.source[path] = CssSource(css, is_default_css, tie_breaker) + self.source[read_from] = CssSource(css, is_default_css, tie_breaker, scope) self._require_parse = True + self._rules_map = None def parse(self) -> None: """Parse the source in the stylesheet. @@ -313,21 +346,28 @@ def parse(self) -> None: rules: list[RuleSet] = [] add_rules = rules.extend - for path, (css, is_default_rules, tie_breaker) in self.source.items(): + for read_from, ( + css, + is_default_rules, + tie_breaker, + scope, + ) in self.source.items(): if css in self._invalid_css: continue try: css_rules = self._parse_rules( css, - path, + read_from=read_from, is_default_rules=is_default_rules, tie_breaker=tie_breaker, + scope=scope, ) except Exception: self._invalid_css.add(css) raise if any(rule.errors for rule in css_rules): error_renderable = StylesheetErrors(css_rules) + self._invalid_css.add(css) raise StylesheetParseError(error_renderable) add_rules(css_rules) self._rules = rules @@ -343,14 +383,27 @@ def reparse(self) -> None: """ # Do this in a fresh Stylesheet so if there are errors we don't break self. stylesheet = Stylesheet(variables=self._variables) - for path, (css, is_defaults, tie_breaker) in self.source.items(): + for read_from, (css, is_defaults, tie_breaker, scope) in self.source.items(): stylesheet.add_source( - css, path, is_default_css=is_defaults, tie_breaker=tie_breaker + css, + read_from=read_from, + is_default_css=is_defaults, + tie_breaker=tie_breaker, + scope=scope, ) - stylesheet.parse() - self._rules = stylesheet.rules - self._rules_map = None - self.source = stylesheet.source + try: + stylesheet.parse() + except Exception: + # If we don't update self's invalid CSS, we might end up reparsing this CSS + # before Textual quits application mode. + # See https://github.com/Textualize/textual/issues/3581. + self._invalid_css.update(stylesheet._invalid_css) + raise + else: + self._rules = stylesheet.rules + self._rules_map = None + self.source = stylesheet.source + self._require_parse = False @classmethod def _check_rule( @@ -364,8 +417,8 @@ def apply( self, node: DOMNode, *, - limit_rules: set[RuleSet] | None = None, animate: bool = False, + cache: dict[tuple, RulesMap] | None = None, ) -> None: """Apply the stylesheet to a DOM node. @@ -376,6 +429,7 @@ def apply( classes modifying the same CSS property), then only the most specific rule will be applied. animate: Animate changed rules. + cache: An optional cache when applying a group of nodes. """ # Dictionary of rule attribute names e.g. "text_background" to list of tuples. # The tuples contain the rule specificity, and the value for that rule. @@ -385,44 +439,121 @@ def apply( rule_attributes: defaultdict[str, list[tuple[Specificity6, object]]] rule_attributes = defaultdict(list) + rules_map = self.rules_map + + # Discard rules which are not applicable early + limit_rules = { + rule + for name in rules_map.keys() & node._selector_names + for rule in rules_map[name] + } + rules = list(filter(limit_rules.__contains__, reversed(self.rules))) + + node._has_hover_style = any("hover" in rule.pseudo_classes for rule in rules) + node._has_focus_within = any( + "focus-within" in rule.pseudo_classes for rule in rules + ) + + cache_key: tuple | None + if cache is not None: + cache_key = ( + node._parent, + ( + None + if node._id is None + else (node._id if f"#{node._id}" in rules_map else None) + ), + node.classes, + node.pseudo_classes, + node._css_type_name, + ) + cached_result: RulesMap | None = cache.get(cache_key) + if cached_result is not None: + self.replace_rules(node, cached_result, animate=animate) + self._process_component_classes(node) + return + else: + cache_key = None + _check_rule = self._check_rule css_path_nodes = node.css_path_nodes - rules: Iterable[RuleSet] - if limit_rules: - rules = [rule for rule in reversed(self.rules) if rule in limit_rules] - else: - rules = reversed(self.rules) + # Rules that may be set to the special value `initial` + initial: set[str] = set() + # Rules in DEFAULT_CSS set to the special value `initial` + initial_defaults: set[str] = set() - # Collect the rules defined in the stylesheet - node._has_hover_style = False - node._has_focus_within = False for rule in rules: is_default_rules = rule.is_default_rules tie_breaker = rule.tie_breaker - if ":hover" in rule.selector_names: - node._has_hover_style = True - if ":focus-within" in rule.selector_names: - node._has_focus_within = True for base_specificity in _check_rule(rule, css_path_nodes): for key, rule_specificity, value in rule.styles.extract_rules( base_specificity, is_default_rules, tie_breaker ): + if value is None: + if is_default_rules: + initial_defaults.add(key) + else: + initial.add(key) rule_attributes[key].append((rule_specificity, value)) - if not rule_attributes: - return - # For each rule declared for this node, keep only the most specific one - get_first_item = itemgetter(0) - node_rules: RulesMap = cast( - RulesMap, - { - name: max(specificity_rules, key=get_first_item)[1] - for name, specificity_rules in rule_attributes.items() - }, - ) - self.replace_rules(node, node_rules, animate=animate) + if rule_attributes: + # For each rule declared for this node, keep only the most specific one + get_first_item = itemgetter(0) + node_rules: RulesMap = cast( + RulesMap, + { + name: max(specificity_rules, key=get_first_item)[1] + for name, specificity_rules in rule_attributes.items() + }, + ) + + # Set initial values + for initial_rule_name in initial: + # Rules with a value of None should be set to the default value + if node_rules[initial_rule_name] is None: # type: ignore[literal-required] + # Exclude non default values + # rule[0] is the specificity, rule[0][0] is 0 for default rules + default_rules = [ + rule + for rule in rule_attributes[initial_rule_name] + if not rule[0][0] + ] + if default_rules: + # There is a default value + new_value = max(default_rules, key=get_first_item)[1] + node_rules[initial_rule_name] = new_value # type: ignore[literal-required] + else: + # No default value + initial_defaults.add(initial_rule_name) + + # Rules in DEFAULT_CSS set to initial + for initial_rule_name in initial_defaults: + if node_rules[initial_rule_name] is None: # type: ignore[literal-required] + default_rules = [ + rule + for rule in rule_attributes[initial_rule_name] + if rule[0][0] + ] + if default_rules: + # There is a default value + rule_value = max(default_rules, key=get_first_item)[1] + else: + rule_value = getattr(_DEFAULT_STYLES, initial_rule_name) + node_rules[initial_rule_name] = rule_value # type: ignore[literal-required] + + if cache is not None: + assert cache_key is not None + cache[cache_key] = node_rules + self.replace_rules(node, node_rules, animate=animate) + self._process_component_classes(node) + + def _process_component_classes(self, node: DOMNode) -> None: + """Process component classes for the given node. + Args: + node: A DOM Node. + """ component_classes = node._get_component_classes() if component_classes: # Create virtual nodes that exist to extract styles @@ -460,25 +591,18 @@ def replace_rules( base_styles = styles.base # Styles currently used on new rules - modified_rule_keys = base_styles.get_rules().keys() | rules.keys() - # Current render rules (missing rules are filled with default) - current_render_rules = styles.get_render_rules() - - # Calculate replacement rules (defaults + new rules) - new_styles = Styles(node, rules) - if new_styles == base_styles: - # Nothing to change, return early - return - - # New render rules - new_render_rules = new_styles.get_render_rules() - - # Some aliases - is_animatable = styles.is_animatable - get_current_render_rule = current_render_rules.get - get_new_render_rule = new_render_rules.get + modified_rule_keys = base_styles._rules.keys() | rules.keys() if animate: + new_styles = Styles(node, rules) + if new_styles == base_styles: + # Nothing to animate, return early + return + current_render_rules = styles.get_render_rules() + is_animatable = styles.is_animatable + get_current_render_rule = current_render_rules.get + new_render_rules = new_styles.get_render_rules() + get_new_render_rule = new_render_rules.get animator = node.app.animator base = node.styles.base for key in modified_rule_keys: @@ -536,21 +660,15 @@ def update_nodes(self, nodes: Iterable[DOMNode], animate: bool = False) -> None: nodes: Nodes to update. animate: Enable CSS animation. """ - rules_map = self.rules_map + cache: dict[tuple, RulesMap] = {} apply = self.apply for node in nodes: - rules = { - rule - for name in node._selector_names - if name in rules_map - for rule in rules_map[name] - } - apply(node, limit_rules=rules, animate=animate) + apply(node, animate=animate, cache=cache) if isinstance(node, Widget) and node.is_scrollable: if node.show_vertical_scrollbar: - apply(node.vertical_scrollbar) + apply(node.vertical_scrollbar, cache=cache) if node.show_horizontal_scrollbar: - apply(node.horizontal_scrollbar) + apply(node.horizontal_scrollbar, cache=cache) if node.show_horizontal_scrollbar and node.show_vertical_scrollbar: - apply(node.scrollbar_corner) + apply(node.scrollbar_corner, cache=cache) diff --git a/src/textual/css/tokenize.py b/src/textual/css/tokenize.py index 12e7ed6e26..7cf1556613 100644 --- a/src/textual/css/tokenize.py +++ b/src/textual/css/tokenize.py @@ -1,10 +1,12 @@ from __future__ import annotations import re -from pathlib import PurePath -from typing import Iterable +from typing import TYPE_CHECKING, Iterable -from textual.css.tokenizer import Expect, Token, Tokenizer +from .tokenizer import Expect, Token, Tokenizer + +if TYPE_CHECKING: + from .types import CSSLocation PERCENT = r"-?\d+\.?\d*%" DECIMAL = r"-?\d+\.?\d*" @@ -45,6 +47,7 @@ # in the CSS file. At this level we might expect to see selectors, comments, # variable definitions etc. expect_root_scope = Expect( + "selector or end of file", whitespace=r"\s+", comment_start=COMMENT_START, comment_line=COMMENT_LINE, @@ -53,11 +56,27 @@ selector_start_universal=r"\*", selector_start=IDENTIFIER, variable_name=rf"{VARIABLE_REF}:", + declaration_set_end=r"\}", ).expect_eof(True) +expect_root_nested = Expect( + "selector or end of file", + whitespace=r"\s+", + comment_start=COMMENT_START, + comment_line=COMMENT_LINE, + selector_start_id=r"\#" + IDENTIFIER, + selector_start_class=r"\." + IDENTIFIER, + selector_start_universal=r"\*", + selector_start=IDENTIFIER, + variable_name=rf"{VARIABLE_REF}:", + declaration_set_end=r"\}", + nested=r"\&", +) + # After a variable declaration e.g. "$warning-text: TOKENS;" # for tokenizing variable value ------^~~~~~~^ expect_variable_name_continue = Expect( + "variable value", variable_value_end=r"\n|;", whitespace=r"\s+", comment_start=COMMENT_START, @@ -66,12 +85,14 @@ ).expect_eof(True) expect_comment_end = Expect( + "comment end", comment_end=re.escape("*/"), ) # After we come across a selector in CSS e.g. ".my-class", we may # find other selectors, pseudo-classes... e.g. ".my-class :hover" expect_selector_continue = Expect( + "selector or {", whitespace=r"\s+", comment_start=COMMENT_START, comment_line=COMMENT_LINE, @@ -83,19 +104,28 @@ combinator_child=">", new_selector=r",", declaration_set_start=r"\{", -) + declaration_set_end=r"\}", +).expect_eof(True) # A rule declaration e.g. "text: red;" # ^---^ expect_declaration = Expect( + "rule or selector", + nested=r"\&", whitespace=r"\s+", comment_start=COMMENT_START, comment_line=COMMENT_LINE, declaration_name=r"[a-zA-Z_\-]+\:", declaration_set_end=r"\}", + # + selector_start_id=r"\#" + IDENTIFIER, + selector_start_class=r"\." + IDENTIFIER, + selector_start_universal=r"\*", + selector_start=IDENTIFIER, ) expect_declaration_solo = Expect( + "rule declaration", whitespace=r"\s+", comment_start=COMMENT_START, comment_line=COMMENT_LINE, @@ -106,6 +136,7 @@ # The value(s)/content from a rule declaration e.g. "text: red;" # ^---^ expect_declaration_content = Expect( + "rule value or end of declaration", declaration_end=r";", whitespace=r"\s+", comment_start=COMMENT_START, @@ -117,6 +148,7 @@ ) expect_declaration_content_solo = Expect( + "rule value or end of declaration", declaration_end=r";", whitespace=r"\s+", comment_start=COMMENT_START, @@ -154,14 +186,16 @@ class TokenizerState: "declaration_set_start": expect_declaration, "declaration_name": expect_declaration_content, "declaration_end": expect_declaration, - "declaration_set_end": expect_root_scope, + "declaration_set_end": expect_root_nested, + "nested": expect_selector_continue, } - def __call__(self, code: str, path: str | PurePath) -> Iterable[Token]: - tokenizer = Tokenizer(code, path=path) + def __call__(self, code: str, read_from: CSSLocation) -> Iterable[Token]: + tokenizer = Tokenizer(code, read_from=read_from) expect = self.EXPECT get_token = tokenizer.get_token get_state = self.STATE_MAP.get + nest_level = 0 while True: token = get_token(expect) name = token.name @@ -172,6 +206,13 @@ def __call__(self, code: str, path: str | PurePath) -> Iterable[Token]: continue elif name == "eof": break + elif name == "declaration_set_start": + nest_level += 1 + elif name == "declaration_set_end": + nest_level -= 1 + expect = expect_root_nested if nest_level else expect_root_scope + yield token + continue expect = get_state(name, expect) yield token @@ -194,7 +235,7 @@ class ValueTokenizerState(TokenizerState): def tokenize_values(values: dict[str, str]) -> dict[str, list[Token]]: - """Tokens the values in a dict of strings. + """Tokenizes the values in a dict of strings. Args: values: A mapping of CSS variable name on to a value, to be @@ -204,6 +245,7 @@ def tokenize_values(values: dict[str, str]) -> dict[str, list[Token]]: A mapping of name on to a list of tokens, """ value_tokens = { - name: list(tokenize_value(value, "__name__")) for name, value in values.items() + name: list(tokenize_value(value, ("__name__", ""))) + for name, value in values.items() } return value_tokens diff --git a/src/textual/css/tokenizer.py b/src/textual/css/tokenizer.py index 276b22b54b..cc748aabe0 100644 --- a/src/textual/css/tokenizer.py +++ b/src/textual/css/tokenizer.py @@ -1,28 +1,29 @@ from __future__ import annotations import re -from pathlib import PurePath -from typing import NamedTuple +from typing import TYPE_CHECKING, NamedTuple import rich.repr from rich.console import Group, RenderableType from rich.highlighter import ReprHighlighter from rich.padding import Padding from rich.panel import Panel -from rich.syntax import Syntax from rich.text import Text from ..suggestions import get_suggestion from ._error_tools import friendly_list from .constants import VALID_PSEUDO_CLASSES +if TYPE_CHECKING: + from .types import CSSLocation + class TokenError(Exception): """Error raised when the CSS cannot be tokenized (syntax error).""" def __init__( self, - path: str, + read_from: CSSLocation, code: str, start: tuple[int, int], message: str, @@ -30,14 +31,14 @@ def __init__( ) -> None: """ Args: - path: Path to source or "" if source is parsed from a literal. + read_from: The location where the CSS was read from. code: The code being parsed. - start: Line number of the error. + start: Line and column number of the error (1-indexed). message: A message associated with the error. - end: End location of token, or None if not known. + end: End location of token (1-indexed), or None if not known. """ - self.path = path + self.read_from = read_from self.code = code self.start = start self.end = end or start @@ -49,6 +50,8 @@ def _get_snippet(self) -> Panel: Returns: A renderable. """ + from rich.syntax import Syntax + line_no = self.start[0] # TODO: Highlight column number syntax = Syntax( @@ -58,9 +61,13 @@ def _get_snippet(self) -> Panel: line_numbers=True, indent_guides=True, line_range=(max(0, line_no - 2), line_no + 2), - highlight_lines={line_no + 1}, + highlight_lines={line_no}, + ) + syntax.stylize_range( + "reverse bold", + (self.start[0], self.start[1] - 1), + (self.end[0], self.end[1] - 1), ) - syntax.stylize_range("reverse bold", self.start, self.end) return Panel(syntax, border_style="red") def __rich__(self) -> RenderableType: @@ -72,7 +79,12 @@ def __rich__(self) -> RenderableType: line_no, col_no = self.start - errors.append(highlighter(f" {self.path or ''}:{line_no}:{col_no}")) + path, widget_variable = self.read_from + if widget_variable: + css_location = f" {path}, {widget_variable}:{line_no}:{col_no}" + else: + css_location = f" {path}:{line_no}:{col_no}" + errors.append(highlighter(css_location)) errors.append(self._get_snippet()) final_message = "\n".join( @@ -94,8 +106,10 @@ class EOFError(TokenError): pass +@rich.repr.auto class Expect: - def __init__(self, **tokens: str) -> None: + def __init__(self, description: str, **tokens: str) -> None: + self.description = f"Expected {description}" self.names = list(tokens.keys()) self.regexes = list(tokens.values()) self._regex = re.compile( @@ -122,26 +136,27 @@ class ReferencedBy(NamedTuple): code: str -@rich.repr.auto +@rich.repr.auto(angular=True) class Token(NamedTuple): name: str value: str - path: str + read_from: CSSLocation code: str location: tuple[int, int] + """Token starting location, 0-indexed.""" referenced_by: ReferencedBy | None = None @property def start(self) -> tuple[int, int]: - """Start line and column (1 indexed).""" + """Start line and column (1-indexed).""" line, offset = self.location - return (line + 1, offset) + return (line + 1, offset + 1) @property def end(self) -> tuple[int, int]: - """End line and column (1 indexed).""" + """End line and column (1-indexed).""" line, offset = self.location - return (line + 1, offset + len(self.value)) + return (line + 1, offset + len(self.value) + 1) def with_reference(self, by: ReferencedBy | None) -> "Token": """Return a copy of the Token, with reference information attached. @@ -153,7 +168,7 @@ def with_reference(self, by: ReferencedBy | None) -> "Token": return Token( name=self.name, value=self.value, - path=self.path, + read_from=self.read_from, code=self.code, location=self.location, referenced_by=by, @@ -165,15 +180,18 @@ def __str__(self) -> str: def __rich_repr__(self) -> rich.repr.Result: yield "name", self.name yield "value", self.value - yield "path", self.path + yield ( + "read_from", + self.read_from[0] if not self.read_from[1] else self.read_from, + ) yield "code", self.code if len(self.code) < 40 else self.code[:40] + "..." yield "location", self.location yield "referenced_by", self.referenced_by, None class Tokenizer: - def __init__(self, text: str, path: str | PurePath = "") -> None: - self.path = str(path) + def __init__(self, text: str, read_from: CSSLocation = ("", "")) -> None: + self.read_from = read_from self.code = text self.lines = text.splitlines(keepends=True) self.line_no = 0 @@ -187,28 +205,27 @@ def get_token(self, expect: Expect) -> Token: return Token( "eof", "", - self.path, + self.read_from, self.code, - (line_no + 1, col_no + 1), + (line_no, col_no), None, ) else: raise EOFError( - self.path, + self.read_from, self.code, (line_no + 1, col_no + 1), - "Unexpected end of file", + "Unexpected end of file; did you forget a '}' ?", ) line = self.lines[line_no] match = expect.match(line, col_no) if match is None: expected = friendly_list(" ".join(name.split("_")) for name in expect.names) - message = f"Expected one of {expected}.; Did you forget a semicolon at the end of a line?" raise TokenError( - self.path, + self.read_from, self.code, - (line_no, col_no), - message, + (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?", ) iter_groups = iter(match.groups()) @@ -224,7 +241,7 @@ def get_token(self, expect: Expect) -> Token: token = Token( name, value, - self.path, + self.read_from, self.code, (line_no, col_no), referenced_by=None, @@ -239,16 +256,16 @@ def get_token(self, expect: Expect) -> Token: all_valid = f"must be one of {friendly_list(VALID_PSEUDO_CLASSES)}" if suggestion: raise TokenError( - self.path, + self.read_from, self.code, - (line_no, col_no), + (line_no + 1, col_no + 1), f"unknown pseudo-class {pseudo_class!r}; did you mean {suggestion!r}?; {all_valid}", ) else: raise TokenError( - self.path, + self.read_from, self.code, - (line_no, col_no), + (line_no + 1, col_no + 1), f"unknown pseudo-class {pseudo_class!r}; {all_valid}", ) @@ -267,7 +284,10 @@ def skip_to(self, expect: Expect) -> Token: while True: if line_no >= len(self.lines): raise EOFError( - self.path, self.code, (line_no, col_no), "Unexpected end of file" + self.read_from, + self.code, + (line_no, col_no), + "Unexpected end of file; did you forget a '}' ?", ) line = self.lines[line_no] match = expect.search(line, col_no) diff --git a/src/textual/css/types.py b/src/textual/css/types.py index c723e0b5fa..ce4cebdd0b 100644 --- a/src/textual/css/types.py +++ b/src/textual/css/types.py @@ -42,3 +42,12 @@ Specificity3 = Tuple[int, int, int] Specificity6 = Tuple[int, int, int, int, int, int] + +CSSLocation = Tuple[str, str] +"""Represents the definition location of a piece of CSS code. + +The first element of the tuple is the file path from where the CSS was read. +If the CSS was read from a Python source file, the second element contains the class +variable from where the CSS was read (e.g., "Widget.DEFAULT_CSS"), otherwise it's an +empty string. +""" diff --git a/src/textual/demo.py b/src/textual/demo.py index 2638a33121..2f37d3718a 100644 --- a/src/textual/demo.py +++ b/src/textual/demo.py @@ -1,8 +1,8 @@ from __future__ import annotations +from importlib.metadata import version from pathlib import Path -from importlib_metadata import version from rich import box from rich.console import RenderableType from rich.json import JSON diff --git a/src/textual/document/__init__.py b/src/textual/document/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/textual/document/_document.py b/src/textual/document/_document.py new file mode 100644 index 0000000000..4fc8076f61 --- /dev/null +++ b/src/textual/document/_document.py @@ -0,0 +1,426 @@ +from __future__ import annotations + +from abc import ABC, abstractmethod +from dataclasses import dataclass +from functools import lru_cache +from typing import TYPE_CHECKING, NamedTuple, Tuple, overload + +from typing_extensions import Literal, get_args + +if TYPE_CHECKING: + from tree_sitter import Node + from tree_sitter.binding import Query + +from textual._cells import cell_len +from textual.geometry import Size + +Newline = Literal["\r\n", "\n", "\r"] +"""The type representing valid line separators.""" +VALID_NEWLINES = set(get_args(Newline)) +"""The set of valid line separator strings.""" + + +@dataclass +class EditResult: + """Contains information about an edit that has occurred.""" + + end_location: Location + """The new end Location after the edit is complete.""" + replaced_text: str + """The text that was replaced.""" + + +@lru_cache(maxsize=1024) +def _utf8_encode(text: str) -> bytes: + """Encode the input text as utf-8 bytes. + + The returned encoded bytes may be retrieved from a cache. + + Args: + text: The text to encode. + + Returns: + The utf-8 bytes representing the input string. + """ + return text.encode("utf-8") + + +def _detect_newline_style(text: str) -> Newline: + """Return the newline type used in this document. + + Args: + text: The text to inspect. + + Returns: + The NewlineStyle used in the file. + """ + if "\r\n" in text: # Windows newline + return "\r\n" + elif "\n" in text: # Unix/Linux/MacOS newline + return "\n" + elif "\r" in text: # Old MacOS newline + return "\r" + else: + return "\n" # Default to Unix style newline + + +class DocumentBase(ABC): + """Describes the minimum functionality a Document implementation must + provide in order to be used by the TextArea widget.""" + + @abstractmethod + def replace_range(self, start: Location, end: Location, text: str) -> EditResult: + """Replace the text at the given range. + + Args: + start: A tuple (row, column) where the edit starts. + end: A tuple (row, column) where the edit ends. + text: The text to insert between start and end. + + Returns: + The new end location after the edit is complete. + """ + + @property + @abstractmethod + def text(self) -> str: + """The text from the document as a string.""" + + @property + @abstractmethod + def newline(self) -> Newline: + """Return the line separator used in the document.""" + + @abstractmethod + def get_line(self, index: int) -> str: + """Returns the line with the given index from the document. + + This is used in rendering lines, and will be called by the + TextArea for each line that is rendered. + + Args: + index: The index of the line in the document. + + Returns: + The str instance representing the line. + """ + + @abstractmethod + def get_text_range(self, start: Location, end: Location) -> str: + """Get the text that falls between the start and end locations. + + Args: + start: The start location of the selection. + end: The end location of the selection. + + Returns: + The text between start (inclusive) and end (exclusive). + """ + + @abstractmethod + def get_size(self, indent_width: int) -> Size: + """Get the size of the document. + + The height is generally the number of lines, and the width + is generally the maximum cell length of all the lines. + + Args: + indent_width: The width to use for tab characters. + + Returns: + The Size of the document bounding box. + """ + + def query_syntax_tree( + self, + query: Query, + start_point: tuple[int, int] | None = None, + end_point: tuple[int, int] | None = None, + ) -> list[tuple[Node, str]]: + """Query the tree-sitter syntax tree. + + The default implementation always returns an empty list. + + To support querying in a subclass, this must be implemented. + + Args: + query: The tree-sitter Query to perform. + start_point: The (row, column byte) to start the query at. + end_point: The (row, column byte) to end the query at. + + Returns: + A tuple containing the nodes and text captured by the query. + """ + return [] + + def prepare_query(self, query: str) -> Query | None: + return None + + @property + @abstractmethod + def line_count(self) -> int: + """Returns the number of lines in the document.""" + + @overload + def __getitem__(self, line_index: int) -> str: + ... + + @overload + def __getitem__(self, line_index: slice) -> list[str]: + ... + + @abstractmethod + def __getitem__(self, line_index: int | slice) -> str | list[str]: + """Return the content of a line as a string, excluding newline characters. + + Args: + line_index: The index or slice of the line(s) to retrieve. + + Returns: + The line or list of lines requested. + """ + + +class Document(DocumentBase): + """A document which can be opened in a TextArea.""" + + def __init__(self, text: str) -> None: + self._newline = _detect_newline_style(text) + """The type of newline used in the text.""" + self._lines: list[str] = text.splitlines(keepends=False) + """The lines of the document, excluding newline characters. + + If there's a newline at the end of the file, the final line is an empty string. + """ + if text.endswith(tuple(VALID_NEWLINES)) or not text: + self._lines.append("") + + @property + def lines(self) -> list[str]: + """Get the document as a list of strings, where each string represents a line. + + Newline characters are not included in at the end of the strings. + + The newline character used in this document can be found via the `Document.newline` property. + """ + return self._lines + + @property + def text(self) -> str: + """Get the text from the document.""" + return self._newline.join(self._lines) + + @property + def newline(self) -> Newline: + """Get the Newline used in this document (e.g. '\r\n', '\n'. etc.)""" + return self._newline + + def get_size(self, tab_width: int) -> Size: + """The Size of the document, taking into account the tab rendering width. + + Args: + tab_width: The width to use for tab indents. + + Returns: + The size (width, height) of the document. + """ + lines = self._lines + cell_lengths = [cell_len(line.expandtabs(tab_width)) for line in lines] + max_cell_length = max(cell_lengths, default=0) + height = len(lines) + return Size(max_cell_length, height) + + def replace_range(self, start: Location, end: Location, text: str) -> EditResult: + """Replace text at the given range. + + Args: + start: A tuple (row, column) where the edit starts. + end: A tuple (row, column) where the edit ends. + text: The text to insert between start and end. + + Returns: + The EditResult containing information about the completed + replace operation. + """ + top, bottom = sorted((start, end)) + top_row, top_column = top + bottom_row, bottom_column = bottom + + insert_lines = text.splitlines() + if text.endswith(tuple(VALID_NEWLINES)): + # Special case where a single newline character is inserted. + insert_lines.append("") + + lines = self._lines + + replaced_text = self.get_text_range(top, bottom) + if bottom_row >= len(lines): + after_selection = "" + else: + after_selection = lines[bottom_row][bottom_column:] + + if top_row >= len(lines): + before_selection = "" + else: + before_selection = lines[top_row][:top_column] + + if insert_lines: + insert_lines[0] = before_selection + insert_lines[0] + destination_column = len(insert_lines[-1]) + insert_lines[-1] = insert_lines[-1] + after_selection + else: + destination_column = len(before_selection) + insert_lines = [before_selection + after_selection] + + lines[top_row : bottom_row + 1] = insert_lines + destination_row = top_row + len(insert_lines) - 1 + + end_location = (destination_row, destination_column) + return EditResult(end_location, replaced_text) + + def get_text_range(self, start: Location, end: Location) -> str: + """Get the text that falls between the start and end locations. + + Returns the text between `start` and `end`, including the appropriate + line separator character as specified by `Document._newline`. Note that + `_newline` is set automatically to the first line separator character + found in the document. + + Args: + start: The start location of the selection. + end: The end location of the selection. + + Returns: + The text between start (inclusive) and end (exclusive). + """ + if start == end: + return "" + + top, bottom = sorted((start, end)) + top_row, top_column = top + bottom_row, bottom_column = bottom + lines = self._lines + if top_row == bottom_row: + line = lines[top_row] + selected_text = line[top_column:bottom_column] + else: + start_line = lines[top_row] + end_line = lines[bottom_row] if bottom_row <= self.line_count - 1 else "" + selected_text = start_line[top_column:] + for row in range(top_row + 1, bottom_row): + selected_text += self._newline + lines[row] + + if bottom_row < self.line_count: + selected_text += self._newline + selected_text += end_line[:bottom_column] + + return selected_text + + @property + def line_count(self) -> int: + """Returns the number of lines in the document.""" + return len(self._lines) + + def get_index_from_location(self, location: Location) -> int: + """Given a location, returns the index from the document's text. + + Args: + location: The location in the document. + + Returns: + The index in the document's text. + """ + row, column = location + index = row * len(self.newline) + column + for line_index in range(row): + index += len(self.get_line(line_index)) + return index + + def get_location_from_index(self, index: int) -> Location: + """Given an index in the document's text, returns the corresponding location. + + Args: + index: The index in the document's text. + + Returns: + The corresponding location. + """ + column_index = 0 + newline_length = len(self.newline) + for line_index in range(self.line_count): + next_column_index = ( + column_index + len(self.get_line(line_index)) + newline_length + ) + if index < next_column_index: + return (line_index, index - column_index) + elif index == next_column_index: + return (line_index + 1, 0) + column_index = next_column_index + + def get_line(self, index: int) -> str: + """Returns the line with the given index from the document. + + Args: + index: The index of the line in the document. + + Returns: + The string representing the line. + """ + line_string = self[index] + return line_string + + @overload + def __getitem__(self, line_index: int) -> str: + ... + + @overload + 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. + + Args: + line_index: The index or slice of the line(s) to retrieve. + + Returns: + The line or list of lines requested. + """ + return self._lines[line_index] + + +Location = Tuple[int, int] +"""A location (row, column) within the document. Indexing starts at 0.""" + + +class Selection(NamedTuple): + """A range of characters within a document from a start point to the end point. + The location of the cursor is always considered to be the `end` point of the selection. + The selection is inclusive of the minimum point and exclusive of the maximum point. + """ + + start: Location = (0, 0) + """The start location of the selection. + + If you were to click and drag a selection inside a text-editor, this is where you *started* dragging. + """ + end: Location = (0, 0) + """The end location of the selection. + + If you were to click and drag a selection inside a text-editor, this is where you *finished* dragging. + """ + + @classmethod + def cursor(cls, location: Location) -> "Selection": + """Create a Selection with the same start and end point - a "cursor". + + Args: + location: The location to create the zero-width Selection. + """ + return cls(location, location) + + @property + def is_empty(self) -> bool: + """Return True if the selection has 0 width, i.e. it's just a cursor.""" + start, end = self + return start == end diff --git a/src/textual/document/_languages.py b/src/textual/document/_languages.py new file mode 100644 index 0000000000..a33f7544e8 --- /dev/null +++ b/src/textual/document/_languages.py @@ -0,0 +1,13 @@ +BUILTIN_LANGUAGES = sorted( + [ + "markdown", + "yaml", + "sql", + "css", + "html", + "json", + "python", + "regex", + "toml", + ] +) diff --git a/src/textual/document/_syntax_aware_document.py b/src/textual/document/_syntax_aware_document.py new file mode 100644 index 0000000000..de571b70f0 --- /dev/null +++ b/src/textual/document/_syntax_aware_document.py @@ -0,0 +1,268 @@ +from __future__ import annotations + +try: + from tree_sitter import Language, Node, Parser, Tree + from tree_sitter.binding import Query + from tree_sitter_languages import get_language, get_parser + + TREE_SITTER = True +except ImportError: + TREE_SITTER = False + +from textual.document._document import Document, EditResult, Location, _utf8_encode +from textual.document._languages import BUILTIN_LANGUAGES + + +class SyntaxAwareDocumentError(Exception): + """General error raised when SyntaxAwareDocument is used incorrectly.""" + + +class SyntaxAwareDocument(Document): + """A wrapper around a Document which also maintains a tree-sitter syntax + tree when the document is edited. + + The primary reason for this split is actually to keep tree-sitter stuff separate, + since it isn't supported in Python 3.7. By having the tree-sitter code + isolated in this subclass, it makes it easier to conditionally import. However, + it does come with other design flaws (e.g. Document is required to have methods + which only really make sense on SyntaxAwareDocument). + + If you're reading this and Python 3.7 is no longer supported by Textual, + consider merging this subclass into the `Document` superclass. + """ + + def __init__( + self, + text: str, + language: str | Language, + ): + """Construct a SyntaxAwareDocument. + + Args: + text: The initial text contained in the document. + language: The language to use. You can pass a string to use a supported + language, or pass in your own tree-sitter `Language` object. + """ + + if not TREE_SITTER: + raise RuntimeError("SyntaxAwareDocument unavailable.") + + super().__init__(text) + self.language: Language | None = None + """The tree-sitter Language or None if tree-sitter is unavailable.""" + + self._parser: Parser | None = None + """The tree-sitter Parser or None if tree-sitter is unavailable.""" + + # If the language is `None`, then avoid doing any parsing related stuff. + 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) + else: + self.language = language + self._parser = Parser() + self._parser.set_language(language) + + self._syntax_tree: Tree = self._parser.parse(self._read_callable) # type: ignore + """The tree-sitter Tree (syntax tree) built from the document.""" + + @property + def language_name(self) -> str | None: + return self.language.name if self.language else None + + def prepare_query(self, query: str) -> Query | None: + """Prepare a tree-sitter tree query. + + Queries should be prepared once, then reused. + + To execute a query, call `query_syntax_tree`. + + Args: + query: The string query to prepare. + + Returns: + The prepared query. + """ + if not TREE_SITTER: + raise SyntaxAwareDocumentError( + "Couldn't prepare query - tree-sitter is not available on this architecture." + ) + + if self.language is None: + raise SyntaxAwareDocumentError( + "Couldn't prepare query - no language assigned." + ) + + return self.language.query(query) + + def query_syntax_tree( + self, + query: Query, + start_point: tuple[int, int] | None = None, + end_point: tuple[int, int] | None = None, + ) -> list[tuple["Node", str]]: + """Query the tree-sitter syntax tree. + + The default implementation always returns an empty list. + + To support querying in a subclass, this must be implemented. + + Args: + query: The tree-sitter Query to perform. + start_point: The (row, column byte) to start the query at. + end_point: The (row, column byte) to end the query at. + + Returns: + A tuple containing the nodes and text captured by the query. + """ + + if not TREE_SITTER: + raise SyntaxAwareDocumentError( + "tree-sitter is not available on this architecture." + ) + + captures_kwargs = {} + if start_point is not None: + captures_kwargs["start_point"] = start_point + if end_point is not None: + captures_kwargs["end_point"] = end_point + + captures = query.captures(self._syntax_tree.root_node, **captures_kwargs) + return captures + + def replace_range(self, start: Location, end: Location, text: str) -> EditResult: + """Replace text at the given range. + + Args: + start: A tuple (row, column) where the edit starts. + end: A tuple (row, column) where the edit ends. + text: The text to insert between start and end. + + Returns: + The new end location after the edit is complete. + """ + top, bottom = sorted((start, end)) + + # An optimisation would be finding the byte offsets as a single operation rather + # than doing two passes over the document content. + start_byte = self._location_to_byte_offset(top) + start_point = self._location_to_point(top) + old_end_byte = self._location_to_byte_offset(bottom) + old_end_point = self._location_to_point(bottom) + + replace_result = super().replace_range(start, end, text) + + text_byte_length = len(_utf8_encode(text)) + end_location = replace_result.end_location + assert self._syntax_tree is not None + assert self._parser is not None + self._syntax_tree.edit( + start_byte=start_byte, + old_end_byte=old_end_byte, + new_end_byte=start_byte + text_byte_length, + start_point=start_point, + old_end_point=old_end_point, + new_end_point=self._location_to_point(end_location), + ) + # Incrementally parse the document. + self._syntax_tree = self._parser.parse( + self._read_callable, self._syntax_tree # type: ignore[arg-type] + ) + + return replace_result + + def get_line(self, line_index: int) -> str: + """Return the string representing the line, not including new line characters. + + Args: + line_index: The index of the line. + + Returns: + The string representing the line. + """ + line_string = self[line_index] + return line_string + + def _location_to_byte_offset(self, location: Location) -> int: + """Given a document coordinate, return the byte offset of that coordinate. + This method only does work if tree-sitter was imported, otherwise it returns 0. + + Args: + location: The location to convert. + + Returns: + An integer byte offset for the given location. + """ + lines = self._lines + row, column = location + lines_above = lines[:row] + end_of_line_width = len(self.newline) + bytes_lines_above = sum( + len(_utf8_encode(line)) + end_of_line_width for line in lines_above + ) + if row < len(lines): + bytes_on_left = len(_utf8_encode(lines[row][:column])) + else: + bytes_on_left = 0 + byte_offset = bytes_lines_above + bytes_on_left + return byte_offset + + def _location_to_point(self, location: Location) -> tuple[int, int]: + """Convert a document location (row_index, column_index) to a tree-sitter + point (row_index, byte_offset_from_start_of_row). If tree-sitter isn't available + returns (0, 0). + + Args: + location: A location (row index, column codepoint offset) + + Returns: + The point corresponding to that location (row index, column byte offset). + """ + lines = self._lines + row, column = location + if row < len(lines): + bytes_on_left = len(_utf8_encode(lines[row][:column])) + else: + bytes_on_left = 0 + return row, bytes_on_left + + def _read_callable(self, byte_offset: int, point: tuple[int, int]) -> bytes: + """A callable which informs tree-sitter about the document content. + + This is passed to tree-sitter which will call it frequently to retrieve + the bytes from the document. + + Args: + byte_offset: The number of (utf-8) bytes from the start of the document. + point: A tuple (row index, column *byte* offset). Note that this differs + from our Location tuple which is (row_index, column codepoint offset). + + Returns: + All the utf-8 bytes between the byte_offset/point and the end of the current + line _including_ the line separator character(s). Returns None if the + offset/point requested by tree-sitter doesn't correspond to a byte. + """ + row, column = point + lines = self._lines + newline = self.newline + + row_out_of_bounds = row >= len(lines) + if row_out_of_bounds: + return b"" + else: + row_text = lines[row] + + encoded_row = _utf8_encode(row_text) + encoded_row_length = len(encoded_row) + + if column < encoded_row_length: + return encoded_row[column:] + _utf8_encode(newline) + elif column == encoded_row_length: + return _utf8_encode(newline[0]) + elif column == encoded_row_length + 1: + if newline == "\r\n": + return b"\n" + + return b"" diff --git a/src/textual/dom.py b/src/textual/dom.py index a65b8beeea..a9c832788e 100644 --- a/src/textual/dom.py +++ b/src/textual/dom.py @@ -1,5 +1,4 @@ """ - A DOMNode is a base class for any object within the Textual Document Object Model, which includes all Widgets, Screens, and Apps. """ @@ -8,10 +7,12 @@ from __future__ import annotations import re -from functools import lru_cache +import threading +from functools import lru_cache, partial from inspect import getfile from typing import ( TYPE_CHECKING, + Any, Callable, ClassVar, Iterable, @@ -24,7 +25,6 @@ import rich.repr from rich.highlighter import ReprHighlighter -from rich.pretty import Pretty from rich.style import Style from rich.text import Text from rich.tree import Tree @@ -50,6 +50,7 @@ from rich.console import RenderableType from .app import App from .css.query import DOMQuery, QueryType + from .css.types import CSSLocation from .message import Message from .screen import Screen from .widget import Widget @@ -132,6 +133,10 @@ class DOMNode(MessagePump): # Mapping of key bindings BINDINGS: ClassVar[list[BindingType]] = [] + # Indicates if the CSS should be automatically scoped + SCOPED_CSS: ClassVar[bool] = True + """Should default css be limited to the widget type?""" + # True if this node inherits the CSS from the base class. _inherit_css: ClassVar[bool] = True @@ -144,6 +149,9 @@ class DOMNode(MessagePump): # List of names of base classes that inherit CSS _css_type_names: ClassVar[frozenset[str]] = frozenset() + # Name of the widget in CSS + _css_type_name: str = "" + # Generated list of bindings _merged_bindings: ClassVar[_Bindings | None] = None @@ -260,7 +268,14 @@ def run_worker( Returns: New Worker instance. """ - worker: Worker[ResultType] = self.workers._new_worker( + + # If we're running a worker from inside a secondary thread, + # do so in a thread-safe way. + if self.app._thread_id != threading.get_ident(): + creator = partial(self.app.call_from_thread, self.workers._new_worker) + else: + creator = self.workers._new_worker + worker: Worker[ResultType] = creator( work, self, name=name, @@ -304,7 +319,9 @@ def __init_subclass__( cls._inherit_bindings = inherit_bindings cls._inherit_component_classes = inherit_component_classes css_type_names: set[str] = set() - for base in cls._css_bases(cls): + bases = cls._css_bases(cls) + cls._css_type_name = bases[0].__name__ + for base in bases: css_type_names.add(base.__name__) cls._merged_bindings = cls._merge_bindings() cls._css_type_names = frozenset(css_type_names) @@ -399,36 +416,54 @@ def _post_register(self, app: App) -> None: """ def __rich_repr__(self) -> rich.repr.Result: - yield "name", self._name, None - yield "id", self._id, None - if self._classes: + # Being a bit defensive here to guard against errors when calling repr before initialization + if hasattr(self, "_name"): + yield "name", self._name, None + if hasattr(self, "_id"): + yield "id", self._id, None + if hasattr(self, "_classes") and self._classes: yield "classes", " ".join(self._classes) - def _get_default_css(self) -> list[tuple[str, str, int]]: + def _get_default_css(self) -> list[tuple[CSSLocation, str, int, str]]: """Gets the CSS for this class and inherited from bases. Default CSS is inherited from base classes, unless `inherit_css` is set to `False` when subclassing. Returns: - A list of tuples containing (PATH, SOURCE) for this - and inherited from base classes. + A list of tuples containing (LOCATION, SOURCE, SPECIFICITY, SCOPE) for this + class and inherited from base classes. """ - css_stack: list[tuple[str, str, int]] = [] + css_stack: list[tuple[CSSLocation, str, int, str]] = [] + + def get_location(base: Type[DOMNode]) -> CSSLocation: + """Get the original location of this DEFAULT_CSS. + + Args: + base: The class from which the default css was extracted. - def get_path(base: Type[DOMNode]) -> str: - """Get a path to the DOM Node""" + Returns: + The filename where the class was defined (if possible) and the class + variable the CSS was extracted from. + """ try: - return f"{getfile(base)}:{base.__name__}" + return (getfile(base), f"{base.__name__}.DEFAULT_CSS") except (TypeError, OSError): - return f"{base.__name__}" + return ("", f"{base.__name__}.DEFAULT_CSS") for tie_breaker, base in enumerate(self._node_bases): - css = base.__dict__.get("DEFAULT_CSS", "").strip() + css: str = base.__dict__.get("DEFAULT_CSS", "") if css: - css_stack.append((get_path(base), css, -tie_breaker)) - + scoped: bool = base.__dict__.get("SCOPED_CSS", True) + css_stack.append( + ( + get_location(base), + css, + -tie_breaker, + base._css_type_name if scoped else "", + ) + ) return css_stack @classmethod @@ -560,20 +595,19 @@ def css_path_nodes(self) -> list[DOMNode]: return result[::-1] @property - def _selector_names(self) -> list[str]: + def _selector_names(self) -> set[str]: """Get a set of selectors applicable to this widget. Returns: Set of selector names. """ - selectors: list[str] = [ + selectors: set[str] = { "*", *(f".{class_name}" for class_name in self._classes), - *(f":{class_name}" for class_name in self.get_pseudo_classes()), *self._css_types, - ] + } if self._id is not None: - selectors.append(f"#{self._id}") + selectors.add(f"#{self._id}") return selectors @property @@ -654,6 +688,7 @@ def tree(self) -> Tree: Returns: A Tree renderable. """ + from rich.pretty import Pretty def render_info(node: DOMNode) -> Pretty: """Render a node for the tree.""" @@ -688,6 +723,7 @@ def css_tree(self) -> Tree: from rich.columns import Columns from rich.console import Group from rich.panel import Panel + from rich.pretty import Pretty from .widget import Widget @@ -877,7 +913,7 @@ def colors(self) -> tuple[Color, Color, Color, Color]: @property def ancestors_with_self(self) -> list[DOMNode]: - """A list of Nodes by tracing a path all the way back to App. + """A list of ancestor nodes found by tracing a path all the way back to App. Note: This is inclusive of ``self``. @@ -895,7 +931,7 @@ def ancestors_with_self(self) -> list[DOMNode]: @property def ancestors(self) -> list[DOMNode]: - """A list of ancestor nodes Nodes by tracing ancestors all the way back to App. + """A list of ancestor nodes found by tracing a path all the way back to App. Returns: A list of nodes. @@ -961,6 +997,9 @@ def reset_styles(self) -> None: def _add_child(self, node: Widget) -> None: """Add a new child node. + !!! note + For tests only. + Args: node: A DOM node. """ @@ -970,6 +1009,9 @@ def _add_child(self, node: Widget) -> None: def _add_children(self, *nodes: Widget) -> None: """Add multiple children to this node. + !!! note + For tests only. + Args: *nodes: Positional args should be new DOM nodes. """ @@ -977,6 +1019,7 @@ def _add_children(self, *nodes: Widget) -> None: for node in nodes: node._attach(self) _append(node) + node._add_children(*node._pending_children) WalkType = TypeVar("WalkType", bound="DOMNode") @@ -1104,12 +1147,12 @@ def query_one( return query.only_one() if expect_type is None else query.only_one(expect_type) - def set_styles(self, css: str | None = None, **update_styles) -> Self: + def set_styles(self, css: str | None = None, **update_styles: Any) -> Self: """Set custom styles on this object. Args: css: Styles in CSS format. - **update_styles: Keyword arguments map style names on to style. + update_styles: Keyword arguments map style names onto style values. Returns: Self. @@ -1117,7 +1160,7 @@ def set_styles(self, css: str | None = None, **update_styles) -> Self: if css is not None: try: - new_styles = parse_declarations(css, path="set_styles") + new_styles = parse_declarations(css, read_from=("set_styles", "")) except DeclarationError as error: raise DeclarationError(error.name, error.token, error.message) from None self._inline_styles.merge(new_styles) @@ -1139,19 +1182,20 @@ def has_class(self, *class_names: str) -> bool: """ return self._classes.issuperset(class_names) - def set_class(self, add: bool, *class_names: str) -> Self: + def set_class(self, add: bool, *class_names: str, update: bool = True) -> Self: """Add or remove class(es) based on a condition. Args: add: Add the classes if True, otherwise remove them. + update: Also update styles. Returns: Self. """ if add: - self.add_class(*class_names) + self.add_class(*class_names, update=update) else: - self.remove_class(*class_names) + self.remove_class(*class_names, update=update) return self def set_classes(self, classes: str | Iterable[str]) -> Self: @@ -1177,11 +1221,12 @@ def _update_styles(self) -> None: except NoActiveAppError: pass - def add_class(self, *class_names: str) -> Self: + def add_class(self, *class_names: str, update: bool = True) -> Self: """Add class names to this Node. Args: *class_names: CSS class names to add. + update: Also update styles. Returns: Self. @@ -1191,14 +1236,16 @@ def add_class(self, *class_names: str) -> Self: self._classes.update(class_names) if old_classes == self._classes: return self - self._update_styles() + if update: + self._update_styles() return self - def remove_class(self, *class_names: str) -> Self: + def remove_class(self, *class_names: str, update: bool = True) -> Self: """Remove class names from this Node. Args: *class_names: CSS class names to remove. + update: Also update styles. Returns: Self. @@ -1208,7 +1255,8 @@ def remove_class(self, *class_names: str) -> Self: self._classes.difference_update(class_names) if old_classes == self._classes: return self - self._update_styles() + if update: + self._update_styles() return self def toggle_class(self, *class_names: str) -> Self: diff --git a/src/textual/driver.py b/src/textual/driver.py index 2676663902..7cada2a473 100644 --- a/src/textual/driver.py +++ b/src/textual/driver.py @@ -4,7 +4,7 @@ from abc import ABC, abstractmethod from typing import TYPE_CHECKING -from . import _time, events +from . import events from .events import MouseUp if TYPE_CHECKING: @@ -32,7 +32,6 @@ def __init__( self._debug = debug self._size = size self._loop = asyncio.get_running_loop() - self._mouse_down_time = _time.get_time() self._down_buttons: list[int] = [] self._last_move_event: events.MouseMove | None = None @@ -57,17 +56,15 @@ def process_event(self, event: events.Event) -> None: Args: event: An event to send. """ + # NOTE: This runs in a thread. + # Avoid calling methods on the app. event._set_sender(self._app) if isinstance(event, events.MouseDown): - self._mouse_down_time = event.time if event.button: self._down_buttons.append(event.button) elif isinstance(event, events.MouseUp): - if event.button: - try: - self._down_buttons.remove(event.button) - except ValueError: - pass + if event.button and event.button in self._down_buttons: + self._down_buttons.remove(event.button) elif isinstance(event, events.MouseMove): if ( self._down_buttons @@ -98,13 +95,6 @@ def process_event(self, event: events.Event) -> None: self.send_event(event) - if ( - isinstance(event, events.MouseUp) - and event.time - self._mouse_down_time <= 0.5 - ): - click_event = events.Click.from_event(event) - self.send_event(click_event) - @abstractmethod def write(self, data: str) -> None: """Write data to the output device. diff --git a/src/textual/drivers/_input_reader_linux.py b/src/textual/drivers/_input_reader_linux.py index 82c032e0b6..04604d820b 100644 --- a/src/textual/drivers/_input_reader_linux.py +++ b/src/textual/drivers/_input_reader_linux.py @@ -4,8 +4,6 @@ from threading import Event from typing import Iterator -from textual import log - class InputReader: """Read input from stdin.""" diff --git a/src/textual/drivers/linux_driver.py b/src/textual/drivers/linux_driver.py index c13512e8ab..9b4b7c9da5 100644 --- a/src/textual/drivers/linux_driver.py +++ b/src/textual/drivers/linux_driver.py @@ -13,7 +13,7 @@ import rich.repr -from .. import events, log +from .. import events from .._xterm_parser import XTermParser from ..driver import Driver from ..geometry import Size @@ -164,7 +164,7 @@ def on_terminal_resize(signum, stack) -> None: self.write("\x1b[?25l") # Hide cursor self.write("\033[?1003h\n") self.flush() - self._key_thread = Thread(target=self.run_input_thread) + self._key_thread = Thread(target=self._run_input_thread) send_size_event() self._key_thread.start() self._request_terminal_sync_mode_support() @@ -233,6 +233,21 @@ def close(self) -> None: if self._writer_thread is not None: self._writer_thread.stop() + def _run_input_thread(self) -> None: + """ + Key thread target that wraps run_input_thread() to die gracefully if it raises + an exception + """ + try: + self.run_input_thread() + except BaseException as error: + import rich.traceback + + self._app.call_later( + self._app.panic, + rich.traceback.Traceback(), + ) + def run_input_thread(self) -> None: """Wait for input and dispatch events.""" selector = selectors.DefaultSelector() @@ -264,7 +279,5 @@ def more_data() -> bool: unicode_data = decode(read(fileno, 1024)) for event in feed(unicode_data): self.process_event(event) - except Exception as error: - log(error) finally: selector.close() diff --git a/src/textual/drivers/web_driver.py b/src/textual/drivers/web_driver.py index 7b0976df94..535e3109c0 100644 --- a/src/textual/drivers/web_driver.py +++ b/src/textual/drivers/web_driver.py @@ -1,6 +1,6 @@ """ -The Remote driver uses the following packet stricture. +The Remote driver uses the following packet structure. 1 byte for packet type. "D" for data, "M" for meta. 4 byte little endian integer for the size of the payload. @@ -142,6 +142,7 @@ def do_exit() -> None: self._enable_bracketed_paste() self.flush() self._key_thread.start() + self._app.post_message(events.AppBlur()) def disable_input(self) -> None: """Disable further input.""" @@ -188,7 +189,7 @@ def _on_meta(self, packet_type: str, payload: bytes) -> None: payload: Meta payload (JSON encoded as bytes). """ payload_map = json.loads(payload) - _type = payload_map.get("type") + _type = payload_map.get("type", {}) if isinstance(payload_map, dict): self.on_meta(_type, payload_map) @@ -203,6 +204,10 @@ def on_meta(self, packet_type: str, payload: dict) -> None: self._size = (payload["width"], payload["height"]) size = Size(*self._size) self._app.post_message(events.Resize(size, size)) + elif packet_type == "focus": + self._app.post_message(events.AppFocus()) + elif packet_type == "blur": + self._app.post_message(events.AppBlur()) elif packet_type == "quit": self._app.post_message(messages.ExitApp()) elif packet_type == "exit": diff --git a/src/textual/drivers/win32.py b/src/textual/drivers/win32.py index 214fb28dc5..5751af2caa 100644 --- a/src/textual/drivers/win32.py +++ b/src/textual/drivers/win32.py @@ -278,7 +278,12 @@ def run(self) -> None: if keys: # Process keys - for event in parser.feed("".join(keys)): + # + # https://github.com/Textualize/textual/issues/3178 has + # the context for the encode/decode here. + for event in parser.feed( + "".join(keys).encode("utf-16", "surrogatepass").decode("utf-16") + ): self.process_event(event) if new_size is not None: # Process changed size diff --git a/src/textual/errors.py b/src/textual/errors.py index 021bcff0fa..034139e204 100644 --- a/src/textual/errors.py +++ b/src/textual/errors.py @@ -1,3 +1,8 @@ +""" +General exception classes. + +""" + from __future__ import annotations diff --git a/src/textual/events.py b/src/textual/events.py index af8aaf0533..a5518407f9 100644 --- a/src/textual/events.py +++ b/src/textual/events.py @@ -228,7 +228,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. """ @@ -417,6 +416,19 @@ def get_content_offset(self, widget: Widget) -> Offset | None: """ if self.screen_offset not in widget.content_region: return None + return self.get_content_offset_capture(widget) + + def get_content_offset_capture(self, widget: Widget) -> Offset: + """Get offset from a widget's content area. + + This method works even if the offset is outside the widget content region. + + Args: + widget: Widget receiving the event. + + Returns: + An offset where the origin is at the top left of the content area. + """ return self.offset - widget.gutter.top_left def _apply_offset(self, x: int, y: int) -> MouseEvent: @@ -548,6 +560,26 @@ class Blur(Event, bubble=False): """ +class AppFocus(Event, bubble=False): + """Sent when the app has focus. + + Used by textual-web. + + - [ ] Bubbles + - [ ] Verbose + """ + + +class AppBlur(Event, bubble=False): + """Sent when the app loses focus. + + Used by textual-web. + + - [ ] Bubbles + - [ ] Verbose + """ + + @dataclass class DescendantFocus(Event, bubble=True, verbose=True): """Sent when a child widget is focussed. diff --git a/src/textual/expand_tabs.py b/src/textual/expand_tabs.py new file mode 100644 index 0000000000..721da64d68 --- /dev/null +++ b/src/textual/expand_tabs.py @@ -0,0 +1,75 @@ +from __future__ import annotations + +import re + +from rich.cells import cell_len + +_TABS_SPLITTER_RE = re.compile(r"(.*?\t|.+?$)") + + +def get_tab_widths(line: str, tab_size: int = 4) -> list[tuple[str, int]]: + """Splits a string line into tuples (str, int). + + Each tuple represents a section of the line which precedes a tab character. + The string is the string text that appears before the tab character (excluding the tab). + The integer is the width that the tab character is expanded to. + + Args: + line: The text to expand tabs in. + tab_size: Number of cells in a tab. + + 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]] = [] + add_part = parts.append + cell_position = 0 + matches = _TABS_SPLITTER_RE.findall(line) + + for match in matches: + expansion_width = 0 + if match.endswith("\t"): + # Remove the tab, and check the width of the rest of the line. + match = match[:-1] + cell_position += cell_len(match) + + # Now move along the line by the width of the tab. + tab_remainder = cell_position % tab_size + expansion_width = tab_size - tab_remainder + cell_position += expansion_width + + add_part((match, expansion_width)) + + return parts + + +def expand_tabs_inline(line: str, tab_size: int = 4) -> str: + """Expands tabs, taking into account double cell characters. + + Args: + line: The text to expand tabs in. + tab_size: Number of cells in a tab. + Returns: + New string with tabs replaced with spaces. + """ + tab_widths = get_tab_widths(line, tab_size) + return "".join( + [part + expansion_width * " " for part, expansion_width in tab_widths] + ) + + +if __name__ == "__main__": + print(expand_tabs_inline("\tbar")) + print(expand_tabs_inline("\tbar\t")) + print(expand_tabs_inline("1\tbar")) + print(expand_tabs_inline("12\tbar")) + print(expand_tabs_inline("123\tbar")) + print(expand_tabs_inline("1234\tbar")) + print(expand_tabs_inline("💩\tbar")) + print(expand_tabs_inline("💩💩\tbar")) + print(expand_tabs_inline("💩💩💩\tbar")) + print(expand_tabs_inline("F💩\tbar")) + print(expand_tabs_inline("F💩O\tbar")) diff --git a/src/textual/file_monitor.py b/src/textual/file_monitor.py index a43cb4538f..3f7bbb3fca 100644 --- a/src/textual/file_monitor.py +++ b/src/textual/file_monitor.py @@ -31,7 +31,7 @@ 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) + return max((os.stat(path).st_mtime for path in self._paths), 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 65378818eb..7494d9a52a 100644 --- a/src/textual/filter.py +++ b/src/textual/filter.py @@ -1,3 +1,16 @@ +"""Filter classes. + +!!! note + + Filters are used internally, and not recommended for use by Textual app developers. + +Filters are used internally to process terminal output after it has been rendered. +Currently this is used internally to convert the application to monochrome, when the NO_COLOR env var is set. + +In the future, this system will be used to implement accessibility features. + +""" + from __future__ import annotations from abc import ABC, abstractmethod diff --git a/src/textual/fuzzy.py b/src/textual/fuzzy.py index 3fa4b0094f..40aa35b363 100644 --- a/src/textual/fuzzy.py +++ b/src/textual/fuzzy.py @@ -1,3 +1,10 @@ +""" +Fuzzy matcher. + +This class is used by the [command palette](/guide/command_palette) to match search terms. + +""" + from __future__ import annotations from re import IGNORECASE, compile, escape diff --git a/src/textual/geometry.py b/src/textual/geometry.py index 1d10bc403e..8727e19c0b 100644 --- a/src/textual/geometry.py +++ b/src/textual/geometry.py @@ -1077,7 +1077,7 @@ def vertical(cls, amount: int) -> Spacing: and no horizontal spacing. Args: - amount: The magnitude of spacing to apply to vertical edges + amount: The magnitude of spacing to apply to vertical edges. Returns: `Spacing(amount, 0, amount, 0)` @@ -1090,7 +1090,7 @@ def horizontal(cls, amount: int) -> Spacing: and no vertical spacing. Args: - amount: The magnitude of spacing to apply to horizontal edges + amount: The magnitude of spacing to apply to horizontal edges. Returns: `Spacing(0, amount, 0, amount)` @@ -1102,7 +1102,7 @@ def all(cls, amount: int) -> Spacing: """Construct a Spacing with a given amount of spacing on all edges. Args: - amount: The magnitude of spacing to apply to all edges + amount: The magnitude of spacing to apply to all edges. Returns: `Spacing(amount, amount, amount, amount)` @@ -1152,5 +1152,8 @@ def grow_maximum(self, other: Spacing) -> Spacing: NULL_REGION: Final = Region(0, 0, 0, 0) """A [Region][textual.geometry.Region] constant for a null region (at the origin, with both width and height set to zero).""" +NULL_SIZE: Final = Size(0, 0) +"""A [Size][textual.geometry.Size] constant for a null size (with zero area).""" + NULL_SPACING: Final = Spacing(0, 0, 0, 0) """A [Spacing][textual.geometry.Spacing] constant for no space.""" diff --git a/src/textual/layouts/grid.py b/src/textual/layouts/grid.py index 3ef2e315f8..d38c74a7d7 100644 --- a/src/textual/layouts/grid.py +++ b/src/textual/layouts/grid.py @@ -6,7 +6,7 @@ from .._layout import ArrangeResult, Layout, WidgetPlacement from .._resolve import resolve from ..css.scalar import Scalar -from ..geometry import Region, Size, Spacing +from ..geometry import Region, Size if TYPE_CHECKING: from ..widget import Widget @@ -30,6 +30,11 @@ def arrange( table_size_columns = max(1, styles.grid_size_columns) table_size_rows = styles.grid_size_rows viewport = parent.screen.size + keyline_style, keyline_color = styles.keyline + offset = (0, 0) + if keyline_style != "none": + size -= (2, 2) + offset = (1, 1) def cell_coords(column_count: int) -> Iterable[tuple[int, int]]: """Iterate over table coordinates ad infinitum. @@ -221,7 +226,6 @@ def apply_height_limits(widget: Widget, height: int) -> int: add_widget = widgets.append max_column = len(columns) - 1 max_row = len(rows) - 1 - margin = Spacing() for widget, (column, row, column_span, row_span) in cell_size_map.items(): x = columns[column][0] if row > max_row: @@ -238,10 +242,10 @@ def apply_height_limits(widget: Widget, height: int) -> int: ) region = ( Region(x, y, int(width + margin.width), int(height + margin.height)) - .shrink(margin) .clip_size(cell_size) + .shrink(margin) ) - add_placement(WidgetPlacement(region, margin, widget)) + add_placement(WidgetPlacement(region + offset, margin, widget)) add_widget(widget) return placements diff --git a/src/textual/layouts/horizontal.py b/src/textual/layouts/horizontal.py index 51d00884f7..9d5697f0fe 100644 --- a/src/textual/layouts/horizontal.py +++ b/src/textual/layouts/horizontal.py @@ -73,16 +73,26 @@ def arrange( _Region = Region _WidgetPlacement = WidgetPlacement - for widget, box_model, margin in zip(children, box_models, margins): + for widget, (content_width, content_height, box_margin), margin in zip( + children, box_models, margins + ): overlay = widget.styles.overlay == "screen" - content_width, content_height, box_margin = box_model offset_y = box_margin.top next_x = x + content_width - region = _Region( - int(x), offset_y, int(next_x - int(x)), int(content_height) - ) add_placement( - _WidgetPlacement(region, box_model.margin, widget, 0, False, overlay) + _WidgetPlacement( + _Region( + int(x), + offset_y, + int(next_x - int(x)), + int(content_height), + ), + box_margin, + widget, + 0, + False, + overlay, + ) ) if not overlay: x = next_x + margin diff --git a/src/textual/layouts/vertical.py b/src/textual/layouts/vertical.py index 539b373dce..a81518f1d2 100644 --- a/src/textual/layouts/vertical.py +++ b/src/textual/layouts/vertical.py @@ -73,17 +73,25 @@ def arrange( _Region = Region _WidgetPlacement = WidgetPlacement - - for widget, box_model, margin in zip(children, box_models, margins): + for widget, (content_width, content_height, box_margin), margin in zip( + children, box_models, margins + ): overlay = widget.styles.overlay == "screen" - content_width, content_height, box_margin = box_model next_y = y + content_height - - region = _Region( - box_margin.left, int(y), int(content_width), int(next_y) - int(y) - ) add_placement( - _WidgetPlacement(region, box_model.margin, widget, 0, False, overlay) + _WidgetPlacement( + _Region( + box_margin.left, + int(y), + int(content_width), + int(next_y) - int(y), + ), + box_margin, + widget, + 0, + False, + overlay, + ) ) if not overlay: y = next_y + margin diff --git a/src/textual/lazy.py b/src/textual/lazy.py new file mode 100644 index 0000000000..ef8d6bfbd4 --- /dev/null +++ b/src/textual/lazy.py @@ -0,0 +1,65 @@ +""" +Tools for lazy loading widgets. +""" + + +from __future__ import annotations + +from .widget import Widget + + +class Lazy(Widget): + """Wraps a widget so that it is mounted *lazily*. + + Lazy widgets are mounted after the first refresh. This can be used to display some parts of + the UI very quickly, followed by the lazy widgets. Technically, this won't make anything + faster, but it reduces the time the user sees a blank screen and will make apps feel + more responsive. + + Making a widget lazy is beneficial for widgets which start out invisible, such as tab panes. + + Note that since lazy widgets aren't mounted immediately (by definition), they will not appear + in queries for a brief interval until they are mounted. Your code should take this in to account. + + Example: + + ```python + def compose(self) -> ComposeResult: + yield Footer() + with ColorTabs("Theme Colors", "Named Colors"): + yield Content(ThemeColorButtons(), ThemeColorsView(), id="theme") + yield Lazy(NamedColorsView()) + ``` + + """ + + DEFAULT_CSS = """ + Lazy { + display: none; + } + """ + + def __init__(self, widget: Widget) -> None: + """Create a lazy widget. + + Args: + widget: A widget that should be mounted after a refresh. + """ + self._replace_widget = widget + super().__init__() + + def compose_add_child(self, widget: Widget) -> None: + self._replace_widget.compose_add_child(widget) + + async def mount_composed_widgets(self, widgets: list[Widget]) -> None: + parent = self.parent + if parent is None: + return + assert isinstance(parent, Widget) + + async def mount() -> None: + """Perform the mount and discard the lazy widget.""" + await parent.mount(self._replace_widget, after=self) + await self.remove() + + self.call_after_refresh(mount) diff --git a/src/textual/message_pump.py b/src/textual/message_pump.py index 7ed468dca2..808422cdce 100644 --- a/src/textual/message_pump.py +++ b/src/textual/message_pump.py @@ -1,27 +1,30 @@ """ -A message pump is a base class for any object which processes messages, which includes Widget, Screen, and App. +A `MessagePump` is a base class for any object which processes messages, which includes Widget, Screen, and App. + +!!! tip + + Most of the method here are useful in general app development. + """ from __future__ import annotations import asyncio import inspect import threading -from asyncio import CancelledError, Queue, QueueEmpty, Task +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 weakref import WeakSet from . import Logger, events, log, messages -from ._asyncio import create_task from ._callback import invoke from ._context import NoActiveAppError, active_app, active_message_pump from ._context import message_hook as message_hook_context_var from ._context import prevent_message_types_stack from ._on import OnNoWidget from ._time import time -from ._types import CallbackType from .case import camel_to_snake from .css.match import match from .errors import DuplicateKeyHandlers @@ -118,6 +121,13 @@ def __init__(self, parent: MessagePump | None = None) -> None: self._last_idle: float = time() self._max_idle: float | None = None self._mounted_event = asyncio.Event() + self._is_mounted = False + """Having this explicit Boolean is an optimization. + + The same information could be retrieved from `self._mounted_event.is_set()`, but + we need to access this frequently in the compositor and the attribute with the + explicit Boolean value is faster than the two lookups and the function call. + """ self._next_callbacks: list[events.Callback] = [] self._thread_id: int = threading.get_ident() @@ -470,7 +480,9 @@ async def _process_messages(self) -> None: self._running = True active_message_pump.set(self) - await self._pre_process() + if not await self._pre_process(): + self._running = False + return try: await self._process_messages_loop() @@ -481,10 +493,16 @@ async def _process_messages(self) -> None: for timer in list(self._timers): timer.stop() - async def _pre_process(self) -> None: - """Procedure to run before processing messages.""" + async def _pre_process(self) -> bool: + """Procedure to run before processing messages. + + Returns: + `True` if successful, or `False` if any exception occurred. + + """ # Dispatch compose and mount messages without going through loop # These events must occur in this order, and at the start. + try: await self._dispatch_message(events.Compose()) await self._dispatch_message(events.Mount()) @@ -492,9 +510,12 @@ async def _pre_process(self) -> None: self._post_mount() except Exception as error: self.app._handle_exception(error) + return False finally: # This is critical, mount may be waiting self._mounted_event.set() + self._is_mounted = True + return True def _post_mount(self): """Called after the object has been mounted.""" @@ -533,6 +554,7 @@ async def _process_messages_loop(self) -> None: raise except Exception as error: self._mounted_event.set() + self._is_mounted = True self.app._handle_exception(error) break finally: diff --git a/src/textual/pad.py b/src/textual/pad.py new file mode 100644 index 0000000000..e92cf71ccd --- /dev/null +++ b/src/textual/pad.py @@ -0,0 +1,79 @@ +from typing import cast + +from rich.align import Align, AlignMethod +from rich.console import ( + Console, + ConsoleOptions, + JustifyMethod, + RenderableType, + RenderResult, +) +from rich.measure import Measurement +from rich.segment import Segment, Segments +from rich.style import Style + + +class HorizontalPad: + """Rich renderable to add padding on the left and right of a renderable. + + Note that unlike Rich's Padding class this align each line independently. + + """ + + def __init__( + self, + renderable: RenderableType, + left: int, + right: int, + pad_style: Style, + justify: JustifyMethod, + ) -> None: + """ + Initialize HorizontalPad. + + Args: + renderable: A Rich renderable. + left: Left padding. + right: Right padding. + pad_style: Style of padding. + justify: Justify method. + """ + self.renderable = renderable + self.left = left + self.right = right + self.pad_style = pad_style + self.justify = justify + + def __rich_console__( + self, console: Console, options: ConsoleOptions + ) -> RenderResult: + options = options.update( + width=options.max_width - self.left - self.right, height=None + ) + lines = console.render_lines(self.renderable, options, pad=False) + left_pad = Segment(" " * self.left, self.pad_style) + right_pad = Segment(" " * self.right, self.pad_style) + + align: AlignMethod = cast( + AlignMethod, + self.justify if self.justify in {"left", "right", "center"} else "left", + ) + + for line in lines: + pad_line = line + if self.left: + pad_line = [left_pad, *line] + if self.right: + pad_line.append(right_pad) + segments = Segments(pad_line) + yield Align(segments, align=align) + + def __rich_measure__( + self, console: "Console", options: "ConsoleOptions" + ) -> Measurement: + measurement = Measurement.get(console, options, self.renderable) + total_padding = self.left + self.right + return Measurement( + measurement.minimum + total_padding, + measurement.maximum + total_padding, + ) diff --git a/src/textual/pilot.py b/src/textual/pilot.py index a94b41a908..a252615621 100644 --- a/src/textual/pilot.py +++ b/src/textual/pilot.py @@ -1,6 +1,9 @@ """ The pilot object is used by [App.run_test][textual.app.App.run_test] to programmatically operate an app. + +See the guide on how to [test Textual apps](/guide/testing). + """ from __future__ import annotations @@ -12,14 +15,14 @@ from ._wait import wait_for_idle from .app import App, ReturnType -from .events import Click, MouseDown, MouseMove, MouseUp +from .events import Click, MouseDown, MouseEvent, MouseMove, MouseUp from .geometry import Offset from .widget import Widget def _get_mouse_message_arguments( target: Widget, - offset: Offset = Offset(), + offset: tuple[int, int] = (0, 0), button: int = 0, shift: bool = False, meta: bool = False, @@ -42,8 +45,15 @@ def _get_mouse_message_arguments( return message_arguments +class OutOfBounds(Exception): + """Raised when the pilot mouse target is outside of the (visible) screen.""" + + class WaitForScreenTimeout(Exception): - pass + """Exception raised if messages aren't being processed quickly enough. + + If this occurs, the most likely explanation is some kind of deadlock in the app code. + """ @rich.repr.auto(angular=True) @@ -71,72 +81,258 @@ async def press(self, *keys: str) -> None: await self._app._press_keys(keys) await self._wait_for_screen() + async def mouse_down( + self, + selector: type[Widget] | str | None = None, + offset: tuple[int, int] = (0, 0), + shift: bool = False, + meta: bool = False, + control: bool = False, + ) -> bool: + """Simulate a [`MouseDown`][textual.events.MouseDown] event at a specified position. + + The final position for the event is computed based on the selector provided and + the offset specified and it must be within the visible area of the screen. + + Args: + selector: A selector to specify a widget that should be used as the reference + for the event offset. If this is not specified, the offset is interpreted + relative to the screen. You can use this parameter to try to target a + specific widget. However, if the widget is currently hidden or obscured by + another widget, the event may not land on the widget you specified. + offset: The offset for the event. The offset is relative to the selector + provided or to the screen, if no selector is provided. + shift: Simulate the event with the shift key held down. + meta: Simulate the event with the meta key held down. + control: Simulate the event with the control key held down. + + Raises: + OutOfBounds: If the position for the event is outside of the (visible) screen. + + Returns: + True if no selector was specified or if the event landed on the selected + widget, False otherwise. + """ + try: + return await self._post_mouse_events( + [MouseDown], + selector=selector, + offset=offset, + button=1, + shift=shift, + meta=meta, + control=control, + ) + except OutOfBounds as error: + raise error from None + + async def mouse_up( + self, + selector: type[Widget] | str | None = None, + offset: tuple[int, int] = (0, 0), + shift: bool = False, + meta: bool = False, + control: bool = False, + ) -> bool: + """Simulate a [`MouseUp`][textual.events.MouseUp] event at a specified position. + + The final position for the event is computed based on the selector provided and + the offset specified and it must be within the visible area of the screen. + + Args: + selector: A selector to specify a widget that should be used as the reference + for the event offset. If this is not specified, the offset is interpreted + relative to the screen. You can use this parameter to try to target a + specific widget. However, if the widget is currently hidden or obscured by + another widget, the event may not land on the widget you specified. + offset: The offset for the event. The offset is relative to the selector + provided or to the screen, if no selector is provided. + shift: Simulate the event with the shift key held down. + meta: Simulate the event with the meta key held down. + control: Simulate the event with the control key held down. + + Raises: + OutOfBounds: If the position for the event is outside of the (visible) screen. + + Returns: + True if no selector was specified or if the event landed on the selected + widget, False otherwise. + """ + try: + return await self._post_mouse_events( + [MouseUp], + selector=selector, + offset=offset, + button=1, + shift=shift, + meta=meta, + control=control, + ) + except OutOfBounds as error: + raise error from None + async def click( self, selector: type[Widget] | str | None = None, - offset: Offset = Offset(), + offset: tuple[int, int] = (0, 0), shift: bool = False, meta: bool = False, control: bool = False, - ) -> None: - """Simulate clicking with the mouse. + ) -> bool: + """Simulate clicking with the mouse at a specified position. + + The final position to be clicked is computed based on the selector provided and + the offset specified and it must be within the visible area of the screen. + + Example: + The code below runs an app and clicks its only button right in the middle: + ```py + async with SingleButtonApp().run_test() as pilot: + await pilot.click(Button, offset=(8, 1)) + ``` Args: - selector: The widget that should be clicked. If None, then the click - will occur relative to the screen. Note that this simply causes - a click to occur at the location of the widget. If the widget is - currently hidden or obscured by another widget, then the click may - not land on it. - offset: The offset to click within the selected widget. + selector: A selector to specify a widget that should be used as the reference + for the click offset. If this is not specified, the offset is interpreted + relative to the screen. You can use this parameter to try to click on a + specific widget. However, if the widget is currently hidden or obscured by + another widget, the click may not land on the widget you specified. + offset: The offset to click. The offset is relative to the selector provided + or to the screen, if no selector is provided. shift: Click with the shift key held down. meta: Click with the meta key held down. control: Click with the control key held down. - """ - app = self.app - screen = app.screen - if selector is not None: - target_widget = screen.query_one(selector) - else: - target_widget = screen - message_arguments = _get_mouse_message_arguments( - target_widget, offset, button=1, shift=shift, meta=meta, control=control - ) - app.post_message(MouseDown(**message_arguments)) - await self.pause(0.1) - app.post_message(MouseUp(**message_arguments)) - await self.pause(0.1) - app.post_message(Click(**message_arguments)) - await self.pause(0.1) + Raises: + OutOfBounds: If the position to be clicked is outside of the (visible) screen. + + Returns: + True if no selector was specified or if the click landed on the selected + widget, False otherwise. + """ + try: + return await self._post_mouse_events( + [MouseDown, MouseUp, Click], + selector=selector, + offset=offset, + button=1, + shift=shift, + meta=meta, + control=control, + ) + except OutOfBounds as error: + raise error from None async def hover( self, selector: type[Widget] | str | None | None = None, - offset: Offset = Offset(), - ) -> None: - """Simulate hovering with the mouse cursor. + offset: tuple[int, int] = (0, 0), + ) -> bool: + """Simulate hovering with the mouse cursor at a specified position. + + The final position to be hovered is computed based on the selector provided and + the offset specified and it must be within the visible area of the screen. + + Args: + selector: A selector to specify a widget that should be used as the reference + for the hover offset. If this is not specified, the offset is interpreted + relative to the screen. You can use this parameter to try to hover a + specific widget. However, if the widget is currently hidden or obscured by + another widget, the hover may not land on the widget you specified. + offset: The offset to hover. The offset is relative to the selector provided + or to the screen, if no selector is provided. + + Raises: + OutOfBounds: If the position to be hovered is outside of the (visible) screen. + + Returns: + True if no selector was specified or if the hover landed on the selected + widget, False otherwise. + """ + # This is usually what the user wants because it gives time for the mouse to + # "settle" before moving it to the new hover position. + await self.pause() + try: + return await self._post_mouse_events( + [MouseMove], selector, offset, button=0 + ) + except OutOfBounds as error: + raise error from None + + async def _post_mouse_events( + self, + events: list[type[MouseEvent]], + selector: type[Widget] | str | None | None = None, + offset: tuple[int, int] = (0, 0), + button: int = 0, + shift: bool = False, + meta: bool = False, + control: bool = False, + ) -> bool: + """Simulate a series of mouse events to be fired at a given position. + + The final position for the events is computed based on the selector provided and + the offset specified and it must be within the visible area of the screen. + + This function abstracts away the commonalities of the other mouse event-related + functions that the pilot exposes. Args: - selector: The widget that should be hovered. If None, then the click - will occur relative to the screen. Note that this simply causes - a hover to occur at the location of the widget. If the widget is - currently hidden or obscured by another widget, then the hover may - not land on it. - offset: The offset to hover over within the selected widget. + selector: A selector to specify a widget that should be used as the reference + for the events offset. If this is not specified, the offset is interpreted + relative to the screen. You can use this parameter to try to target a + specific widget. However, if the widget is currently hidden or obscured by + another widget, the events may not land on the widget you specified. + offset: The offset for the events. The offset is relative to the selector + provided or to the screen, if no selector is provided. + shift: Simulate the events with the shift key held down. + meta: Simulate the events with the meta key held down. + control: Simulate the events with the control key held down. + + Raises: + OutOfBounds: If the position for the events is outside of the (visible) screen. + + Returns: + True if no selector was specified or if the *final* event landed on the + selected widget, False otherwise. """ app = self.app screen = app.screen if selector is not None: - target_widget = screen.query_one(selector) + target_widget = app.query_one(selector) else: target_widget = screen message_arguments = _get_mouse_message_arguments( - target_widget, offset, button=0 + target_widget, + offset, + button=button, + shift=shift, + meta=meta, + control=control, ) - await self.pause() - app.post_message(MouseMove(**message_arguments)) - await self.pause() + + offset = Offset(message_arguments["x"], message_arguments["y"]) + if offset not in screen.region: + raise OutOfBounds( + "Target offset is outside of currently-visible screen region." + ) + + widget_at = None + for mouse_event_cls in events: + # Get the widget under the mouse before the event because the app might + # react to the event and move things around. We override on each iteration + # because we assume the final event in `events` is the actual event we care + # about and that all the preceding events are just setup. + # E.g., the click event is preceded by MouseDown/MouseUp to emulate how + # 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 + app.screen._forward_event(event) + await self.pause() + + return selector is None or widget_at is target_widget async def _wait_for_screen(self, timeout: float = 30.0) -> bool: """Wait for the current screen and its children to have processed all pending events. diff --git a/src/textual/renderables/__init__.py b/src/textual/renderables/__init__.py index e69de29bb2..3d892e1f58 100644 --- a/src/textual/renderables/__init__.py +++ b/src/textual/renderables/__init__.py @@ -0,0 +1 @@ +__all__ = ["bar", "blank", "digits", "gradient", "sparkline"] diff --git a/src/textual/renderables/align.py b/src/textual/renderables/align.py deleted file mode 100644 index d97dfc4348..0000000000 --- a/src/textual/renderables/align.py +++ /dev/null @@ -1,56 +0,0 @@ -from __future__ import annotations - -from rich.console import Console, ConsoleOptions, RenderableType, RenderResult -from rich.measure import Measurement -from rich.segment import Segment -from rich.style import Style - -from .._segment_tools import align_lines -from ..css.types import AlignHorizontal, AlignVertical -from ..geometry import Size - - -class Align: - def __init__( - self, - renderable: RenderableType, - size: Size, - style: Style, - horizontal: AlignHorizontal, - vertical: AlignVertical, - ) -> None: - """Align a child renderable - - Args: - renderable: Renderable to align. - size: Size of container. - style: Style of any padding. - horizontal: Horizontal alignment. - vertical: Vertical alignment. - """ - self.renderable = renderable - self.size = size - self.style = style - self.horizontal = horizontal - self.vertical = vertical - - def __rich_console__( - self, console: Console, options: ConsoleOptions - ) -> RenderResult: - lines = console.render_lines(self.renderable, options, pad=False) - new_line = Segment.line() - for line in align_lines( - lines, - self.style, - self.size, - self.horizontal, - self.vertical, - ): - yield from line - yield new_line - - def __rich_measure__( - self, console: "Console", options: "ConsoleOptions" - ) -> Measurement: - width, _ = self.size - return Measurement(width, width) diff --git a/src/textual/renderables/gradient.py b/src/textual/renderables/gradient.py index 4368f1e6f5..7dd359f7a7 100644 --- a/src/textual/renderables/gradient.py +++ b/src/textual/renderables/gradient.py @@ -2,6 +2,7 @@ from functools import lru_cache from math import cos, pi, sin +from typing import Sequence from rich.color import Color as RichColor from rich.console import Console, ConsoleOptions, RenderResult @@ -42,17 +43,22 @@ def __rich_console__( class LinearGradient: - """Render a linear gradient with a rotation.""" + """Render a linear gradient with a rotation. - def __init__(self, angle: float, stops: list[tuple[float, Color]]) -> None: - """ + Args: + angle: Angle of rotation in degrees. + stops: List of stop consisting of pairs of offset (between 0 and 1) and color. - Args: - angle: Angle of rotation in degrees. - stops: List of stop consisting of pairs of offset (between 0 and 1) and colors. - """ + """ + + def __init__( + self, angle: float, stops: Sequence[tuple[float, Color | str]] + ) -> None: self.angle = angle - self._stops = stops[:] + self._stops = [ + (stop, Color.parse(color) if isinstance(color, str) else color) + for stop, color in stops + ] def __rich_console__( self, console: Console, options: ConsoleOptions @@ -75,7 +81,7 @@ def __rich_console__( get_color = color_gradient.get_color from_color = Style.from_color - @lru_cache(maxsize=None) + @lru_cache(maxsize=1024) def get_rich_color(color_offset: int) -> RichColor: """Get a Rich color in the gradient. diff --git a/src/textual/screen.py b/src/textual/screen.py index d0bb45cf0c..7c120fc37f 100644 --- a/src/textual/screen.py +++ b/src/textual/screen.py @@ -5,6 +5,7 @@ from __future__ import annotations +import asyncio from functools import partial from operator import attrgetter from typing import ( @@ -74,17 +75,21 @@ def __init__( self, requester: MessagePump, callback: ScreenResultCallbackType[ScreenResultType] | None, + future: asyncio.Future[ScreenResultType] | None = None, ) -> None: """Initialise the result callback object. Args: requester: The object making a request for the callback. callback: The callback function. + future: A Future to hold the result. """ self.requester = requester """The object in the DOM that requested the callback.""" self.callback: ScreenResultCallbackType | None = callback """The callback function.""" + self.future = future + """A future for the result""" def __call__(self, result: ScreenResultType) -> None: """Call the callback, passing the given result. @@ -95,6 +100,8 @@ def __call__(self, result: ScreenResultType) -> None: Note: If the requested or the callback are `None` this will be a no-op. """ + if self.future is not None: + self.future.set_result(result) if self.requester is not None and self.callback is not None: self.requester.call_next(self.callback, result) @@ -157,8 +164,8 @@ class Screen(Generic[ScreenResultType], Widget): title: Reactive[str | None] = Reactive(None, compute=False) """Screen title to override [the app title][textual.app.App.title].""" - COMMANDS: ClassVar[set[type[Provider]]] = set() - """Command providers used by the [command palette](/guide/command), associated with the screen. + COMMANDS: ClassVar[set[type[Provider] | Callable[[], type[Provider]]]] = set() + """Command providers used by the [command palette](/guide/command_palette), associated with the screen. Should be a set of [`command.Provider`][textual.command.Provider] classes. """ @@ -237,7 +244,7 @@ def layers(self) -> tuple[str, ...]: Returns: Tuple of layer names. """ - extras = [] + extras = ["_loading"] if not self.app._disable_notifications: extras.append("_toastrack") if not self.app._disable_tooltips: @@ -347,7 +354,7 @@ def focus_chain(self) -> list[Widget]: if node is None: pop() else: - if node.disabled: + if node._check_disabled(): continue node_styles_visibility = node.styles.get_rule("visibility") node_is_visible = ( @@ -515,7 +522,7 @@ def _reset_focus( chosen = candidate break - # Go with the what was found. + # Go with what was found. self.set_focus(chosen) def _update_focus_styles( @@ -582,6 +589,7 @@ def scroll_to_center(widget: Widget) -> None: self.screen.scroll_to_center(widget, origin_visible=True) self.call_after_refresh(scroll_to_center, widget) + widget.post_message(events.Focus()) focused = widget @@ -637,7 +645,7 @@ def _compositor_refresh(self) -> None: def _on_timer_update(self) -> None: """Called by the _update_timer.""" self._update_timer.pause() - if self.is_current: + if self.is_current and not self.app._batch_count: if self._layout_required: self._refresh_layout() self._layout_required = False @@ -687,15 +695,17 @@ def _push_result_callback( self, requester: MessagePump, callback: ScreenResultCallbackType[ScreenResultType] | None, + future: asyncio.Future[ScreenResultType] | None = None, ) -> None: """Add a result callback to the screen. Args: requester: The object requesting the callback. callback: The callback. + future: A Future to hold the result. """ self._result_callbacks.append( - ResultCallback[ScreenResultType](requester, callback) + ResultCallback[ScreenResultType](requester, callback, future) ) def _pop_result_callback(self) -> None: @@ -809,12 +819,16 @@ def _on_screen_resume(self) -> None: size = self.app.size self._refresh_layout(size, full=True) self.refresh() - auto_focus = self.app.AUTO_FOCUS if self.AUTO_FOCUS is None else self.AUTO_FOCUS - if auto_focus and self.focused is None: - for widget in self.query(auto_focus): - if widget.focusable: - self.set_focus(widget) - break + # Only auto-focus when the app has focus (textual-web only) + if self.app.app_focus: + auto_focus = ( + self.app.AUTO_FOCUS if self.AUTO_FOCUS is None else self.AUTO_FOCUS + ) + if auto_focus and self.focused is None: + for widget in self.query(auto_focus): + if widget.focusable: + self.set_focus(widget) + break def _on_screen_suspend(self) -> None: """Screen has suspended.""" @@ -953,11 +967,8 @@ def _forward_event(self, event: events.Event) -> None: except errors.NoWidget: self.set_focus(None) else: - if isinstance(event, events.MouseUp) and widget.focusable: - if self.focused is not widget: - self.set_focus(widget) - event.stop() - return + if isinstance(event, events.MouseDown) and widget.focusable: + self.set_focus(widget, scroll_visible=False) event.style = self.get_style_at(event.screen_x, event.screen_y) if widget is self: event._set_forwarded() @@ -965,17 +976,6 @@ def _forward_event(self, event: events.Event) -> None: else: widget._forward_event(event._apply_offset(-region.x, -region.y)) - elif isinstance(event, (events.MouseScrollDown, events.MouseScrollUp)): - try: - widget, _region = self.get_widget_at(event.x, event.y) - except errors.NoWidget: - return - scroll_widget = widget - if scroll_widget is not None: - if scroll_widget is self: - self.post_message(event) - else: - scroll_widget._forward_event(event) else: self.post_message(event) @@ -1067,3 +1067,15 @@ def __init__( ) -> None: super().__init__(name=name, id=id, classes=classes) self._modal = True + + +class _SystemModalScreen(ModalScreen[ScreenResultType], inherit_css=False): + """A variant of `ModalScreen` for internal use. + + This version of `ModalScreen` allows us to build system-level screens; + the type being used to indicate that the screen should be isolated from + the main application. + + Note: + This screen is set to *not* inherit CSS. + """ diff --git a/src/textual/scrollbar.py b/src/textual/scrollbar.py index cc41c61fcc..31ae35b18f 100644 --- a/src/textual/scrollbar.py +++ b/src/textual/scrollbar.py @@ -49,6 +49,8 @@ class ScrollRight(ScrollMessage, verbose=True): class ScrollTo(ScrollMessage, verbose=True): """Message sent when click and dragging handle.""" + __slots__ = ["x", "y", "animate"] + def __init__( self, x: float | None = None, @@ -219,16 +221,6 @@ class MyScrollBarRender(ScrollBarRender): ... ``` """ - DEFAULT_CSS = """ - ScrollBar { - link-hover-color: ; - link-hover-background:; - link-hover-style: ; - link-color: transparent; - link-background: transparent; - } - """ - def __init__( self, vertical: bool = True, name: str | None = None, *, thickness: int = 1 ) -> None: @@ -316,6 +308,10 @@ def action_grab(self) -> None: """Begin capturing the mouse cursor.""" self.capture_mouse() + async def _on_mouse_down(self, event: events.MouseDown) -> None: + # We don't want mouse events on the scrollbar bubbling + event.stop() + async def _on_mouse_up(self, event: events.MouseUp) -> None: if self.grabbed: self.release_mouse() diff --git a/src/textual/strip.py b/src/textual/strip.py index cfce3bb11d..3f90414cae 100644 --- a/src/textual/strip.py +++ b/src/textual/strip.py @@ -12,6 +12,7 @@ import rich.repr from rich.cells import cell_len, set_cell_size from rich.console import Console, ConsoleOptions, RenderResult +from rich.measure import Measurement from rich.segment import Segment from rich.style import Style, StyleType @@ -38,8 +39,9 @@ def get_line_length(segments: Iterable[Segment]) -> int: class StripRenderable: """A renderable which renders a list of strips in to lines.""" - def __init__(self, strips: list[Strip]) -> None: + def __init__(self, strips: list[Strip], width: int | None = None) -> None: self._strips = strips + self._width = width def __rich_console__( self, console: Console, options: ConsoleOptions @@ -49,6 +51,15 @@ def __rich_console__( yield from strip yield new_line + def __rich_measure__( + self, console: "Console", options: "ConsoleOptions" + ) -> Measurement: + if self._width is None: + width = max(strip.cell_length for strip in self._strips) + else: + width = self._width + return Measurement(width, width) + @rich.repr.auto class Strip: @@ -188,7 +199,7 @@ def join(cls, strips: Iterable[Strip | None]) -> Strip: return strip def __bool__(self) -> bool: - return bool(self._segments) + return not not self._segments # faster than bool(...) def __iter__(self) -> Iterator[Segment]: return iter(self._segments) diff --git a/src/textual/suggester.py b/src/textual/suggester.py index 362fe89f6d..505993b43a 100644 --- a/src/textual/suggester.py +++ b/src/textual/suggester.py @@ -1,3 +1,9 @@ +""" + +The `Suggester` class is used by the [Input](/widgets/input) widget. + +""" + from __future__ import annotations from abc import ABC, abstractmethod diff --git a/src/textual/timer.py b/src/textual/timer.py index f708512de4..1d31af4b14 100644 --- a/src/textual/timer.py +++ b/src/textual/timer.py @@ -7,19 +7,18 @@ from __future__ import annotations import weakref -from asyncio import CancelledError, Event, Task -from typing import Awaitable, Callable, Union +from asyncio import CancelledError, Event, Task, create_task +from typing import Any, Awaitable, Callable, Union from rich.repr import Result, rich_repr from . import _time, events -from ._asyncio import create_task from ._callback import invoke from ._context import active_app from ._time import sleep from ._types import MessageTarget -TimerCallback = Union[Callable[[], Awaitable[None]], Callable[[], None]] +TimerCallback = Union[Callable[[], Awaitable[Any]], Callable[[], Any]] """Type of valid callbacks to be used with timers.""" diff --git a/src/textual/tree-sitter/highlights/bash.scm b/src/textual/tree-sitter/highlights/bash.scm new file mode 100644 index 0000000000..23bf03e697 --- /dev/null +++ b/src/textual/tree-sitter/highlights/bash.scm @@ -0,0 +1,145 @@ +(simple_expansion) @none +(expansion + "${" @punctuation.special + "}" @punctuation.special) @none +[ + "(" + ")" + "((" + "))" + "{" + "}" + "[" + "]" + "[[" + "]]" + ] @punctuation.bracket + +[ + ";" + ";;" + (heredoc_start) + ] @punctuation.delimiter + +[ + "$" +] @punctuation.special + +[ + ">" + ">>" + "<" + "<<" + "&" + "&&" + "|" + "||" + "=" + "=~" + "==" + "!=" + ] @operator + +[ + (string) + (raw_string) + (ansi_c_string) + (heredoc_body) +] @string @spell + +(variable_assignment (word) @string) + +[ + "if" + "then" + "else" + "elif" + "fi" + "case" + "in" + "esac" + ] @conditional + +[ + "for" + "do" + "done" + "select" + "until" + "while" + ] @repeat + +[ + "declare" + "export" + "local" + "readonly" + "unset" + ] @keyword + +"function" @keyword.function + +(special_variable_name) @constant + +; trap -l +((word) @constant.builtin + (#match? @constant.builtin "^SIG(HUP|INT|QUIT|ILL|TRAP|ABRT|BUS|FPE|KILL|USR[12]|SEGV|PIPE|ALRM|TERM|STKFLT|CHLD|CONT|STOP|TSTP|TT(IN|OU)|URG|XCPU|XFSZ|VTALRM|PROF|WINCH|IO|PWR|SYS|RTMIN([+]([1-9]|1[0-5]))?|RTMAX(-([1-9]|1[0-4]))?)$")) + +((word) @boolean + (#any-of? @boolean "true" "false")) + +(comment) @comment @spell +(test_operator) @string + +(command_substitution + [ "$(" ")" ] @punctuation.bracket) + +(process_substitution + [ "<(" ")" ] @punctuation.bracket) + + +(function_definition + name: (word) @function) + +(command_name (word) @function.call) + +((command_name (word) @function.builtin) + (#any-of? @function.builtin + "alias" "bg" "bind" "break" "builtin" "caller" "cd" + "command" "compgen" "complete" "compopt" "continue" + "coproc" "dirs" "disown" "echo" "enable" "eval" + "exec" "exit" "fc" "fg" "getopts" "hash" "help" + "history" "jobs" "kill" "let" "logout" "mapfile" + "popd" "printf" "pushd" "pwd" "read" "readarray" + "return" "set" "shift" "shopt" "source" "suspend" + "test" "time" "times" "trap" "type" "typeset" + "ulimit" "umask" "unalias" "wait")) + +(command + argument: [ + (word) @parameter + (concatenation (word) @parameter) + ]) + +((word) @number + (#lua-match? @number "^[0-9]+$")) + +(file_redirect + descriptor: (file_descriptor) @operator + destination: (word) @parameter) + +(expansion + [ "${" "}" ] @punctuation.bracket) + +(variable_name) @variable + +((variable_name) @constant + (#lua-match? @constant "^[A-Z][A-Z_0-9]*$")) + +(case_item + value: (word) @parameter) + +(regex) @string.regex + +((program . (comment) @preproc) + (#lua-match? @preproc "^#!/")) diff --git a/src/textual/tree-sitter/highlights/css.scm b/src/textual/tree-sitter/highlights/css.scm new file mode 100644 index 0000000000..b26f0ec96c --- /dev/null +++ b/src/textual/tree-sitter/highlights/css.scm @@ -0,0 +1,91 @@ +[ + "@media" + "@charset" + "@namespace" + "@supports" + "@keyframes" + (at_keyword) + (to) + (from) + ] @keyword + +"@import" @include + +(comment) @comment @spell + +[ + (tag_name) + (nesting_selector) + (universal_selector) + ] @type + +(function_name) @function + +[ + "~" + ">" + "+" + "-" + "*" + "/" + "=" + "^=" + "|=" + "~=" + "$=" + "*=" + "and" + "or" + "not" + "only" + ] @operator + +(important) @type.qualifier + +(attribute_selector (plain_value) @string) +(pseudo_element_selector "::" (tag_name) @property) +(pseudo_class_selector (class_name) @property) + +[ + (class_name) + (id_name) + (property_name) + (feature_name) + (attribute_name) + ] @property + +(namespace_name) @namespace + +((property_name) @type.definition + (#lua-match? @type.definition "^[-][-]")) +((plain_value) @type + (#lua-match? @type "^[-][-]")) + +[ + (string_value) + (color_value) + (unit) + ] @string + +[ + (integer_value) + (float_value) + ] @number + +[ + "#" + "," + "." + ":" + "::" + ";" + ] @punctuation.delimiter + +[ + "{" + ")" + "(" + "}" + ] @punctuation.bracket + +(ERROR) @error diff --git a/src/textual/tree-sitter/highlights/html.scm b/src/textual/tree-sitter/highlights/html.scm new file mode 100644 index 0000000000..15f2adb436 --- /dev/null +++ b/src/textual/tree-sitter/highlights/html.scm @@ -0,0 +1,64 @@ +(tag_name) @tag +(erroneous_end_tag_name) @html.end_tag_error +(comment) @comment +(attribute_name) @tag.attribute +(attribute + (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)) + (#any-of? @_attr "href" "src")) + +[ + "<" + ">" + "" +] @tag.delimiter + +"=" @operator + +(doctype) @constant + +"" + "=" + "==" + ">" + ">=" + ">>" + ">>=" + "@" + "@=" + "|" + "|=" + "~" + "->" +] @operator + +; Keywords +[ + "and" + "in" + "is" + "not" + "or" + "del" +] @keyword.operator + +[ + "def" + "lambda" +] @keyword.function + +[ + "assert" + "async" + "await" + "class" + "exec" + "global" + "nonlocal" + "pass" + "print" + "with" + "as" +] @keyword + +[ + "return" + "yield" +] @keyword.return +(yield "from" @keyword.return) + +(future_import_statement + "from" @include + "__future__" @constant.builtin) +(import_from_statement "from" @include) +"import" @include + +(aliased_import "as" @include) + +["if" "elif" "else" "match" "case"] @conditional + +["for" "while" "break" "continue"] @repeat + +[ + "try" + "except" + "raise" + "finally" +] @exception + +(raise_statement "from" @exception) + +(try_statement + (else_clause + "else" @exception)) + +["(" ")" "[" "]" "{" "}"] @punctuation.bracket + +(interpolation + "{" @punctuation.special + "}" @punctuation.special) + +["," "." ":" ";" (ellipsis)] @punctuation.delimiter + +;; Class definitions + +(class_definition name: (identifier) @type.class) + +(class_definition + body: (block + (function_definition + name: (identifier) @method))) + +(class_definition + superclasses: (argument_list + (identifier) @type)) + +((class_definition + body: (block + (expression_statement + (assignment + left: (identifier) @field)))) + (#match? @field "^([A-Z])@!.*$")) +((class_definition + body: (block + (expression_statement + (assignment + left: (_ + (identifier) @field))))) + (#match? @field "^([A-Z])@!.*$")) + +((class_definition + (block + (function_definition + name: (identifier) @constructor))) + (#any-of? @constructor "__new__" "__init__")) + +;; Error +(ERROR) @error diff --git a/src/textual/tree-sitter/highlights/regex.scm b/src/textual/tree-sitter/highlights/regex.scm new file mode 100644 index 0000000000..7c671c2c04 --- /dev/null +++ b/src/textual/tree-sitter/highlights/regex.scm @@ -0,0 +1,34 @@ +;; Forked from tree-sitter-regex +;; The MIT License (MIT) Copyright (c) 2014 Max Brunsfeld +[ + "(" + ")" + "(?" + "(?:" + "(?<" + ">" + "[" + "]" + "{" + "}" +] @regex.punctuation.bracket + +(group_name) @property + +;; These are escaped special characters that lost their special meaning +;; -> no special highlighting +(identity_escape) @string.regex + +(class_character) @constant + +[ + (control_letter_escape) + (character_class_escape) + (control_escape) + (start_assertion) + (end_assertion) + (boundary_assertion) + (non_boundary_assertion) +] @string.escape + +[ "*" "+" "?" "|" "=" "!" ] @regex.operator diff --git a/src/textual/tree-sitter/highlights/sql.scm b/src/textual/tree-sitter/highlights/sql.scm new file mode 100644 index 0000000000..03a15fe381 --- /dev/null +++ b/src/textual/tree-sitter/highlights/sql.scm @@ -0,0 +1,114 @@ +(string) @string +(number) @number +(comment) @comment + +(function_call + function: (identifier) @function) + +[ + (NULL) + (TRUE) + (FALSE) +] @constant.builtin + +([ + (type_cast + (type (identifier) @type.builtin)) + (create_function_statement + (type (identifier) @type.builtin)) + (create_function_statement + (create_function_parameters + (create_function_parameter (type (identifier) @type.builtin)))) + (create_type_statement + (type_spec_composite (type (identifier) @type.builtin))) + (create_table_statement + (table_parameters + (table_column (type (identifier) @type.builtin)))) + ] + (#match? + @type.builtin + "^(bigint|BIGINT|int8|INT8|bigserial|BIGSERIAL|serial8|SERIAL8|bit|BIT|varbit|VARBIT|boolean|BOOLEAN|bool|BOOL|box|BOX|bytea|BYTEA|character|CHARACTER|char|CHAR|varchar|VARCHAR|cidr|CIDR|circle|CIRCLE|date|DATE|float8|FLOAT8|inet|INET|integer|INTEGER|int|INT|int4|INT4|interval|INTERVAL|json|JSON|jsonb|JSONB|line|LINE|lseg|LSEG|macaddr|MACADDR|money|MONEY|numeric|NUMERIC|decimal|DECIMAL|path|PATH|pg_lsn|PG_LSN|point|POINT|polygon|POLYGON|real|REAL|float4|FLOAT4|smallint|SMALLINT|int2|INT2|smallserial|SMALLSERIAL|serial2|SERIAL2|serial|SERIAL|serial4|SERIAL4|text|TEXT|time|TIME|time|TIME|timestamp|TIMESTAMP|tsquery|TSQUERY|tsvector|TSVECTOR|txid_snapshot|TXID_SNAPSHOT|enum|ENUM|range|RANGE)$")) + +(identifier) @variable + +[ + "::" + "<" + "<=" + "<>" + "=" + ">" + ">=" +] @operator + +[ + "(" + ")" + "[" + "]" +] @punctuation.bracket + +[ + ";" + "." +] @punctuation.delimiter + +[ + (type) + (array_type) +] @type + +[ + (primary_key_constraint) + (unique_constraint) + (null_constraint) +] @keyword + +[ + "AND" + "AS" + "AUTO_INCREMENT" + "CREATE" + "CREATE_DOMAIN" + "CREATE_OR_REPLACE_FUNCTION" + "CREATE_SCHEMA" + "TABLE" + "TEMPORARY" + "CREATE_TYPE" + "DATABASE" + "FROM" + "GRANT" + "GROUP_BY" + "IF_NOT_EXISTS" + "INDEX" + "INNER" + "INSERT" + "INTO" + "IN" + "JOIN" + "LANGUAGE" + "LEFT" + "LOCAL" + "NOT" + "ON" + "OR" + "ORDER_BY" + "OUTER" + "PRIMARY_KEY" + "PUBLIC" + "RETURNS" + "SCHEMA" + "SELECT" + "SESSION" + "SET" + "TABLE" + "TIME_ZONE" + "TO" + "UNIQUE" + "UPDATE" + "USAGE" + "VALUES" + "WHERE" + "WITH" + "WITHOUT" +] @keyword diff --git a/src/textual/tree-sitter/highlights/toml.scm b/src/textual/tree-sitter/highlights/toml.scm new file mode 100644 index 0000000000..9228d28072 --- /dev/null +++ b/src/textual/tree-sitter/highlights/toml.scm @@ -0,0 +1,36 @@ +; Properties +;----------- + +(bare_key) @toml.type +(quoted_key) @string +(pair (bare_key)) @property + +; Literals +;--------- + +(boolean) @boolean +(comment) @comment @spell +(string) @string +(integer) @number +(float) @float +(offset_date_time) @toml.datetime +(local_date_time) @toml.datetime +(local_date) @toml.datetime +(local_time) @toml.datetime + +; Punctuation +;------------ + +"." @punctuation.delimiter +"," @punctuation.delimiter + +"=" @toml.operator + +"[" @punctuation.bracket +"]" @punctuation.bracket +"[[" @punctuation.bracket +"]]" @punctuation.bracket +"{" @punctuation.bracket +"}" @punctuation.bracket + +(ERROR) @toml.error diff --git a/src/textual/tree-sitter/highlights/yaml.scm b/src/textual/tree-sitter/highlights/yaml.scm new file mode 100644 index 0000000000..a57f464dfc --- /dev/null +++ b/src/textual/tree-sitter/highlights/yaml.scm @@ -0,0 +1,53 @@ +(boolean_scalar) @boolean +(null_scalar) @constant.builtin +(double_quote_scalar) @string +(single_quote_scalar) @string +((block_scalar) @string (#set! "priority" 99)) +(string_scalar) @string +(escape_sequence) @string.escape +(integer_scalar) @number +(float_scalar) @number +(comment) @comment +(anchor_name) @type +(alias_name) @type +(tag) @type +(ERROR) @error + +[ + (yaml_directive) + (tag_directive) + (reserved_directive) +] @preproc + +(block_mapping_pair + key: (flow_node [(double_quote_scalar) (single_quote_scalar)] @yaml.field)) +(block_mapping_pair + key: (flow_node (plain_scalar (string_scalar) @yaml.field))) + +(flow_mapping + (_ key: (flow_node [(double_quote_scalar) (single_quote_scalar)] @yaml.field))) +(flow_mapping + (_ key: (flow_node (plain_scalar (string_scalar) @yaml.field)))) + +[ + "," + "-" + ":" + ">" + "?" + "|" +] @punctuation.delimiter + +[ + "[" + "]" + "{" + "}" +] @punctuation.bracket + +[ + "*" + "&" + "---" + "..." +] @punctuation.special diff --git a/src/textual/types.py b/src/textual/types.py index b768c424c4..33be4449fe 100644 --- a/src/textual/types.py +++ b/src/textual/types.py @@ -9,12 +9,21 @@ CallbackType, IgnoreReturnCallbackType, MessageTarget, + UnusedParameter, WatchCallbackType, ) from .actions import ActionParseResult from .css.styles import RenderStyles -from .widgets._data_table import CursorType +from .widgets._directory_tree import DirEntry from .widgets._input import InputValidationOn +from .widgets._option_list import ( + DuplicateID, + NewOptionListContent, + OptionDoesNotExist, + OptionListContent, +) +from .widgets._placeholder import PlaceholderVariant +from .widgets._select import NoSelection, SelectType __all__ = [ "ActionParseResult", @@ -22,12 +31,20 @@ "CallbackType", "CSSPathError", "CSSPathType", - "CursorType", + "DirEntry", + "DuplicateID", "EasingFunction", "IgnoreReturnCallbackType", "InputValidationOn", "MessageTarget", + "NewOptionListContent", "NoActiveAppError", + "NoSelection", + "OptionDoesNotExist", + "OptionListContent", + "PlaceholderVariant", "RenderStyles", + "SelectType", + "UnusedParameter", "WatchCallbackType", ] diff --git a/src/textual/widget.py b/src/textual/widget.py index 2f79d4c20b..d0c549a5ae 100644 --- a/src/textual/widget.py +++ b/src/textual/widget.py @@ -4,14 +4,14 @@ from __future__ import annotations -from asyncio import wait +from asyncio import create_task, wait from collections import Counter from fractions import Fraction from itertools import islice -from operator import attrgetter from types import TracebackType from typing import ( TYPE_CHECKING, + Awaitable, ClassVar, Collection, Generator, @@ -37,13 +37,11 @@ from rich.segment import Segment from rich.style import Style from rich.text import Text -from rich.traceback import Traceback from typing_extensions import Self from . import constants, errors, events, messages from ._animator import DEFAULT_EASING, Animatable, BoundAnimator, EasingFunction from ._arrange import DockArrangeResult, arrange -from ._asyncio import create_task from ._cache import FIFOCache from ._compose import compose from ._context import NoActiveAppError, active_app @@ -57,13 +55,23 @@ from .css.query import NoMatches, WrongType from .css.scalar import ScalarOffset from .dom import DOMNode, NoScreen -from .geometry import NULL_REGION, NULL_SPACING, Offset, Region, Size, Spacing, clamp +from .geometry import ( + NULL_REGION, + NULL_SIZE, + NULL_SPACING, + Offset, + Region, + Size, + Spacing, + clamp, +) from .layouts.vertical import VerticalLayout from .message import Message from .messages import CallbackType from .notifications import Notification, SeverityLevel from .reactive import Reactive from .render import measure +from .renderables.blank import Blank from .strip import Strip from .walk import walk_depth_first @@ -87,6 +95,9 @@ } +_NULL_STYLE = Style() + + class AwaitMount: """An *optional* awaitable returned by [mount][textual.widget.Widget.mount] and [mount_all][textual.widget.Widget.mount_all]. @@ -245,12 +256,12 @@ class Widget(DOMNode): scrollbar-corner-color: $panel-darken-1; scrollbar-size-vertical: 2; scrollbar-size-horizontal: 1; - link-background:; + link-background: initial; link-color: $text; link-style: underline; - link-hover-background: $accent; - link-hover-color: $text; - link-hover-style: bold not underline; + link-background-hover: $accent; + link-color-hover: $text; + link-style-hover: bold not underline; } """ COMPONENT_CLASSES: ClassVar[set[str]] = set() @@ -278,6 +289,8 @@ class Widget(DOMNode): """The current hover style (style under the mouse cursor). Read only.""" highlight_link_id: Reactive[str] = Reactive("") """The currently highlighted link id. Read only.""" + loading: Reactive[bool] = Reactive(False) + """If set to `True` this widget will temporarily be replaced with a loading indicator.""" def __init__( self, @@ -296,8 +309,9 @@ def __init__( classes: The CSS classes for the widget. disabled: Whether the widget is disabled or not. """ - self._size = Size(0, 0) - self._container_size = Size(0, 0) + _null_size = NULL_SIZE + self._size = _null_size + self._container_size = _null_size self._layout_required = False self._repaint_required = False self._scroll_required = False @@ -312,7 +326,7 @@ def __init__( self._border_title: Text | None = None self._border_subtitle: Text | None = None - self._render_cache = _RenderCache(Size(0, 0), []) + self._render_cache = _RenderCache(_null_size, []) # Regions which need to be updated (in Widget) self._dirty_regions: set[Region] = set() # Regions which need to be transferred from cache to screen @@ -351,8 +365,7 @@ def __init__( raise TypeError( f"Widget positional arguments must be Widget subclasses; not {child!r}" ) - - self._add_children(*children) + self._pending_children = list(children) self.disabled = disabled if self.BORDER_TITLE: self.border_title = self.BORDER_TITLE @@ -378,7 +391,7 @@ def __init__( scroll_target_y = Reactive(0.0, repaint=False) show_vertical_scrollbar: Reactive[bool] = Reactive(False, layout=True) - """Show a horizontal scrollbar?""" + """Show a vertical scrollbar?""" show_horizontal_scrollbar: Reactive[bool] = Reactive(False, layout=True) """Show a horizontal scrollbar?""" @@ -388,6 +401,11 @@ def __init__( border_subtitle: str | Text | None = _BorderTitle() # type: ignore """A title to show in the bottom border (if there is one).""" + @property + def is_mounted(self) -> bool: + """Check if this widget is mounted.""" + return self._is_mounted + @property def siblings(self) -> list[Widget]: """Get the widget's siblings (self is removed from the return list). @@ -421,6 +439,8 @@ def allow_vertical_scroll(self) -> bool: May be overridden if you want different logic regarding allowing scrolling. """ + if self._check_disabled(): + return False return self.is_scrollable and self.show_vertical_scrollbar @property @@ -429,6 +449,8 @@ def allow_horizontal_scroll(self) -> bool: May be overridden if you want different logic regarding allowing scrolling. """ + if self._check_disabled(): + return False return self.is_scrollable and self.show_horizontal_scrollbar @property @@ -465,6 +487,15 @@ def opacity(self) -> float: break return opacity + def _check_disabled(self) -> bool: + """Check if the widget is disabled either explicitly by setting `disabled`, + or implicitly by setting `loading`. + + Returns: + True if the widget should be disabled. + """ + return self.disabled or self.loading + @property def tooltip(self) -> RenderableType | None: """Tooltip for the widget, or `None` for no tooltip.""" @@ -478,6 +509,19 @@ def tooltip(self, tooltip: RenderableType | None): except NoScreen: pass + def compose_add_child(self, widget: Widget) -> None: + """Add a node to children. + + This is used by the compose process when it adds children. + There is no need to use it directly, but you may want to override it in a subclass + if you want children to be attached to a different node. + + Args: + widget: A Widget to add. + """ + _rich_traceback_omit = True + self._pending_children.append(widget) + def __enter__(self) -> Self: """Use as context manager when composing.""" self.app._compose_stacks[-1].append(self) @@ -497,6 +541,42 @@ def __exit__( else: self.app._composed[-1].append(composed) + def get_loading_widget(self) -> Widget: + """Get a widget to display a loading indicator. + + The default implementation will defer to App.get_loading_widget. + + Returns: + A widget in place of this widget to indicate a loading. + """ + loading_widget = self.app.get_loading_widget() + return loading_widget + + def set_loading(self, loading: bool) -> Awaitable: + """Set or reset the loading state of this widget. + + A widget in a loading state will display a LoadingIndicator that obscures the widget. + + Args: + loading: `True` to put the widget into a loading state, or `False` to reset the loading state. + + Returns: + An optional awaitable. + """ + + if loading: + loading_indicator = self.get_loading_widget() + loading_indicator.add_class("-textual-loading-indicator") + await_mount = self.mount(loading_indicator) + return await_mount + else: + await_remove = self.query(".-textual-loading-indicator").remove() + return await_remove + + async def _watch_loading(self, loading: bool) -> None: + """Called when the 'loading' reactive is changed.""" + await self.set_loading(loading) + ExpectType = TypeVar("ExpectType", bound="Widget") @overload @@ -578,16 +658,18 @@ def get_widget_by_id( raise NoMatches(f"No descendant found with id={id!r}") def get_child_by_type(self, expect_type: type[ExpectType]) -> ExpectType: - """Get a child of a give type. + """Get the first immediate child of a given type. + + Only returns exact matches, and so will not match subclasses of the given type. Args: - expect_type: The type of the expected child. + expect_type: The type of the child to search for. Raises: - NoMatches: If no valid child is found. + NoMatches: If no matching child is found. Returns: - A widget. + The first immediate child widget with the expected type. """ for child in self._nodes: # We want the child with the exact type (not subclasses) @@ -644,8 +726,6 @@ def _arrange(self, size: Size) -> DockArrangeResult: Returns: Widget locations. """ - assert self.is_container - cache_key = (size, self._nodes._updates) cached_result = self._arrangement_cache.get(cache_key) if cached_result is not None: @@ -751,7 +831,6 @@ def mount( Only one of ``before`` or ``after`` can be provided. If both are provided a ``MountError`` will be raised. """ - # Check for duplicate IDs in the incoming widgets ids_to_mount = [widget.id for widget in widgets if widget.id is not None] unique_ids = set(ids_to_mount) @@ -822,9 +901,30 @@ def mount_all( await_mount = self.mount(*widgets, before=before, after=after) return await_mount + @overload def move_child( self, child: int | Widget, + *, + before: int | Widget, + after: None = None, + ) -> None: + ... + + @overload + def move_child( + self, + child: int | Widget, + *, + after: int | Widget, + before: None = None, + ) -> None: + ... + + def move_child( + self, + child: int | Widget, + *, before: int | Widget | None = None, after: int | Widget | None = None, ) -> None: @@ -850,10 +950,6 @@ def move_child( elif before is not None and after is not None: raise WidgetError("Only one of `before` or `after` can be handled.") - # We short-circuit the no-op, otherwise it will error later down the road. - if child is before or child is after: - return - def _to_widget(child: int | Widget, called: str) -> Widget: """Ensure a given child reference is a Widget.""" if isinstance(child, int): @@ -878,6 +974,9 @@ def _to_widget(child: int | Widget, called: str) -> Widget: cast("int | Widget", before if after is None else after), "move towards" ) + if child is target: + return # Nothing to be done. + # At this point we should know what we're moving, and it should be a # child; where we're moving it to, which should be within the child # list; and how we're supposed to move it. All that's left is doing @@ -900,9 +999,8 @@ def compose(self) -> ComposeResult: ```python def compose(self) -> ComposeResult: yield Header() - yield Container( - Tree(), Viewer() - ) + yield Label("Press the button below:") + yield Button() yield Footer() ``` """ @@ -915,9 +1013,13 @@ def _post_register(self, app: App) -> None: app: App instance. """ # Parse the Widget's CSS - for path, css, tie_breaker in self._get_default_css(): + for read_from, css, tie_breaker, scope in self._get_default_css(): self.app.stylesheet.add_source( - css, path=path, is_default_css=True, tie_breaker=tie_breaker + css, + read_from=read_from, + is_default_css=True, + tie_breaker=tie_breaker, + scope=scope, ) def _get_box_model( @@ -985,7 +1087,9 @@ def _get_box_model( min_width = styles.min_width.resolve( content_container, viewport, width_fraction ) - content_width = max(content_width, min_width) + if is_border_box: + min_width -= gutter.width + content_width = max(content_width, min_width, Fraction(0)) if styles.max_width is not None: # Restrict to maximum width, if set @@ -1027,13 +1131,17 @@ def _get_box_model( min_height = styles.min_height.resolve( content_container, viewport, height_fraction ) - content_height = max(content_height, min_height) + if is_border_box: + min_height -= gutter.height + content_height = max(content_height, min_height, Fraction(0)) if styles.max_height is not None: # Restrict maximum height, if set max_height = styles.max_height.resolve( content_container, viewport, height_fraction ) + if is_border_box: + max_height -= gutter.height content_height = min(content_height, max_height) content_height = max(Fraction(0), content_height) @@ -1101,10 +1209,22 @@ def get_content_height(self, container: Size, viewport: Size, width: int) -> int return self._content_height_cache[1] renderable = self.render() - options = self._console.options.update_width(width).update(highlight=False) - segments = self._console.render(renderable, options) - # Cheaper than counting the lines returned from render_lines! - height = sum([text.count("\n") for text, _, _ in segments]) + if isinstance(renderable, Text): + height = len( + renderable.wrap( + self._console, + width, + no_wrap=renderable.no_wrap, + tab_size=renderable.tab_size or 8, + ) + ) + else: + options = self._console.options.update_width(width).update( + highlight=False + ) + segments = self._console.render(renderable, options) + # Cheaper than counting the lines returned from render_lines! + height = sum([text.count("\n") for text, _, _ in segments]) self._content_height_cache = (cache_key, height) return height @@ -1216,7 +1336,7 @@ def vertical_scrollbar(self) -> ScrollBar: @property def horizontal_scrollbar(self) -> ScrollBar: - """The a horizontal scrollbar. + """The horizontal scrollbar. Note: This will *create* a scrollbar if one doesn't exist. @@ -1301,8 +1421,7 @@ def scrollbars_enabled(self) -> tuple[bool, bool]: if not self.is_scrollable: return False, False - enabled = self.show_vertical_scrollbar, self.show_horizontal_scrollbar - return enabled + return (self.show_vertical_scrollbar, self.show_horizontal_scrollbar) @property def scrollbars_space(self) -> tuple[int, int]: @@ -1507,7 +1626,12 @@ def _self_or_ancestors_disabled(self) -> bool: @property def focusable(self) -> bool: """Can this widget currently be focused?""" - return self.can_focus and self.visible and not self._self_or_ancestors_disabled + return ( + not self.loading + and self.can_focus + and self.visible + and not self._self_or_ancestors_disabled + ) @property def _focus_sort_key(self) -> tuple[int, int]: @@ -1687,7 +1811,7 @@ def link_style(self) -> Style: return style @property - def link_hover_style(self) -> Style: + def link_style_hover(self) -> Style: """Style of links underneath the mouse cursor. Returns: @@ -1695,13 +1819,13 @@ def link_hover_style(self) -> Style: """ styles = self.styles _, background = self.background_colors - hover_background = background + styles.link_hover_background + hover_background = background + styles.link_background_hover hover_color = hover_background + ( - hover_background.get_contrast_text(styles.link_hover_color.a) - if styles.auto_link_hover_color - else styles.link_hover_color + hover_background.get_contrast_text(styles.link_color_hover.a) + if styles.auto_link_color_hover + else styles.link_color_hover ) - style = styles.link_hover_style + Style.from_color( + style = styles.link_style_hover + Style.from_color( hover_color.rich_color, hover_background.rich_color, ) @@ -2660,6 +2784,13 @@ 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 and scrollbar_size_vertical + ) + show_horizontal_scrollbar: bool = ( + show_horizontal_scrollbar and scrollbar_size_horizontal + ) + if styles.scrollbar_gutter == "stable": # Let's _always_ reserve some space, whether the scrollbar is actually displayed or not: show_vertical_scrollbar = True @@ -2690,6 +2821,13 @@ 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 and scrollbar_size_vertical + ) + show_horizontal_scrollbar: bool = ( + show_horizontal_scrollbar and scrollbar_size_horizontal + ) + if show_horizontal_scrollbar and show_vertical_scrollbar: ( window_region, @@ -2697,8 +2835,8 @@ def _arrange_scrollbars(self, region: Region) -> Iterable[tuple[Widget, Region]] horizontal_scrollbar_region, scrollbar_corner_gap, ) = region.split( - -scrollbar_size_vertical, - -scrollbar_size_horizontal, + region.width - scrollbar_size_vertical, + region.height - scrollbar_size_horizontal, ) if scrollbar_corner_gap: yield self.scrollbar_corner, scrollbar_corner_gap @@ -2715,7 +2853,7 @@ def _arrange_scrollbars(self, region: Region) -> Iterable[tuple[Widget, Region]] elif show_vertical_scrollbar: window_region, scrollbar_region = region.split_vertical( - -scrollbar_size_vertical + region.width - scrollbar_size_vertical ) if scrollbar_region: scrollbar = self.vertical_scrollbar @@ -2724,7 +2862,7 @@ def _arrange_scrollbars(self, region: Region) -> Iterable[tuple[Widget, Region]] yield scrollbar, scrollbar_region elif show_horizontal_scrollbar: window_region, scrollbar_region = region.split_horizontal( - -scrollbar_size_horizontal + region.height - scrollbar_size_horizontal ) if scrollbar_region: scrollbar = self.horizontal_scrollbar @@ -2750,6 +2888,8 @@ def get_pseudo_classes(self) -> Iterable[str]: yield "hover" if self.has_focus: yield "focus" + else: + yield "blur" if self.can_focus: yield "can-focus" try: @@ -2757,6 +2897,7 @@ def get_pseudo_classes(self) -> Iterable[str]: except NoScreen: pass else: + yield "dark" if self.app.dark else "light" if focused: node = focused while node is not None: @@ -2786,16 +2927,22 @@ def get_pseudo_class_state(self) -> PseudoClasses: ) return pseudo_classes + def _get_rich_justify(self) -> JustifyMethod | None: + """Get the justify method that may be passed to a Rich renderable.""" + text_justify: JustifyMethod | None = None + if self.styles.has_rule("text_align"): + text_align: JustifyMethod = cast(JustifyMethod, self.styles.text_align) + text_justify = _JUSTIFY_MAP.get(text_align, text_align) + return text_justify + def post_render(self, renderable: RenderableType) -> ConsoleRenderable: """Applies style attributes to the default renderable. Returns: A new renderable. """ - text_justify: JustifyMethod | None = None - if self.styles.has_rule("text_align"): - text_align: JustifyMethod = cast(JustifyMethod, self.styles.text_align) - text_justify = _JUSTIFY_MAP.get(text_align, text_align) + + text_justify = self._get_rich_justify() if isinstance(renderable, str): renderable = Text.from_markup(renderable, justify=text_justify) @@ -2836,7 +2983,7 @@ def watch_disabled(self) -> None: and self in self.app.focused.ancestors_with_self ): self.app.focused.blur() - except ScreenStackError: + except (ScreenStackError, NoActiveAppError): pass self._update_styles() @@ -2903,8 +3050,8 @@ def _render_content(self) -> None: width, height = self.size renderable = self.render() renderable = self.post_render(renderable) - options = self._console.options.update_dimensions(width, height).update( - highlight=False + options = self._console.options.update( + highlight=False, width=width, height=height ) segments = self._console.render(renderable, options) @@ -2923,7 +3070,7 @@ def _render_content(self) -> None: lines = list( align_lines( lines, - Style(), + _NULL_STYLE, self.size, align_horizontal, align_vertical, @@ -3016,6 +3163,8 @@ def refresh( Returns: The `Widget` instance. """ + if not self._is_mounted: + return self if layout: self._layout_required = True self._stabilize_scrollbar = None @@ -3054,13 +3203,30 @@ def remove_children(self) -> AwaitRemove: return await_remove def render(self) -> RenderableType: - """Get renderable for widget. + """Get text or Rich renderable for this widget. + + Implement this for custom widgets. + + Example: + ```python + from textual.app import RenderableType + from textual.widget import Widget + + class CustomWidget(Widget): + def render(self) -> RenderableType: + return "Welcome to [bold red]Textual[/]!" + ``` Returns: Any renderable. """ - render: Text | str = "" if self.is_container else self.css_identifier_styled - return render + + if self.is_container: + if self.styles.layout and self.styles.keyline[0] != "none": + return self._layout.render_keyline(self) + else: + return Blank(self.background_colors[1]) + return self.css_identifier_styled def _render(self) -> ConsoleRenderable | RichCast: """Get renderable, promoting str to text as required. @@ -3070,7 +3236,7 @@ def _render(self) -> ConsoleRenderable | RichCast: """ renderable = self.render() if isinstance(renderable, str): - return Text(renderable) + return Text.from_markup(renderable) return renderable async def run_action(self, action: str) -> None: @@ -3186,18 +3352,18 @@ def release_mouse(self) -> None: def begin_capture_print(self, stdout: bool = True, stderr: bool = True) -> None: """Capture text from print statements (or writes to stdout / stderr). - If printing is captured, the widget will be send an [events.Print][textual.events.Print] message. + If printing is captured, the widget will be sent an [`events.Print`][textual.events.Print] message. - Call [end_capture_print][textual.widget.Widget.end_capture_print] to disable print capture. + Call [`end_capture_print`][textual.widget.Widget.end_capture_print] to disable print capture. Args: - stdout: Capture stdout. - stderr: Capture stderr. + stdout: Whether to capture stdout. + stderr: Whether to capture stderr. """ self.app.begin_capture_print(self, stdout=stdout, stderr=stderr) def end_capture_print(self) -> None: - """End print capture (set with [capture_print][textual.widget.Widget.capture_print]).""" + """End print capture (set with [`begin_capture_print`][textual.widget.Widget.begin_capture_print]).""" self.app.end_capture_print(self) def check_message_enabled(self, message: Message) -> bool: @@ -3244,18 +3410,34 @@ async def _on_key(self, event: events.Key) -> None: async def handle_key(self, event: events.Key) -> bool: return await self.dispatch_key(event) - async def _on_compose(self) -> None: + async def _on_compose(self, event: events.Compose) -> None: + event.prevent_default() try: - widgets = [*self._nodes, *compose(self)] + widgets = [*self._pending_children, *compose(self)] + self._pending_children.clear() except TypeError as error: raise TypeError( f"{self!r} compose() method returned an invalid result; {error}" ) from error except Exception: + from rich.traceback import Traceback + self.app.panic(Traceback()) else: self._extend_compose(widgets) - await self.mount(*widgets) + await self.mount_composed_widgets(widgets) + + async def mount_composed_widgets(self, widgets: list[Widget]) -> None: + """Called by Textual to mount widgets after compose. + + There is generally no need to implement this method in your application. + See [Lazy][textual.lazy.Lazy] for a class which uses this method to implement + *lazy* mounting. + + Args: + widgets: A list of child widgets. + """ + await self.mount_all(widgets) def _extend_compose(self, widgets: list[Widget]) -> None: """Hook to extend composed widgets. diff --git a/src/textual/widgets/__init__.py b/src/textual/widgets/__init__.py index af7bd8968d..cd6e21f13b 100644 --- a/src/textual/widgets/__init__.py +++ b/src/textual/widgets/__init__.py @@ -41,6 +41,7 @@ from ._switch import Switch from ._tabbed_content import TabbedContent, TabPane from ._tabs import Tab, Tabs + from ._text_area import TextArea from ._tooltip import Tooltip from ._tree import Tree from ._welcome import Welcome @@ -79,6 +80,7 @@ "TabbedContent", "TabPane", "Tabs", + "TextArea", "RichLog", "Tooltip", "Tree", diff --git a/src/textual/widgets/__init__.pyi b/src/textual/widgets/__init__.pyi index a6f22febc0..d4db2f8f52 100644 --- a/src/textual/widgets/__init__.pyi +++ b/src/textual/widgets/__init__.pyi @@ -33,6 +33,7 @@ from ._tabbed_content import TabbedContent as TabbedContent from ._tabbed_content import TabPane as TabPane from ._tabs import Tab as Tab from ._tabs import Tabs as Tabs +from ._text_area import TextArea as TextArea from ._tooltip import Tooltip as Tooltip from ._tree import Tree as Tree from ._welcome import Welcome as Welcome diff --git a/src/textual/widgets/_button.py b/src/textual/widgets/_button.py index fee0db2030..83a3237b2d 100644 --- a/src/textual/widgets/_button.py +++ b/src/textual/widgets/_button.py @@ -1,8 +1,10 @@ from __future__ import annotations from functools import partial +from typing import cast import rich.repr +from rich.console import ConsoleRenderable, RenderableType from rich.text import Text, TextType from typing_extensions import Literal, Self @@ -10,8 +12,9 @@ from ..binding import Binding from ..css._error_tools import friendly_list from ..message import Message +from ..pad import HorizontalPad from ..reactive import reactive -from ..widgets import Static +from ..widget import Widget ButtonVariant = Literal["default", "primary", "success", "warning", "error"] """The names of the valid button variants. @@ -26,136 +29,124 @@ class InvalidButtonVariant(Exception): """Exception raised if an invalid button variant is used.""" -class Button(Static, can_focus=True): +class Button(Widget, can_focus=True): """A simple clickable button.""" DEFAULT_CSS = """ Button { width: auto; min-width: 16; - height: 3; + height: auto; background: $panel; color: $text; border: none; border-top: tall $panel-lighten-2; border-bottom: tall $panel-darken-3; + text-align: center; content-align: center middle; text-style: bold; - } - - Button:focus { - text-style: bold reverse; - } - - Button:hover { - border-top: tall $panel; - background: $panel-darken-2; - color: $text; - } - - Button.-active { - background: $panel; - border-bottom: tall $panel-lighten-2; - border-top: tall $panel-darken-2; - tint: $background 30%; - } - - /* Primary variant */ - Button.-primary { - background: $primary; - color: $text; - border-top: tall $primary-lighten-3; - border-bottom: tall $primary-darken-3; - - } - Button.-primary:hover { - background: $primary-darken-2; - color: $text; - border-top: tall $primary; - } + &:focus { + text-style: bold reverse; + } + &:hover { + border-top: tall $panel; + background: $panel-darken-2; + color: $text; + } + &.-active { + background: $panel; + border-bottom: tall $panel-lighten-2; + border-top: tall $panel-darken-2; + tint: $background 30%; + } + + &.-primary { + background: $primary; + color: $text; + border-top: tall $primary-lighten-3; + border-bottom: tall $primary-darken-3; + + &:hover { + background: $primary-darken-2; + color: $text; + border-top: tall $primary; + } + + &.-active { + background: $primary; + border-bottom: tall $primary-lighten-3; + border-top: tall $primary-darken-3; + } + } + + &.-success { + background: $success; + color: $text; + border-top: tall $success-lighten-2; + border-bottom: tall $success-darken-3; + + &:hover { + background: $success-darken-2; + color: $text; + border-top: tall $success; + } + + &.-active { + background: $success; + border-bottom: tall $success-lighten-2; + border-top: tall $success-darken-2; + } + } + + &.-warning{ + background: $warning; + color: $text; + border-top: tall $warning-lighten-2; + border-bottom: tall $warning-darken-3; + + &:hover { + background: $warning-darken-2; + color: $text; + border-top: tall $warning; + } + + &.-active { + background: $warning; + border-bottom: tall $warning-lighten-2; + border-top: tall $warning-darken-2; + } + } + + &.-error { + background: $error; + color: $text; + border-top: tall $error-lighten-2; + border-bottom: tall $error-darken-3; + + &:hover { + background: $error-darken-1; + color: $text; + border-top: tall $error; + } + + &.-active { + background: $error; + border-bottom: tall $error-lighten-2; + border-top: tall $error-darken-2; + } + + } - Button.-primary.-active { - background: $primary; - border-bottom: tall $primary-lighten-3; - border-top: tall $primary-darken-3; - } - - - /* Success variant */ - Button.-success { - background: $success; - color: $text; - border-top: tall $success-lighten-2; - border-bottom: tall $success-darken-3; - } - - Button.-success:hover { - background: $success-darken-2; - color: $text; - border-top: tall $success; - } - - Button.-success.-active { - background: $success; - border-bottom: tall $success-lighten-2; - border-top: tall $success-darken-2; - } - - - /* Warning variant */ - Button.-warning { - background: $warning; - color: $text; - border-top: tall $warning-lighten-2; - border-bottom: tall $warning-darken-3; - } - - Button.-warning:hover { - background: $warning-darken-2; - color: $text; - border-top: tall $warning; - - } - - Button.-warning.-active { - background: $warning; - border-bottom: tall $warning-lighten-2; - border-top: tall $warning-darken-2; - } - - - /* Error variant */ - Button.-error { - background: $error; - color: $text; - border-top: tall $error-lighten-2; - border-bottom: tall $error-darken-3; - - } - - Button.-error:hover { - background: $error-darken-1; - color: $text; - border-top: tall $error; - } - - Button.-error.-active { - background: $error; - border-bottom: tall $error-lighten-2; - border-top: tall $error-darken-2; } """ BINDINGS = [Binding("enter", "press", "Press Button", show=False)] - ACTIVE_EFFECT_DURATION = 0.3 - """When buttons are clicked they get the `-active` class for this duration (in seconds)""" - label: reactive[TextType] = reactive[TextType]("") """The text label that appears within the button.""" - variant = reactive("default") + variant = reactive("default", init=False) """The variant name for the button.""" class Pressed(Message): @@ -203,9 +194,10 @@ def __init__( if label is None: label = self.css_identifier_styled - self.label = self.validate_label(label) - - self.variant = self.validate_variant(variant) + self.label = label + self.variant = variant + self.active_effect_duration = 0.3 + """Amount of time in seconds the button 'press' animation lasts.""" def __rich_repr__(self) -> rich.repr.Result: yield from super().__rich_repr__() @@ -222,16 +214,26 @@ def watch_variant(self, old_variant: str, variant: str): self.remove_class(f"-{old_variant}") self.add_class(f"-{variant}") - def validate_label(self, label: TextType) -> TextType: + def validate_label(self, label: TextType) -> Text: """Parse markup for self.label""" if isinstance(label, str): return Text.from_markup(label) return label - def render(self) -> TextType: - label = Text.assemble(" ", self.label, " ") - label.stylize(self.text_style) - return label + def render(self) -> RenderableType: + assert isinstance(self.label, Text) + label = self.label.copy() + label.stylize(self.rich_style) + return HorizontalPad( + label, + 1, + 1, + self.rich_style, + self._get_rich_justify() or "center", + ) + + def post_render(self, renderable: RenderableType) -> ConsoleRenderable: + return cast(ConsoleRenderable, renderable) async def _on_click(self, event: events.Click) -> None: event.stop() @@ -252,10 +254,11 @@ def press(self) -> Self: def _start_active_affect(self) -> None: """Start a small animation to show the button was clicked.""" - self.add_class("-active") - self.set_timer( - self.ACTIVE_EFFECT_DURATION, partial(self.remove_class, "-active") - ) + if self.active_effect_duration > 0: + self.add_class("-active") + self.set_timer( + self.active_effect_duration, partial(self.remove_class, "-active") + ) def action_press(self) -> None: """Activate a press of the button.""" diff --git a/src/textual/widgets/_collapsible.py b/src/textual/widgets/_collapsible.py index d673f3de50..79a04816db 100644 --- a/src/textual/widgets/_collapsible.py +++ b/src/textual/widgets/_collapsible.py @@ -1,8 +1,5 @@ from __future__ import annotations -from rich.console import RenderableType -from rich.text import Text - from .. import events from ..app import ComposeResult from ..binding import Binding @@ -11,11 +8,12 @@ from ..message import Message from ..reactive import reactive from ..widget import Widget +from ..widgets import Static __all__ = ["Collapsible", "CollapsibleTitle"] -class CollapsibleTitle(Widget, can_focus=True): +class CollapsibleTitle(Static, can_focus=True): """Title and symbol for the Collapsible.""" DEFAULT_CSS = """ @@ -37,8 +35,14 @@ class CollapsibleTitle(Widget, can_focus=True): """ BINDINGS = [Binding("enter", "toggle", "Toggle collapsible", show=False)] + """ + | Key(s) | Description | + | :- | :- | + | enter | Toggle the collapsible. | + """ collapsed = reactive(True) + label = reactive("Toggle") def __init__( self, @@ -52,7 +56,9 @@ def __init__( self.collapsed_symbol = collapsed_symbol self.expanded_symbol = expanded_symbol self.label = label - self.collapse = collapsed + self.collapsed = collapsed + self._collapsed_label = f"{collapsed_symbol} {label}" + self._expanded_label = f"{expanded_symbol} {label}" class Toggle(Message): """Request toggle.""" @@ -66,18 +72,26 @@ def action_toggle(self) -> None: """Toggle the state of the parent collapsible.""" self.post_message(self.Toggle()) - def render(self) -> RenderableType: - """Compose right/down arrow and label.""" + def _watch_label(self, label: str) -> None: + self._collapsed_label = f"{self.collapsed_symbol} {label}" + self._expanded_label = f"{self.expanded_symbol} {label}" if self.collapsed: - return Text(f"{self.collapsed_symbol} {self.label}") + self.update(self._collapsed_label) + else: + self.update(self._expanded_label) + + def _watch_collapsed(self, collapsed: bool) -> None: + if collapsed: + self.update(self._collapsed_label) else: - return Text(f"{self.expanded_symbol} {self.label}") + self.update(self._expanded_label) class Collapsible(Widget): """A collapsible container.""" collapsed = reactive(True) + title = reactive("Toggle") DEFAULT_CSS = """ Collapsible { @@ -94,6 +108,42 @@ class Collapsible(Widget): } """ + class Toggled(Message): + """Parent class subclassed by `Collapsible` messages. + + Can be handled with `on(Collapsible.Toggled)` if you want to handle expansions + and collapsed in the same way, or you can handle the specific events individually. + """ + + def __init__(self, collapsible: Collapsible) -> None: + """Create an instance of the message. + + Args: + collapsible: The `Collapsible` widget that was toggled. + """ + self.collapsible: Collapsible = collapsible + """The collapsible that was toggled.""" + super().__init__() + + @property + def control(self) -> Collapsible: + """An alias for [Toggled.collapsible][textual.widgets.Collapsible.Toggled.collapsible].""" + return self.collapsible + + class Expanded(Toggled): + """Event sent when the `Collapsible` widget is expanded. + + Can be handled using `on_collapsible_expanded` in a subclass of + [`Collapsible`][textual.widgets.Collapsible] or in a parent widget in the DOM. + """ + + class Collapsed(Toggled): + """Event sent when the `Collapsible` widget is collapsed. + + Can be handled using `on_collapsible_collapsed` in a subclass of + [`Collapsible`][textual.widgets.Collapsible] or in a parent widget in the DOM. + """ + class Contents(Container): DEFAULT_CSS = """ Contents { @@ -128,19 +178,24 @@ def __init__( classes: The CSS classes of the collapsible. disabled: Whether the collapsible is disabled or not. """ + super().__init__(name=name, id=id, classes=classes, disabled=disabled) self._title = CollapsibleTitle( label=title, collapsed_symbol=collapsed_symbol, expanded_symbol=expanded_symbol, collapsed=collapsed, ) + self.title = title self._contents_list: list[Widget] = list(children) - super().__init__(name=name, id=id, classes=classes, disabled=disabled) self.collapsed = collapsed - def on_collapsible_title_toggle(self, event: CollapsibleTitle.Toggle) -> None: + 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.""" @@ -154,7 +209,7 @@ def _update_collapsed(self, collapsed: bool) -> None: except NoMatches: pass - def _on_mount(self) -> None: + def _on_mount(self, event: events.Mount) -> None: """Initialise collapsed state.""" self._update_collapsed(self.collapsed) @@ -169,3 +224,6 @@ def compose_add_child(self, widget: Widget) -> None: widget: A Widget to add. """ self._contents_list.append(widget) + + def _watch_title(self, title: str) -> None: + self._title.label = title diff --git a/src/textual/widgets/_data_table.py b/src/textual/widgets/_data_table.py index 62aa1e5464..eef090bfb2 100644 --- a/src/textual/widgets/_data_table.py +++ b/src/textual/widgets/_data_table.py @@ -4,7 +4,7 @@ from dataclasses import dataclass from itertools import chain, zip_longest from operator import itemgetter -from typing import Any, ClassVar, Generic, Iterable, NamedTuple, TypeVar, cast +from typing import Any, Callable, ClassVar, Generic, Iterable, NamedTuple, TypeVar, cast import rich.repr from rich.console import RenderableType @@ -33,15 +33,17 @@ from ..widget import PseudoClasses CellCacheKey: TypeAlias = ( - "tuple[RowKey, ColumnKey, Style, bool, bool, int, PseudoClasses]" + "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]" CursorType = Literal["cell", "row", "column", "none"] """The valid types of cursors for [`DataTable.cursor_type`][textual.widgets.DataTable.cursor_type].""" CellType = TypeVar("CellType") +"""Type used for cells in the DataTable.""" -CELL_X_PADDING = 2 +_DEFAULT_CELL_X_PADDING = 1 +"""Default padding to use on each side of a column in the data table.""" class CellDoesNotExist(Exception): @@ -130,12 +132,16 @@ class ColumnKey(StringKey): class CellKey(NamedTuple): """A unique identifier for a cell in the DataTable. + A cell key is a `(row_key, column_key)` tuple. + Even if the cell changes visual location (i.e. moves to a different coordinate in the table), this key can still be used to retrieve it, regardless of where it currently is.""" row_key: RowKey + """The key of this cell's row.""" column_key: ColumnKey + """The key of this cell's column.""" def __rich_repr__(self): yield "row_key", self.row_key @@ -170,14 +176,18 @@ class Column: content_width: int = 0 auto_width: bool = False - @property - def render_width(self) -> int: - """Width in cells, required to render a column.""" - # +2 is to account for space padding either side of the cell - if self.auto_width: - return self.content_width + CELL_X_PADDING - else: - return self.width + CELL_X_PADDING + def get_render_width(self, data_table: DataTable[Any]) -> int: + """Width, in cells, required to render the column with padding included. + + Args: + data_table: The data table where the column will be rendered. + + Returns: + The width, in cells, required to render the column with padding included. + """ + return 2 * data_table.cell_padding + ( + self.content_width if self.auto_width else self.width + ) @dataclass @@ -187,6 +197,7 @@ class Row: key: RowKey height: int label: Text | None = None + auto_height: bool = False class RowRenderables(NamedTuple): @@ -244,14 +255,14 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True): """ DEFAULT_CSS = """ - App.-dark DataTable { - background:; + DataTable:dark { + background: initial; } DataTable { background: $surface ; color: $text; height: auto; - max-height: 100%; + max-height: 100vh; } DataTable > .datatable--header { text-style: bold; @@ -290,7 +301,7 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True): background: $secondary 30%; } - .-dark-mode DataTable > .datatable--even-row { + DataTable:dark > .datatable--even-row { background: $primary 15%; } @@ -308,6 +319,8 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True): show_cursor = Reactive(True) cursor_type: Reactive[CursorType] = Reactive[CursorType]("cell") """The type of the cursor of the `DataTable`.""" + cell_padding = Reactive(_DEFAULT_CELL_X_PADDING) + """Horizontal padding between cells, applied on each side of each cell.""" cursor_coordinate: Reactive[Coordinate] = Reactive( Coordinate(0, 0), repaint=False, always_update=True @@ -583,11 +596,43 @@ def __init__( cursor_foreground_priority: Literal["renderable", "css"] = "css", cursor_background_priority: Literal["renderable", "css"] = "renderable", cursor_type: CursorType = "cell", + cell_padding: int = _DEFAULT_CELL_X_PADDING, name: str | None = None, id: str | None = None, classes: str | None = None, disabled: bool = False, ) -> None: + """Initialises a widget to display tabular data. + + Args: + show_header: Whether the table header should be visible or not. + show_row_labels: Whether the row labels should be shown or not. + fixed_rows: The number of rows, counting from the top, that should be fixed + and still visible when the user scrolls down. + fixed_columns: The number of columns, counting from the left, that should be + fixed and still visible when the user scrolls right. + zebra_stripes: Enables or disables a zebra effect applied to the background + color of the rows of the table, where alternate colors are styled + differently to improve the readability of the table. + header_height: The height, in number of cells, of the data table header. + show_cursor: Whether the cursor should be visible when navigating the data + table or not. + cursor_foreground_priority: If the data associated with a cell is an + arbitrary renderable with a set foreground color, this determines whether + that color is prioritized over the cursor component class or not. + cursor_background_priority: If the data associated with a cell is an + arbitrary renderable with a set background color, this determines whether + that color is prioritized over the cursor component class or not. + cursor_type: The type of cursor to be used when navigating the data table + with the keyboard. + cell_padding: The number of cells added on each side of each column. Setting + this value to zero will likely make your table very heard to read. + name: The name of the widget. + id: The ID of the widget in the DOM. + classes: The CSS classes for the widget. + disabled: Whether the widget is disabled or not. + """ + super().__init__(name=name, id=id, classes=classes, disabled=disabled) self._data: dict[RowKey, dict[ColumnKey, CellType]] = {} """Contains the cells of the table, indexed by row key and column key. @@ -672,6 +717,8 @@ def __init__( in the event where a cell contains a renderable with a background color.""" self.cursor_type = cursor_type """The type of cursor of the `DataTable`.""" + self.cell_padding = cell_padding + """Horizontal padding between cells, applied on each side of each cell.""" @property def hover_row(self) -> int: @@ -744,12 +791,15 @@ def update_cell( if isinstance(column_key, str): column_key = ColumnKey(column_key) - try: - self._data[row_key][column_key] = value - except KeyError: + if ( + row_key not in self._row_locations + or column_key not in self._column_locations + ): raise CellDoesNotExist( f"No cell exists for row_key={row_key!r}, column_key={column_key!r}." - ) from None + ) + + self._data[row_key][column_key] = value self._update_count += 1 # Recalculate widths if necessary @@ -951,6 +1001,7 @@ def _clear_caches(self) -> None: self._styles_cache.clear() self._offset_cache.clear() self._ordered_row_cache.clear() + self._get_styles_to_render_cell.cache_clear() def get_row_height(self, row_key: RowKey) -> int: """Given a row key, return the height of that row in terminal cells. @@ -965,7 +1016,7 @@ def get_row_height(self, row_key: RowKey) -> int: return self.header_height return self.rows[row_key].height - async def _on_styles_updated(self) -> None: + def notify_style_update(self) -> None: self._clear_caches() self.refresh() @@ -994,7 +1045,7 @@ def watch_show_header(self, show: bool) -> None: def watch_show_row_labels(self, show: bool) -> None: width, height = self.virtual_size - column_width = self._label_column.render_width + column_width = self._label_column.get_render_width(self) width_change = column_width if show else -column_width self.virtual_size = Size(width + width_change, height) self._scroll_cursor_into_view() @@ -1009,6 +1060,19 @@ def watch_fixed_columns(self) -> None: def watch_zebra_stripes(self) -> None: self._clear_caches() + def validate_cell_padding(self, cell_padding: int) -> int: + return max(cell_padding, 0) + + def watch_cell_padding(self, old_padding: int, new_padding: int) -> None: + # A single side of a single cell will have its width changed by (new - old), + # so the total width change is double that per column, times the number of + # columns for the whole data table. + width_change = 2 * (new_padding - old_padding) * len(self.columns) + width, height = self.virtual_size + self.virtual_size = Size(width + width_change, height) + self._scroll_cursor_into_view() + self._clear_caches() + def watch_hover_coordinate(self, old: Coordinate, value: Coordinate) -> None: self.refresh_coordinate(old) self.refresh_coordinate(value) @@ -1062,7 +1126,12 @@ def move_cursor( cursor_column = column destination = Coordinate(cursor_row, cursor_column) self.cursor_coordinate = destination - self._scroll_cursor_into_view(animate=animate) + + # Scroll the cursor after refresh to ensure the virtual height + # (calculated in on_idle) has settled. If we tried to scroll before + # the virtual size has been set, then it might fail if we added a bunch + # of rows then tried to immediately move the cursor. + self.call_after_refresh(self._scroll_cursor_into_view, animate=animate) def _highlight_coordinate(self, coordinate: Coordinate) -> None: """Apply highlighting to the cell at the coordinate, and post event.""" @@ -1162,7 +1231,11 @@ def _highlight_cursor(self) -> None: @property def _row_label_column_width(self) -> int: """The render width of the column containing row labels""" - return self._label_column.render_width if self._should_render_row_labels else 0 + return ( + self._label_column.get_render_width(self) + if self._should_render_row_labels + else 0 + ) def _update_column_widths(self, updated_cells: set[CellKey]) -> None: """Update the widths of the columns based on the newly updated cell widths.""" @@ -1190,8 +1263,16 @@ def _update_column_widths(self, updated_cells: set[CellKey]) -> None: self._require_update_dimensions = True def _update_dimensions(self, new_rows: Iterable[RowKey]) -> None: - """Called to recalculate the virtual (scrollable) size.""" + """Called to recalculate the virtual (scrollable) size. + + This recomputes column widths and then checks if any of the new rows need + to have their height computed. + + Args: + new_rows: The new rows that will affect the `DataTable` dimensions. + """ console = self.app.console + auto_height_rows: list[tuple[int, Row, list[RenderableType]]] = [] for row_key in new_rows: row_index = self._row_locations.get(row_key) @@ -1201,6 +1282,7 @@ def _update_dimensions(self, new_rows: Iterable[RowKey]) -> None: continue row = self.rows.get(row_key) + assert row is not None if row.label is not None: self._labelled_row_exists = True @@ -1215,9 +1297,69 @@ def _update_dimensions(self, new_rows: Iterable[RowKey]) -> None: content_width = measure(console, renderable, 1) column.content_width = max(column.content_width, content_width) - self._clear_caches() - - data_cells_width = sum(column.render_width for column in self.columns.values()) + if row.auto_height: + auto_height_rows.append((row_index, row, cells_in_row)) + + # If there are rows that need to have their height computed, render them correctly + # so that we can cache this rendering for later. + if auto_height_rows: + render_cell = self._render_cell # This method renders & caches. + should_highlight = self._should_highlight + cursor_type = self.cursor_type + cursor_location = self.cursor_coordinate + hover_location = self.hover_coordinate + base_style = self.rich_style + fixed_style = self.get_component_styles( + "datatable--fixed" + ).rich_style + Style.from_meta({"fixed": True}) + ordered_columns = self.ordered_columns + fixed_columns = self.fixed_columns + + for row_index, row, cells_in_row in auto_height_rows: + height = 0 + row_style = self._get_row_style(row_index, base_style) + + # As we go through the cells, save their rendering, height, and + # column width. After we compute the height of the row, go over the cells + # that were rendered with the wrong height and append the missing padding. + rendered_cells: list[tuple[SegmentLines, int, int]] = [] + for column_index, column in enumerate(ordered_columns): + style = fixed_style if column_index < fixed_columns else row_style + cell_location = Coordinate(row_index, column_index) + rendered_cell = render_cell( + row_index, + column_index, + style, + column.get_render_width(self), + cursor=should_highlight( + cursor_location, cell_location, cursor_type + ), + hover=should_highlight( + hover_location, cell_location, cursor_type + ), + ) + cell_height = len(rendered_cell) + rendered_cells.append( + (rendered_cell, cell_height, column.get_render_width(self)) + ) + height = max(height, cell_height) + + row.height = height + # Do surgery on the cache for cells that were rendered with the incorrect + # height during the first pass. + for cell_renderable, cell_height, column_width in rendered_cells: + if cell_height < height: + first_line_space_style = cell_renderable[0][0].style + cell_renderable.extend( + [ + [Segment(" " * column_width, first_line_space_style)] + for _ in range(height - cell_height) + ] + ) + + data_cells_width = sum( + column.get_render_width(self) for column in self.columns.values() + ) total_width = data_cells_width + self._row_label_column_width header_height = self.header_height if self.show_header else 0 self.virtual_size = Size( @@ -1237,11 +1379,14 @@ def _get_cell_region(self, coordinate: Coordinate) -> Region: # The x-coordinate of a cell is the sum of widths of the data cells to the left # plus the width of the render width of the longest row label. x = ( - sum(column.render_width for column in self.ordered_columns[:column_index]) + sum( + column.get_render_width(self) + for column in self.ordered_columns[:column_index] + ) + self._row_label_column_width ) column_key = self._column_locations.get_key(column_index) - width = self.columns[column_key].render_width + width = self.columns[column_key].get_render_width(self) height = row.height y = sum(ordered_row.height for ordered_row in self.ordered_rows[:row_index]) if self.show_header: @@ -1258,7 +1403,7 @@ def _get_row_region(self, row_index: int) -> Region: row_key = self._row_locations.get_key(row_index) row = rows[row_key] row_width = ( - sum(column.render_width for column in self.columns.values()) + sum(column.get_render_width(self) for column in self.columns.values()) + self._row_label_column_width ) y = sum(ordered_row.height for ordered_row in self.ordered_rows[:row_index]) @@ -1274,11 +1419,14 @@ def _get_column_region(self, column_index: int) -> Region: columns = self.columns x = ( - sum(column.render_width for column in self.ordered_columns[:column_index]) + sum( + column.get_render_width(self) + for column in self.ordered_columns[:column_index] + ) + self._row_label_column_width ) column_key = self._column_locations.get_key(column_index) - width = columns[column_key].render_width + width = columns[column_key].get_render_width(self) header_height = self.header_height if self.show_header else 0 height = self._total_row_height + header_height full_column_region = Region(x, 0, width, height) @@ -1373,7 +1521,7 @@ def add_column( def add_row( self, *cells: CellType, - height: int = 1, + height: int | None = 1, key: str | None = None, label: TextType | None = None, ) -> RowKey: @@ -1381,13 +1529,14 @@ def add_row( Args: *cells: Positional arguments should contain cell data. - height: The height of a row (in lines). + height: The height of a row (in lines). Use `None` to auto-detect the optimal + height. key: A key which uniquely identifies this row. If None, it will be generated for you and returned. label: The label for the row. Will be displayed to the left if supplied. Returns: - Uniquely identifies this row. Can be used to retrieve this row regardless + Unique identifier for this row. Can be used to retrieve this row regardless of its current location in the DataTable (it could have moved after being added due to sorting or insertion/deletion of other rows). """ @@ -1407,7 +1556,15 @@ def add_row( for column, cell in zip_longest(self.ordered_columns, cells) } label = Text.from_markup(label) if isinstance(label, str) else label - self.rows[row_key] = Row(row_key, height, label) + # Rows with auto-height get a height of 0 because 1) we need an integer height + # to do some intermediate computations and 2) because 0 doesn't impact the data + # table while we don't figure out how tall this row is. + self.rows[row_key] = Row( + row_key, + height or 0, + label, + height is None, + ) self._new_rows.add(row_key) self._require_update_dimensions = True self.cursor_coordinate = self.cursor_coordinate @@ -1485,6 +1642,10 @@ def remove_row(self, row_key: RowKey | str) -> None: self._row_locations = new_row_locations + # Prevent the removed cells from triggering dimension updates + for column_key in self._data.get(row_key): + self._updated_cells.discard(CellKey(row_key, column_key)) + del self.rows[row_key] del self._data[row_key] @@ -1521,8 +1682,10 @@ def remove_column(self, column_key: ColumnKey | str) -> None: self._column_locations = new_column_locations del self.columns[column_key] - for row in self._data: - del self._data[row][column_key] + + for row_key in self._data: + self._updated_cells.discard(CellKey(row_key, column_key)) + del self._data[row_key][column_key] self.cursor_coordinate = self.cursor_coordinate self.hover_coordinate = self.hover_coordinate @@ -1540,13 +1703,14 @@ async def _on_idle(self, _: events.Idle) -> None: if self._updated_cells: # Cell contents have already been updated at this point. # Now we only need to worry about measuring column widths. - updated_columns = self._updated_cells.copy() + updated_cells = self._updated_cells.copy() self._updated_cells.clear() - self._update_column_widths(updated_columns) + self._update_column_widths(updated_cells) if self._require_update_dimensions: # Add the new rows *before* updating the column widths, since - # cells in a new row may influence the final width of a column + # cells in a new row may influence the final width of a column. + # Only then can we compute optimal height of rows with "auto" height. self._require_update_dimensions = False new_rows = self._new_rows.copy() self._new_rows.clear() @@ -1754,7 +1918,7 @@ def _render_cell( row_key = self._row_locations.get_key(row_index) column_key = self._column_locations.get_key(column_index) - cell_cache_key = ( + cell_cache_key: CellCacheKey = ( row_key, column_key, base_style, @@ -1767,7 +1931,6 @@ def _render_cell( if cell_cache_key not in self._cell_render_cache: base_style += Style.from_meta({"row": row_index, "column": column_index}) - height = self.header_height if is_header_cell else self.rows[row_key].height row_label, row_cells = self._get_row_renderables(row_index) if is_row_label_cell: @@ -1775,50 +1938,104 @@ def _render_cell( else: cell = row_cells[column_index] - get_component = self.get_component_rich_style - show_cursor = self.show_cursor - component_style = Style() - - if hover and show_cursor and self._show_hover_cursor: - component_style += get_component("datatable--hover") - if is_header_cell or is_row_label_cell: - # Apply subtle variation in style for the header/label (blue background by - # default) rows and columns affected by the cursor, to ensure we can - # still differentiate between the labels and the data. - component_style += get_component("datatable--header-hover") - - if cursor and show_cursor: - cursor_style = get_component("datatable--cursor") - component_style += cursor_style - if is_header_cell or is_row_label_cell: - component_style += get_component("datatable--header-cursor") - elif is_fixed_style_cell: - component_style += get_component("datatable--fixed-cursor") - - post_foreground = ( - Style.from_color(color=component_style.color) - if self.cursor_foreground_priority == "css" - else Style.null() - ) - post_background = ( - Style.from_color(bgcolor=component_style.bgcolor) - if self.cursor_background_priority == "css" - else Style.null() + component_style, post_style = self._get_styles_to_render_cell( + is_header_cell, + is_row_label_cell, + is_fixed_style_cell, + hover, + cursor, + self.show_cursor, + self._show_hover_cursor, + self.cursor_foreground_priority == "css", + self.cursor_background_priority == "css", ) + if is_header_cell: + options = self.app.console.options.update_dimensions( + width, self.header_height + ) + else: + row = self.rows[row_key] + # If an auto-height row hasn't had its height calculated, we don't fix + # the value for `height` so that we can measure the height of the cell. + if row.auto_height and row.height == 0: + options = self.app.console.options.update_width(width) + else: + options = self.app.console.options.update_dimensions( + width, row.height + ) lines = self.app.console.render_lines( Styled( - Padding(cell, (0, 1)), + Padding(cell, (0, self.cell_padding)), pre_style=base_style + component_style, - post_style=post_foreground + post_background, + post_style=post_style, ), - self.app.console.options.update_dimensions(width, height), + options, ) self._cell_render_cache[cell_cache_key] = lines return self._cell_render_cache[cell_cache_key] + @functools.lru_cache(maxsize=32) + def _get_styles_to_render_cell( + self, + is_header_cell: bool, + is_row_label_cell: bool, + is_fixed_style_cell: bool, + hover: bool, + cursor: bool, + show_cursor: bool, + show_hover_cursor: bool, + has_css_foreground_priority: bool, + has_css_background_priority: bool, + ) -> tuple[Style, Style]: + """Auxiliary method to compute styles used to render a given cell. + + Args: + is_header_cell: Is this a cell from a header? + is_row_label_cell: Is this the label of any given row? + is_fixed_style_cell: Should this cell be styled like a fixed cell? + hover: Does this cell have the hover pseudo class? + cursor: Is this cell covered by the cursor? + show_cursor: Do we want to show the cursor in the data table? + show_hover_cursor: Do we want to show the mouse hover when using the keyboard + to move the cursor? + has_css_foreground_priority: `self.cursor_foreground_priority == "css"`? + has_css_background_priority: `self.cursor_background_priority == "css"`? + """ + get_component = self.get_component_rich_style + component_style = Style() + + if hover and show_cursor and show_hover_cursor: + component_style += get_component("datatable--hover") + if is_header_cell or is_row_label_cell: + # Apply subtle variation in style for the header/label (blue background by + # default) rows and columns affected by the cursor, to ensure we can + # still differentiate between the labels and the data. + component_style += get_component("datatable--header-hover") + + if cursor and show_cursor: + cursor_style = get_component("datatable--cursor") + component_style += cursor_style + if is_header_cell or is_row_label_cell: + component_style += get_component("datatable--header-cursor") + elif is_fixed_style_cell: + component_style += get_component("datatable--fixed-cursor") + + post_foreground = ( + Style.from_color(color=component_style.color) + if has_css_foreground_priority + else Style.null() + ) + post_background = ( + Style.from_color(bgcolor=component_style.bgcolor) + if has_css_background_priority + else Style.null() + ) + + return component_style, post_foreground + post_background + def _render_line_in_row( self, row_key: RowKey, @@ -1859,29 +2076,9 @@ def _render_line_in_row( if cache_key in self._row_render_cache: return self._row_render_cache[cache_key] - def _should_highlight( - cursor: Coordinate, - target_cell: Coordinate, - type_of_cursor: CursorType, - ) -> bool: - """Determine whether we should highlight a cell given the location - of the cursor, the location of the cell, and the type of cursor that - is currently active.""" - if type_of_cursor == "cell": - return cursor == target_cell - elif type_of_cursor == "row": - cursor_row, _ = cursor - cell_row, _ = target_cell - return cursor_row == cell_row - elif type_of_cursor == "column": - _, cursor_column = cursor - _, cell_column = target_cell - return cursor_column == cell_column - else: - return False - - is_header_row = row_key is self._header_row_key + should_highlight = self._should_highlight render_cell = self._render_cell + header_style = self.get_component_styles("datatable--header").rich_style if row_key in self._row_locations: row_index = self._row_locations.get(row_key) @@ -1890,7 +2087,6 @@ def _should_highlight( # If the row has a label, add it to fixed_row here with correct style. fixed_row = [] - header_style = self.get_component_styles("datatable--header").rich_style if self._labelled_row_exists and self.show_row_labels: # The width of the row label is updated again on idle @@ -1900,14 +2096,17 @@ def _should_highlight( -1, header_style, width=self._row_label_column_width, - cursor=_should_highlight(cursor_location, cell_location, cursor_type), - hover=_should_highlight(hover_location, cell_location, cursor_type), + cursor=should_highlight(cursor_location, cell_location, cursor_type), + hover=should_highlight(hover_location, cell_location, cursor_type), )[line_no] fixed_row.append(label_cell_lines) if self.fixed_columns: - fixed_style = self.get_component_styles("datatable--fixed").rich_style - fixed_style += Style.from_meta({"fixed": True}) + if row_key is self._header_row_key: + fixed_style = header_style # We use the header style either way. + else: + fixed_style = self.get_component_styles("datatable--fixed").rich_style + fixed_style += Style.from_meta({"fixed": True}) for column_index, column in enumerate( self.ordered_columns[: self.fixed_columns] ): @@ -1915,28 +2114,16 @@ def _should_highlight( fixed_cell_lines = render_cell( row_index, column_index, - header_style if is_header_row else fixed_style, - column.render_width, - cursor=_should_highlight( + fixed_style, + column.get_render_width(self), + cursor=should_highlight( cursor_location, cell_location, cursor_type ), - hover=_should_highlight(hover_location, cell_location, cursor_type), + hover=should_highlight(hover_location, cell_location, cursor_type), )[line_no] fixed_row.append(fixed_cell_lines) - is_header_row = row_key is self._header_row_key - if is_header_row: - row_style = self.get_component_styles("datatable--header").rich_style - elif row_index < self.fixed_rows: - row_style = self.get_component_styles("datatable--fixed").rich_style - else: - if self.zebra_stripes: - component_row_style = ( - "datatable--odd-row" if row_index % 2 else "datatable--even-row" - ) - row_style = self.get_component_styles(component_row_style).rich_style - else: - row_style = base_style + row_style = self._get_row_style(row_index, base_style) scrollable_row = [] for column_index, column in enumerate(self.ordered_columns): @@ -1945,9 +2132,9 @@ def _should_highlight( row_index, column_index, row_style, - column.render_width, - cursor=_should_highlight(cursor_location, cell_location, cursor_type), - hover=_should_highlight(hover_location, cell_location, cursor_type), + column.get_render_width(self), + cursor=should_highlight(cursor_location, cell_location, cursor_type), + hover=should_highlight(hover_location, cell_location, cursor_type), )[line_no] scrollable_row.append(cell_lines) @@ -1955,7 +2142,7 @@ def _should_highlight( widget_width = self.size.width table_width = ( sum( - column.render_width + column.get_render_width(self) for column in self.ordered_columns[self.fixed_columns :] ) + self._row_label_column_width @@ -2039,7 +2226,8 @@ def _render_line(self, y: int, x1: int, x2: int, base_style: Style) -> Strip: hover_location=self.hover_coordinate, ) fixed_width = sum( - column.render_width for column in self.ordered_columns[: self.fixed_columns] + column.get_render_width(self) + for column in self.ordered_columns[: self.fixed_columns] ) fixed_line: list[Segment] = list(chain.from_iterable(fixed)) if fixed else [] @@ -2075,6 +2263,63 @@ def render_line(self, y: int) -> Strip: return self._render_line(y, scroll_x, scroll_x + width, self.rich_style) + def _should_highlight( + self, + cursor: Coordinate, + target_cell: Coordinate, + type_of_cursor: CursorType, + ) -> bool: + """Determine if the given cell should be highlighted because of the cursor. + + This auxiliary method takes the cursor position and type into account when + determining whether the cell should be highlighted. + + Args: + cursor: The current position of the cursor. + target_cell: The cell we're checking for the need to highlight. + type_of_cursor: The type of cursor that is currently active. + + Returns: + Whether or not the given cell should be highlighted. + """ + if type_of_cursor == "cell": + return cursor == target_cell + elif type_of_cursor == "row": + cursor_row, _ = cursor + cell_row, _ = target_cell + return cursor_row == cell_row + elif type_of_cursor == "column": + _, cursor_column = cursor + _, cell_column = target_cell + return cursor_column == cell_column + else: + return False + + def _get_row_style(self, row_index: int, base_style: Style) -> Style: + """Gets the Style that should be applied to the row at the given index. + + Args: + row_index: The index of the row to style. + base_style: The base style to use by default. + + Returns: + The appropriate style. + """ + + if row_index == -1: + row_style = self.get_component_styles("datatable--header").rich_style + elif row_index < self.fixed_rows: + row_style = self.get_component_styles("datatable--fixed").rich_style + else: + if self.zebra_stripes: + component_row_style = ( + "datatable--odd-row" if row_index % 2 else "datatable--even-row" + ) + row_style = self.get_component_styles(component_row_style).rich_style + else: + row_style = base_style + return row_style + def _on_mouse_move(self, event: events.MouseMove): """If the hover cursor is visible, display it by extracting the row and column metadata from the segments present in the cells.""" @@ -2101,7 +2346,7 @@ def _get_fixed_offset(self) -> Spacing: top += sum(row.height for row in self.ordered_rows[: self.fixed_rows]) left = ( sum( - column.render_width + column.get_render_width(self) for column in self.ordered_columns[: self.fixed_columns] ) + self._row_label_column_width @@ -2111,30 +2356,40 @@ def _get_fixed_offset(self) -> Spacing: def sort( self, *columns: ColumnKey | str, + key: Callable[[Any], Any] | None = None, reverse: bool = False, ) -> Self: - """Sort the rows in the `DataTable` by one or more column keys. + """Sort the rows in the `DataTable` by one or more column keys or a + key function (or other callable). If both columns and a key function + are specified, only data from those columns will sent to the key function. Args: columns: One or more columns to sort by the values in. + key: A function (or other callable) that returns a key to + use for sorting purposes. reverse: If True, the sort order will be reversed. Returns: The `DataTable` instance. """ - def sort_by_column_keys( - row: tuple[RowKey, dict[ColumnKey | str, CellType]] - ) -> Any: + def key_wrapper(row: tuple[RowKey, dict[ColumnKey | str, CellType]]) -> Any: _, row_data = row - result = itemgetter(*columns)(row_data) + if columns: + result = itemgetter(*columns)(row_data) + else: + result = tuple(row_data.values()) + if key is not None: + return key(result) return result ordered_rows = sorted( - self._data.items(), key=sort_by_column_keys, reverse=reverse + self._data.items(), + key=key_wrapper, + reverse=reverse, ) self._row_locations = TwoWayDict( - {key: new_index for new_index, (key, _) in enumerate(ordered_rows)} + {row_key: new_index for new_index, (row_key, _) in enumerate(ordered_rows)} ) self._update_count += 1 self.refresh() diff --git a/src/textual/widgets/_directory_tree.py b/src/textual/widgets/_directory_tree.py index e85c566f49..e4a1d4ba72 100644 --- a/src/textual/widgets/_directory_tree.py +++ b/src/textual/widgets/_directory_tree.py @@ -5,6 +5,8 @@ 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 @@ -20,7 +22,7 @@ @dataclass class DirEntry: - """Attaches directory information to a node.""" + """Attaches directory information to a [`DirectoryTree`][textual.widgets.DirectoryTree] node.""" path: Path """The path of the directory entry.""" @@ -152,18 +154,26 @@ def __init__( ) self.path = path - def _add_to_load_queue(self, node: TreeNode[DirEntry]) -> None: + def _add_to_load_queue(self, node: TreeNode[DirEntry]) -> AwaitComplete: """Add the given node to the load queue. + The return value can optionally be awaited until the queue is empty. + Args: node: The node to add to the load queue. + + Returns: + An optionally awaitable object that can be awaited until the + load queue has finished processing. """ assert node.data is not None if not node.data.loaded: node.data.loaded = True self._load_queue.put_nowait(node) - def reload(self) -> None: + return AwaitComplete(self._load_queue.join()) + + def reload(self) -> AwaitComplete: """Reload the `DirectoryTree` contents.""" self.reset(str(self.path), DirEntry(self.PATH(self.path))) # Orphan the old queue... @@ -172,7 +182,8 @@ def reload(self) -> None: self._loader() # We have a fresh queue, we have a fresh loader, get the fresh root # loading up. - self._add_to_load_queue(self.root) + queue_processed = self._add_to_load_queue(self.root) + return queue_processed def clear_node(self, node: TreeNode[DirEntry]) -> Self: """Clear all nodes under the given node. @@ -202,6 +213,7 @@ def reset_node( """Clear the subtree and reset the given node. Args: + node: The node to reset. label: The label for the node. data: Optional data for the node. @@ -213,16 +225,20 @@ def reset_node( node.data = data return self - def reload_node(self, node: TreeNode[DirEntry]) -> None: + def reload_node(self, node: TreeNode[DirEntry]) -> AwaitComplete: """Reload the given node's contents. + The return value may be awaited to ensure the DirectoryTree has reached + a stable state and is no longer performing any node reloading (of this node + or any other nodes). + Args: node: The node to reload. """ self.reset_node( node, str(node.data.path.name), DirEntry(self.PATH(node.data.path)) ) - self._add_to_load_queue(node) + return self._add_to_load_queue(node) def validate_path(self, path: str | Path) -> Path: """Ensure that the path is of the `Path` type. @@ -239,13 +255,13 @@ def validate_path(self, path: str | Path) -> Path: """ return self.PATH(path) - def watch_path(self) -> None: + async def watch_path(self) -> None: """Watch for changes to the `path` of the directory tree. If the path is changed the directory tree will be repopulated using the new value as the root. """ - self.reload() + await self.reload() def process_label(self, label: TextType) -> Text: """Process a str or Text into a label. Maybe overridden in a subclass to modify how labels are rendered. @@ -421,16 +437,17 @@ async def _loader(self) -> None: # the tree. if content: self._populate_node(node, content) - # Mark this iteration as done. - self._load_queue.task_done() + finally: + # Mark this iteration as done. + self._load_queue.task_done() - def _on_tree_node_expanded(self, event: Tree.NodeExpanded) -> None: + async def _on_tree_node_expanded(self, event: Tree.NodeExpanded) -> None: event.stop() dir_entry = event.node.data if dir_entry is None: return if self._safe_is_dir(dir_entry.path): - self._add_to_load_queue(event.node) + await self._add_to_load_queue(event.node) else: self.post_message(self.FileSelected(event.node, dir_entry.path)) diff --git a/src/textual/widgets/_input.py b/src/textual/widgets/_input.py index b92161e504..828f2ff4ac 100644 --- a/src/textual/widgets/_input.py +++ b/src/textual/widgets/_input.py @@ -14,11 +14,13 @@ from .. import events from .._segment_tools import line_crop from ..binding import Binding, BindingType +from ..css._error_tools import friendly_list from ..events import Blur, Focus, Mount -from ..geometry import Size +from ..geometry import Offset, Size from ..message import Message -from ..reactive import reactive +from ..reactive import reactive, var from ..suggester import Suggester, SuggestionReady +from ..timer import Timer from ..validation import ValidationResult, Validator from ..widget import Widget @@ -28,6 +30,14 @@ """Set literal with the legal values for the type `InputValidationOn`.""" +_RESTRICT_TYPES = { + "integer": r"[-+]?\d*", + "number": r"[-+]?\d*\.?\d*[eE]?[-+]?\d*", + "text": None, +} +InputType = Literal["integer", "number", "text"] + + class _InputRenderable: """Render the input content.""" @@ -46,7 +56,7 @@ def __rich_console__( value = input.value value_length = len(value) suggestion = input._suggestion - show_suggestion = len(suggestion) > value_length + show_suggestion = len(suggestion) > value_length and input.has_focus if show_suggestion: result += Text( suggestion[value_length:], @@ -135,8 +145,7 @@ class Input(Widget, can_focus=True): padding: 0 2; border: tall $background; width: 100%; - height: 1; - min-height: 1; + height: 3; } Input:focus { border: tall $accent; @@ -157,7 +166,7 @@ class Input(Widget, can_focus=True): } """ - cursor_blink = reactive(True) + cursor_blink = reactive(True, init=False) value = reactive("", layout=True, init=False) input_scroll_offset = reactive(0) cursor_position = reactive(0) @@ -167,11 +176,18 @@ class Input(Widget, can_focus=True): width = reactive(1) _cursor_visible = reactive(True) password = reactive(False) - max_size: reactive[int | None] = reactive(None) suggester: Suggester | None """The suggester used to provide completions as the user types.""" _suggestion = reactive("") """A completion suggestion for the current value in the input.""" + restrict = var["str | None"](None) + """A regular expression to limit changes in value.""" + type = var[InputType]("text") + """The type of the input.""" + max_length = var["int | None"](None) + """The maximum length of the input, in characters.""" + valid_empty = var(False) + """Empty values should pass validation.""" @dataclass class Changed(Message): @@ -225,9 +241,13 @@ def __init__( highlighter: Highlighter | None = None, password: bool = False, *, + restrict: str | None = None, + type: InputType = "text", + max_length: int = 0, suggester: Suggester | None = None, validators: Validator | Iterable[Validator] | None = None, validate_on: Iterable[InputValidationOn] | None = None, + valid_empty: bool = False, name: str | None = None, id: str | None = None, classes: str | None = None, @@ -240,24 +260,31 @@ def __init__( placeholder: Optional placeholder text for the input. highlighter: An optional highlighter for the input. password: Flag to say if the field should obfuscate its content. + restrict: A regex to restrict character inputs. + type: The type of the input. + max_length: The maximum length of the input, or 0 for no maximum length. suggester: [`Suggester`][textual.suggester.Suggester] associated with this input instance. validators: An iterable of validators that the Input value will be checked against. validate_on: Zero or more of the values "blur", "changed", and "submitted", which determine when to do input validation. The default is to do validation for all messages. + valid_empty: Empty values are valid. name: Optional name for the input widget. id: Optional ID for the widget. classes: Optional initial classes for the widget. disabled: Whether the input is disabled or not. """ super().__init__(name=name, id=id, classes=classes, disabled=disabled) - if value is not None: - self.value = value + + self._blink_timer: Timer | None = None + """Timer controlling the blinking of the cursor, instantiated in `on_mount`.""" + self.placeholder = placeholder self.highlighter = highlighter self.password = password self.suggester = suggester + # Ensure we always end up with an Iterable of validators if isinstance(validators, Validator): self.validators: list[Validator] = [validators] @@ -283,6 +310,26 @@ def __init__( input = Input(validate_on=["submitted"]) ``` """ + self._reactive_valid_empty = valid_empty + self._valid = True + + self.restrict = restrict + if type not in _RESTRICT_TYPES: + raise ValueError( + f"Input type must be one of {friendly_list(_RESTRICT_TYPES.keys())}; not {type!r}" + ) + self.type = type + self.max_length = max_length + if not self.validators: + from ..validation import Integer, Number + + if self.type == "integer": + self.validators.append(Integer()) + elif self.type == "number": + self.validators.append(Number()) + + if value is not None: + self.value = value def _position_to_cell(self, position: int) -> int: """Convert an index within the value to cell position.""" @@ -327,7 +374,24 @@ def _watch_cursor_position(self) -> None: else: self.view_position = self.view_position - async def _watch_value(self, value: str) -> None: + self.app.cursor_position = self.cursor_screen_offset + + def _watch_cursor_blink(self, blink: bool) -> None: + """Ensure we handle updating the cursor blink at runtime.""" + if self._blink_timer is not None: + if blink: + self._blink_timer.resume() + else: + self._cursor_visible = True + self._blink_timer.pause() + + @property + def cursor_screen_offset(self) -> Offset: + """The offset of the cursor of this input in screen-space. (x, y)/(column, row)""" + x, y, _width, _height = self.content_region + return Offset(x + self._cursor_offset - self.view_position, y) + + def _watch_value(self, value: str) -> None: self._suggestion = "" if self.suggester and value: self.run_worker(self.suggester._get_suggestion(self, value)) @@ -339,6 +403,10 @@ async def _watch_value(self, value: str) -> None: ) self.post_message(self.Changed(self, value, validation_result)) + def _watch_valid_empty(self) -> None: + """Repeat validation when valid_empty changes.""" + self._watch_value(self.value) + def validate(self, value: str) -> ValidationResult | None: """Run all the validators associated with this Input on the supplied value. @@ -353,18 +421,38 @@ def validate(self, value: str) -> ValidationResult | None: That is, if *any* validator fails, the result will be an unsuccessful validation. """ + + def set_classes() -> None: + """Set classes for valid flag.""" + valid = self._valid + self.set_class(not valid, "-invalid") + self.set_class(valid, "-valid") + # If no validators are supplied, and therefore no validation occurs, we return None. if not self.validators: + self._valid = True + set_classes() + return None + + if self.valid_empty and not value: + self._valid = True + set_classes() return None validation_results: list[ValidationResult] = [ validator.validate(value) for validator in self.validators ] combined_result = ValidationResult.merge(validation_results) - self.set_class(not combined_result.is_valid, "-invalid") - self.set_class(combined_result.is_valid, "-valid") + self._valid = combined_result.is_valid + set_classes() + return combined_result + @property + def is_valid(self) -> bool: + """Check if the value has passed validation.""" + return self._valid + @property def cursor_width(self) -> int: """The width of the input (with extra space for cursor at the end).""" @@ -410,26 +498,27 @@ def _toggle_cursor(self) -> None: self._cursor_visible = not self._cursor_visible def _on_mount(self, _: Mount) -> None: - self.blink_timer = self.set_interval( + self._blink_timer = self.set_interval( 0.5, self._toggle_cursor, pause=not (self.cursor_blink and self.has_focus), ) def _on_blur(self, _: Blur) -> None: - self.blink_timer.pause() + self._blink_timer.pause() if "blur" in self.validate_on: self.validate(self.value) def _on_focus(self, _: Focus) -> None: self.cursor_position = len(self.value) if self.cursor_blink: - self.blink_timer.resume() + self._blink_timer.resume() + self.app.cursor_position = self.cursor_screen_offset async def _on_key(self, event: events.Key) -> None: self._cursor_visible = True if self.cursor_blink: - self.blink_timer.reset() + self._blink_timer.reset() if event.is_printable: event.stop() @@ -471,15 +560,55 @@ def insert_text_at_cursor(self, text: str) -> None: Args: text: New text to insert. """ + + def check_allowed_value(value: str) -> bool: + """Check if new value is restricted.""" + # Check max length + if self.max_length and len(value) > self.max_length: + return False + # Check explicit restrict + if self.restrict and re.fullmatch(self.restrict, value) is None: + return False + # Check type restrict + if self.type: + type_restrict = _RESTRICT_TYPES.get(self.type, None) + if ( + type_restrict is not None + and re.fullmatch(type_restrict, value) is None + ): + return False + # Character is allowed + return True + if self.cursor_position >= len(self.value): - self.value += text - self.cursor_position = len(self.value) + new_value = self.value + text + if check_allowed_value(new_value): + self.value = new_value + self.cursor_position = len(self.value) + else: + self.restricted() else: value = self.value before = value[: self.cursor_position] after = value[self.cursor_position :] - self.value = f"{before}{text}{after}" - self.cursor_position += len(text) + new_value = f"{before}{text}{after}" + if check_allowed_value(new_value): + self.value = new_value + self.cursor_position += len(text) + else: + self.restricted() + + def restricted(self) -> None: + """Called when a character has been restricted. + + The default behavior is to play the system bell. + You may want to override this method if you want to disable the bell or do something else entirely. + """ + self.app.bell() + + def clear(self) -> None: + """Clear the input.""" + self.value = "" def action_cursor_left(self) -> None: """Move the cursor one position to the left.""" diff --git a/src/textual/widgets/_list_item.py b/src/textual/widgets/_list_item.py index bf3a43e28a..e87b8cf4fc 100644 --- a/src/textual/widgets/_list_item.py +++ b/src/textual/widgets/_list_item.py @@ -16,6 +16,8 @@ class ListItem(Widget, can_focus=False): documentation for more details on use. """ + SCOPED_CSS = False + DEFAULT_CSS = """ ListItem { color: $text; diff --git a/src/textual/widgets/_list_view.py b/src/textual/widgets/_list_view.py index 615d37ae5a..b102a17d20 100644 --- a/src/textual/widgets/_list_view.py +++ b/src/textual/widgets/_list_view.py @@ -2,6 +2,8 @@ from typing import ClassVar, Iterable, Optional +from typing_extensions import TypeGuard + from textual.await_remove import AwaitRemove from textual.binding import Binding, BindingType from textual.containers import VerticalScroll @@ -37,6 +39,7 @@ class ListView(VerticalScroll, can_focus=True, can_focus_children=False): """ index = reactive[Optional[int]](0, always_update=True) + """The index of the currently highlighted item.""" class Highlighted(Message): """Posted when the highlighted item changes. @@ -148,13 +151,13 @@ def _clamp_index(self, index: int) -> int: last_index = max(len(self._nodes) - 1, 0) return clamp(index, 0, last_index) - def _is_valid_index(self, index: int | None) -> bool: + def _is_valid_index(self, index: int | None) -> TypeGuard[int]: """Return True if the current index is valid given the current list of children""" if index is None: return False return 0 <= index < len(self._nodes) - def watch_index(self, old_index: int, new_index: int) -> None: + def watch_index(self, old_index: int | None, new_index: int | None) -> None: """Updates the highlighting when the index changes.""" if self._is_valid_index(old_index): old_child = self._nodes[old_index] diff --git a/src/textual/widgets/_loading_indicator.py b/src/textual/widgets/_loading_indicator.py index 51a391f8f9..e7cc4abb47 100644 --- a/src/textual/widgets/_loading_indicator.py +++ b/src/textual/widgets/_loading_indicator.py @@ -22,8 +22,33 @@ class LoadingIndicator(Widget): content-align: center middle; color: $accent; } + LoadingIndicator.-textual-loading-indicator { + layer: _loading; + background: $boost; + dock: top; + } """ + def __init__( + self, + name: str | None = None, + id: str | None = None, + classes: str | None = None, + disabled: bool = False, + ): + """Initialize a loading indicator. + + Args: + name: The name of the widget. + id: The ID of the widget in the DOM. + classes: The CSS classes for the widget. + disabled: Whether the widget is disabled or not. + """ + super().__init__(name=name, id=id, classes=classes, disabled=disabled) + + self._start_time: float = 0.0 + """The time the loading indicator was mounted (a Unix timestamp).""" + def _on_mount(self, _: Mount) -> None: self._start_time = time() self.auto_refresh = 1 / 16 diff --git a/src/textual/widgets/_log.py b/src/textual/widgets/_log.py index b80f1c9dea..0f4a4bc6be 100644 --- a/src/textual/widgets/_log.py +++ b/src/textual/widgets/_log.py @@ -75,7 +75,7 @@ def __init__( @property def lines(self) -> Sequence[str]: - """The raw lines in the TextLog. + """The raw lines in the Log. Note that this attribute is read only. Changing the lines will not update the Log's contents. @@ -294,6 +294,7 @@ def _render_line_strip(self, y: int, rich_style: Style) -> Strip: line = Strip(line_text.render(self.app.console), cell_len(_line)) else: line = Strip([Segment(_line, rich_style)], cell_len(_line)) + self._render_line_cache[y] = line return line diff --git a/src/textual/widgets/_markdown.py b/src/textual/widgets/_markdown.py index 7c15be6534..d820ccc723 100644 --- a/src/textual/widgets/_markdown.py +++ b/src/textual/widgets/_markdown.py @@ -1,27 +1,31 @@ from __future__ import annotations from pathlib import Path, PurePath -from typing import Callable, Iterable +from typing import Callable, Iterable, Optional from markdown_it import MarkdownIt 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 from .._slug import TrackedSlugs from ..app import ComposeResult +from ..await_complete import AwaitComplete from ..containers import Horizontal, Vertical, VerticalScroll from ..events import Mount from ..message import Message from ..reactive import reactive, var -from ..widget import AwaitMount, Widget +from ..widget import Widget from ..widgets import Static, Tree TableOfContentsType: TypeAlias = "list[tuple[int, str, str | None]]" +"""Information about the table of contents of a markdown document. + +The triples encode the level, the label, and the optional block id of each heading. +""" class Navigator: @@ -223,6 +227,7 @@ class MarkdownHorizontalRule(MarkdownBlock): class MarkdownParagraph(MarkdownBlock): """A paragraph Markdown block.""" + SCOPED_CSS = False DEFAULT_CSS = """ Markdown > MarkdownParagraph { margin: 0 0 1 0; @@ -498,6 +503,8 @@ def __init__(self, markdown: Markdown, code: str, lexer: str) -> None: super().__init__(markdown) def compose(self) -> ComposeResult: + from rich.syntax import Syntax + yield Static( Syntax( self.code, @@ -544,7 +551,19 @@ class Markdown(Widget): text-style: bold dim; } """ + COMPONENT_CLASSES = {"em", "strong", "s", "code_inline"} + """ + These component classes target standard inline markdown styles. + Changing these will potentially break the standard markdown formatting. + + | Class | Description | + | :- | :- | + | `code_inline` | Target text that is styled as inline code. | + | `em` | Target text that is emphasized inline. | + | `s` | Target text that is styled inline with strykethrough. | + | `strong` | Target text that is styled inline with strong. | + """ BULLETS = ["\u25CF ", "▪ ", "‣ ", "• ", "⭑ "] @@ -648,7 +667,7 @@ def sanitize_location(location: str) -> tuple[Path, str]: location, _, anchor = location.partition("#") return Path(location), anchor - def goto_anchor(self, anchor: str) -> None: + def goto_anchor(self, anchor: str) -> bool: """Try and find the given anchor in the current document. Args: @@ -661,14 +680,18 @@ def goto_anchor(self, anchor: str) -> None: Note that the slugging method used is similar to that found on GitHub. + + Returns: + True when the anchor was found in the current document, False otherwise. """ if not self._table_of_contents or not isinstance(self.parent, Widget): - return + return False unique = TrackedSlugs() for _, title, header_id in self._table_of_contents: if unique.slug(title) == anchor: self.parent.scroll_to_widget(self.query_one(f"#{header_id}"), top=True) - return + return True + return False async def load(self, path: Path) -> None: """Load a new Markdown document. @@ -692,14 +715,14 @@ def unhandled_token(self, token: Token) -> MarkdownBlock | None: """Process an unhandled token. Args: - token: The token to handle. + token: The MarkdownIt token to handle. Returns: Either a widget to be added to the output, or `None`. """ return None - def update(self, markdown: str) -> AwaitMount: + def update(self, markdown: str) -> AwaitComplete: """Update the document with new Markdown. Args: @@ -849,12 +872,21 @@ def update(self, markdown: str) -> AwaitMount: self.post_message( Markdown.TableOfContentsUpdated(self, self._table_of_contents) ) - with self.app.batch_update(): - self.query("MarkdownBlock").remove() - return self.mount_all(output) + markdown_block = self.query("MarkdownBlock") + + async def await_update() -> None: + """Update in a single batch.""" + + with self.app.batch_update(): + await markdown_block.remove() + await self.mount_all(output) + + return AwaitComplete(await_update()) class MarkdownTableOfContents(Widget, can_focus_children=True): + """Displays a table of contents for a markdown document.""" + DEFAULT_CSS = """ MarkdownTableOfContents { width: auto; @@ -867,7 +899,8 @@ class MarkdownTableOfContents(Widget, can_focus_children=True): } """ - table_of_contents = reactive["TableOfContentsType | None"](None, init=False) + table_of_contents = reactive[Optional[TableOfContentsType]](None, init=False) + """Underlying data to populate the table of contents widget.""" def __init__( self, @@ -886,7 +919,7 @@ def __init__( classes: The CSS classes for the widget. disabled: Whether the widget is disabled or not. """ - self.markdown = markdown + self.markdown: Markdown = markdown """The Markdown document associated with this table of contents.""" super().__init__(name=name, id=id, classes=classes, disabled=disabled) @@ -900,10 +933,10 @@ def compose(self) -> ComposeResult: def watch_table_of_contents(self, table_of_contents: TableOfContentsType) -> None: """Triggered when the table of contents changes.""" - self.set_table_of_contents(table_of_contents) + self.rebuild_table_of_contents(table_of_contents) - def set_table_of_contents(self, table_of_contents: TableOfContentsType) -> None: - """Set the table of contents. + def rebuild_table_of_contents(self, table_of_contents: TableOfContentsType) -> None: + """Rebuilds the tree representation of the table of contents data. Args: table_of_contents: Table of contents. @@ -920,7 +953,8 @@ def set_table_of_contents(self, table_of_contents: TableOfContentsType) -> None: node.allow_expand = True else: node = node.add(NUMERALS[level], expand=True) - node.add_leaf(f"[dim]{NUMERALS[level]}[/] {name}", {"block_id": block_id}) + node_label = Text.assemble((f"{NUMERALS[level]} ", "dim"), name) + node.add_leaf(node_label, {"block_id": block_id}) async def _on_tree_node_selected(self, message: Tree.NodeSelected) -> None: node_data = message.node.data @@ -934,6 +968,8 @@ async def _on_tree_node_selected(self, message: Tree.NodeSelected) -> None: class MarkdownViewer(VerticalScroll, can_focus=True, can_focus_children=True): """A Markdown viewer widget.""" + SCOPED_CSS = False + DEFAULT_CSS = """ MarkdownViewer { height: 1fr; @@ -985,12 +1021,12 @@ def __init__( @property def document(self) -> Markdown: - """The Markdown document object.""" + """The [`Markdown`][textual.widgets.Markdown] document widget.""" return self.query_one(Markdown) @property def table_of_contents(self) -> MarkdownTableOfContents: - """The table of contents widget""" + """The [table of contents][textual.widgets.markdown.MarkdownTableOfContents] widget.""" return self.query_one(MarkdownTableOfContents) def _on_mount(self, _: Mount) -> None: diff --git a/src/textual/widgets/_option_list.py b/src/textual/widgets/_option_list.py index 60a804287a..bed8c1e01e 100644 --- a/src/textual/widgets/_option_list.py +++ b/src/textual/widgets/_option_list.py @@ -26,11 +26,11 @@ class DuplicateID(Exception): - """Exception raised if a duplicate ID is used.""" + """Raised if a duplicate ID is used when adding options to an option list.""" class OptionDoesNotExist(Exception): - """Exception raised when a request has been made for an option that doesn't exist.""" + """Raised when a request has been made for an option that doesn't exist.""" class Option: @@ -126,7 +126,7 @@ def __contains__(self, line: object) -> bool: """The type of a new item of option list content to be added to an option list. This type represents all of the types that will be accepted when adding new -content to the option list. This is a superset of `OptionListContent`. +content to the option list. This is a superset of [`OptionListContent`][textual.types.OptionListContent]. """ @@ -540,10 +540,6 @@ def _refresh_content_tracking(self, force: bool = False) -> None: if content.id is not None: # The option has an ID set, create a mapping from that # ID to the option so we can use it later. - if content.id in option_ids: - raise DuplicateID( - f"The option list already has an option with id '{content.id}'" - ) option_ids[content.id] = option option += 1 else: @@ -558,6 +554,30 @@ def _refresh_content_tracking(self, force: bool = False) -> None: # list, set the virtual size. self.virtual_size = Size(self.scrollable_content_region.width, len(self._lines)) + def _duplicate_id_check(self, candidate_items: list[OptionListContent]) -> None: + """Check the items to be added for any duplicates. + + Args: + candidate_items: The items that are going be added. + + Raises: + DuplicateID: If there is an attempt to use a duplicate ID. + """ + # We're only interested in options, and only those that have IDs. + new_options = [ + item + for item in candidate_items + if isinstance(item, Option) and item.id is not None + ] + # Get the set of new IDs that we're being given. + new_option_ids = {option.id for option in new_options} + # Now check for duplicates, both internally amongst the new items + # incoming, and also against all the current known IDs. + if len(new_options) != len(new_option_ids) or not new_option_ids.isdisjoint( + self._option_ids + ): + raise DuplicateID("Attempt made to add options with duplicate IDs.") + def add_options(self, items: Iterable[NewOptionListContent]) -> Self: """Add new options to the end of the option list. @@ -569,12 +589,18 @@ def add_options(self, items: Iterable[NewOptionListContent]) -> Self: Raises: DuplicateID: If there is an attempt to use a duplicate ID. + + Note: + All options are checked for duplicate IDs *before* any option is + added. A duplicate ID will cause none of the passed items to be + added to the option list. """ # Only work if we have items to add; but don't make a fuss out of # zero items to add, just carry on like nothing happened. if items: # Turn any incoming values into valid content for the list. 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 and add them to the # list of options. @@ -825,12 +851,15 @@ def get_option(self, option_id: str) -> Option: """ return self.get_option_at_index(self.get_option_index(option_id)) - def get_option_index(self, option_id): + def get_option_index(self, option_id: str) -> int: """Get the index of the option with the given ID. Args: option_id: The ID of the option to get the index of. + Returns: + The index of the item with the given ID. + Raises: OptionDoesNotExist: If no option has the given ID. """ diff --git a/src/textual/widgets/_placeholder.py b/src/textual/widgets/_placeholder.py index 21367631ea..9c9adf725c 100644 --- a/src/textual/widgets/_placeholder.py +++ b/src/textual/widgets/_placeholder.py @@ -3,19 +3,20 @@ from __future__ import annotations from itertools import cycle -from typing import Iterator +from typing import TYPE_CHECKING, Iterator from weakref import WeakKeyDictionary from rich.console import RenderableType from typing_extensions import Literal, Self -from textual.app import App - from .. import events from ..css._error_tools import friendly_list from ..reactive import Reactive, reactive from ..widget import Widget +if TYPE_CHECKING: + from textual.app import App + PlaceholderVariant = Literal["default", "size", "text"] """The different variants of placeholder.""" @@ -120,7 +121,7 @@ def __init__( while next(self._variants_cycle) != self.variant: pass - def _on_mount(self) -> None: + async def _on_compose(self, event: events.Compose) -> None: """Set the color for this placeholder.""" colors = Placeholder._COLORS.setdefault( self.app, cycle(_PLACEHOLDER_BACKGROUND_COLORS) diff --git a/src/textual/widgets/_progress_bar.py b/src/textual/widgets/_progress_bar.py index 617d390892..ec8c1b22cb 100644 --- a/src/textual/widgets/_progress_bar.py +++ b/src/textual/widgets/_progress_bar.py @@ -8,16 +8,19 @@ from rich.style import Style -from textual.geometry import clamp - +from .._types import UnusedParameter from ..app import ComposeResult, RenderResult from ..containers import Horizontal +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 +UNUSED = UnusedParameter() +"""Sentinel for method signatures.""" + class Bar(Widget, can_focus=False): """The bar portion of the progress bar.""" @@ -276,7 +279,6 @@ class ProgressBar(Widget, can_focus=False): """The total number of steps associated with this progress bar, when known. The value `None` will render an indeterminate progress bar. - Once `total` is set to a numerical value, it cannot be set back to `None`. """ percentage: reactive[float | None] = reactive[Optional[float]](None) """The percentage of progress that has been completed. @@ -398,6 +400,7 @@ def advance(self, advance: float = 1) -> None: ```py progress_bar.advance(10) # Advance 10 steps. ``` + Args: advance: Number of steps to advance progress by. """ @@ -406,30 +409,28 @@ def advance(self, advance: float = 1) -> None: def update( self, *, - total: float | None = None, - progress: float | None = None, - advance: float | None = None, + total: None | float | UnusedParameter = UNUSED, + progress: float | UnusedParameter = UNUSED, + advance: float | UnusedParameter = UNUSED, ) -> None: """Update the progress bar with the given options. - Options only affect the progress bar if they are not `None`. - Example: ```py progress_bar.update( total=200, # Set new total to 200 steps. - progress=None, # This has no effect. + progress=50, # Set the progress to 50 (out of 200). ) ``` Args: - total: New total number of steps (if not `None`). - progress: Set the progress to the given number of steps (if not `None`). - advance: Advance the progress by this number of steps (if not `None`). + total: New total number of steps. + progress: Set the progress to the given number of steps. + advance: Advance the progress by this number of steps. """ - if total is not None: + if not isinstance(total, UnusedParameter): self.total = total - if progress is not None: + if not isinstance(progress, UnusedParameter): self.progress = progress - if advance is not None: + if not isinstance(advance, UnusedParameter): self.progress += advance diff --git a/src/textual/widgets/_radio_set.py b/src/textual/widgets/_radio_set.py index 27581af68d..8223e69713 100644 --- a/src/textual/widgets/_radio_set.py +++ b/src/textual/widgets/_radio_set.py @@ -2,7 +2,8 @@ from __future__ import annotations -from typing import ClassVar, Optional +from contextlib import suppress +from typing import ClassVar, Literal, Optional import rich.repr @@ -151,9 +152,8 @@ def __init__( def _on_mount(self, _: Mount) -> None: """Perform some processing once mounted in the DOM.""" - # If there are radio buttons, select the first one. - if self._nodes: - self._selected = 0 + # If there are radio buttons, select the first available one. + self.action_next_button() # Get all the buttons within us; we'll be doing a couple of things # with that list. @@ -248,24 +248,58 @@ def action_previous_button(self) -> None: Note that this will wrap around to the end if at the start. """ - if self._nodes: - if self._selected == 0: - self._selected = len(self.children) - 1 - elif self._selected is None: - self._selected = 0 - else: - self._selected -= 1 + self._move_selected_button(-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. """ - if self._nodes: - if self._selected is None or self._selected == len(self._nodes) - 1: - self._selected = 0 - else: - self._selected += 1 + 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 + ) + + if self._selected is None: + with suppress(StopIteration): + self._selected = next(candidate_indices) + else: + selected = self._selected + + def distance(index: int) -> int: + """Induce a distance between the given index and the selected button. + + Args: + index: The index of the button to consider. + + Returns: + The distance between the two buttons. + """ + return direction * (index - selected) % len(self.children) + + self._selected = min(candidate_indices, key=distance, default=selected) def action_toggle(self) -> None: """Toggle the state of the currently-selected button.""" diff --git a/src/textual/widgets/_select.py b/src/textual/widgets/_select.py index 508da0487d..93d82379b1 100644 --- a/src/textual/widgets/_select.py +++ b/src/textual/widgets/_select.py @@ -1,13 +1,12 @@ from __future__ import annotations from dataclasses import dataclass -from typing import TYPE_CHECKING, Generic, Iterable, Optional, TypeVar +from typing import TYPE_CHECKING, Generic, Iterable, TypeVar, Union from rich.console import RenderableType from rich.text import Text from .. import events, on -from ..app import ComposeResult from ..containers import Horizontal, Vertical from ..css.query import NoMatches from ..message import Message @@ -18,6 +17,26 @@ if TYPE_CHECKING: from typing_extensions import TypeAlias + from ..app import ComposeResult + + +class NoSelection: + """Used by the `Select` widget to flag the unselected state. See [`Select.BLANK`][textual.widgets.Select.BLANK].""" + + def __repr__(self) -> str: + return "Select.BLANK" + + +BLANK = NoSelection() + + +class InvalidSelectValueError(Exception): + """Raised when setting a [`Select`][textual.widgets.Select] to an unknown option.""" + + +class EmptySelectError(Exception): + """Raised when a [`Select`][textual.widgets.Select] has no options and `allow_blank=False`.""" + class SelectOverlay(OptionList): """The 'pop-up' overlay for the Select control.""" @@ -88,31 +107,26 @@ class SelectCurrent(Horizontal): width: 100%; height: auto; padding: 0 2; - } - SelectCurrent Static#label { - width: 1fr; - height: auto; - color: $text-disabled; - background: transparent; - } - SelectCurrent.-has-value Static#label { - color: $text; - } - SelectCurrent .arrow { - box-sizing: content-box; - width: 1; - height: 1; - padding: 0 0 0 1; - color: $text-muted; - background: transparent; - } - SelectCurrent .arrow { - box-sizing: content-box; - width: 1; - height: 1; - padding: 0 0 0 1; - color: $text-muted; - background: transparent; + + Static#label { + width: 1fr; + height: auto; + color: $text-disabled; + background: transparent; + } + + &.-has-value Static#label { + color: $text; + } + + .arrow { + box-sizing: content-box; + width: 1; + height: 1; + padding: 0 0 0 1; + color: $text-muted; + background: transparent; + } } """ @@ -130,18 +144,18 @@ def __init__(self, placeholder: str) -> None: """ super().__init__() self.placeholder = placeholder - self.label: RenderableType | None = None + self.label: RenderableType | NoSelection = Select.BLANK - def update(self, label: RenderableType | None) -> None: + def update(self, label: RenderableType | NoSelection) -> None: """Update the content in the widget. Args: label: A renderable to display, or `None` for the placeholder. """ self.label = label - self.has_value = label is not None + self.has_value = label is not Select.BLANK self.query_one("#label", Static).update( - self.placeholder if label is None else label + self.placeholder if isinstance(label, NoSelection) else label ) def compose(self) -> ComposeResult: @@ -170,9 +184,11 @@ class Select(Generic[SelectType], Vertical, can_focus=True): A Select displays the current selection. When activated with ++enter++ the widget displays an overlay with a list of all possible options. - """ + BLANK = BLANK + """Constant to flag that the widget has no selection.""" + BINDINGS = [("enter,down,space,up", "show_overlay")] """ | Key(s) | Description | @@ -183,48 +199,54 @@ class Select(Generic[SelectType], Vertical, can_focus=True): DEFAULT_CSS = """ Select { height: auto; - } - Select:focus > SelectCurrent { - border: tall $accent; + & > SelectOverlay { + width: 1fr; + display: none; + height: auto; + max-height: 12; + overlay: screen; + constrain: y; + } + + &:focus > SelectCurrent { + border: tall $accent; + } + + .up-arrow { + display: none; + } + + &.-expanded .down-arrow { + display: none; + } + + &.-expanded .up-arrow { + display: block; + } + + &.-expanded > SelectOverlay { + display: block; + } + + &.-expanded > SelectCurrent { + border: tall $accent; + } } - Select > SelectOverlay { - width: 1fr; - display: none; - height: auto; - max-height: 10; - overlay: screen; - constrain: y; - } - - Select .up-arrow { - display:none; - } - - Select.-expanded .down-arrow { - display:none; - } - - Select.-expanded .up-arrow { - display: block; - } - - Select.-expanded > SelectOverlay { - display: block; - } - - Select.-expanded > SelectCurrent { - border: tall $accent; - } """ expanded: var[bool] = var(False, init=False) """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 | None] = var[Optional[SelectType]](None) - """The value of the select.""" + value: var[SelectType | NoSelection] = var[Union[SelectType, NoSelection]](BLANK) + """The value of the selection. + + If the widget has no selection, its value will be [`Select.BLANK`][textual.widgets.Select.BLANK]. + Setting this to an illegal value will raise a [`InvalidSelectValueError`][textual.widgets.select.InvalidSelectValueError] + exception. + """ class Changed(Message): """Posted when the select value was changed. @@ -232,7 +254,9 @@ class Changed(Message): This message can be handled using a `on_select_changed` method. """ - def __init__(self, select: Select, value: SelectType | None) -> None: + def __init__( + self, select: Select[SelectType], value: SelectType | NoSelection + ) -> None: """ Initialize the Changed message. """ @@ -243,56 +267,124 @@ def __init__(self, select: Select, value: SelectType | None) -> None: """The value of the Select when it changed.""" @property - def control(self) -> Select: + def control(self) -> Select[SelectType]: """The Select that sent the message.""" return self.select def __init__( self, - options: Iterable[tuple[str, SelectType]], + options: Iterable[tuple[RenderableType, SelectType]], *, prompt: str = "Select", allow_blank: bool = True, - value: SelectType | None = None, + value: SelectType | NoSelection = BLANK, name: str | None = None, id: str | None = None, classes: str | None = None, disabled: bool = False, ): - """Initialize the Select control + """Initialize the Select control. Args: - options: Options to select from. - prompt: Text to show in the control when no option is select. - allow_blank: Allow the selection of a blank option. - value: Initial value (should be one of the values in `options`). + options: Options to select from. If no options are provided then + `allow_blank` must be set to `True`. + prompt: Text to show in the control when no option is selected. + allow_blank: Enables or disables the ability to have the widget in a state + with no selection made, in which case its value is set to the constant + [`Select.BLANK`][textual.widgets.Select.BLANK]. + value: Initial value selected. Should be one of the values in `options`. + If no initial value is set and `allow_blank` is `False`, the widget + will auto-select the first available option. name: The name of the select control. - id: The ID of the control the DOM. + id: The ID of the control in the DOM. classes: The CSS classes of the control. disabled: Whether the control is disabled or not. + + Raises: + EmptySelectError: If no options are provided and `allow_blank` is `False`. """ super().__init__(name=name, id=id, classes=classes, disabled=disabled) self._allow_blank = allow_blank self.prompt = prompt - self._initial_options = list(options) - self._value: SelectType | None = value - self._options = options + self._value = value + self._setup_variables_for_options(options) - def set_options(self, options: Iterable[tuple[RenderableType, SelectType]]) -> None: - """Set the options for the Select. + @classmethod + def from_values( + cls, + values: Iterable[SelectType], + *, + prompt: str = "Select", + allow_blank: bool = True, + value: SelectType | NoSelection = BLANK, + name: str | None = None, + id: str | None = None, + classes: str | None = None, + disabled: bool = False, + ) -> Select[SelectType]: + """Initialize the Select control with values specified by an arbitrary iterable + + The options shown in the control are computed by calling the built-in `str` + on each value. Args: - options: An iterable of tuples containing (STRING, VALUE). + values: Values used to generate options to select from. + prompt: Text to show in the control when no option is selected. + allow_blank: Enables or disables the ability to have the widget in a state + with no selection made, in which case its value is set to the constant + [`Select.BLANK`][textual.widgets.Select.BLANK]. + value: Initial value selected. Should be one of the values in `values`. + If no initial value is set and `allow_blank` is `False`, the widget + will auto-select the first available value. + name: The name of the select control. + id: The ID of the control in the DOM. + classes: The CSS classes of the control. + disabled: Whether the control is disabled or not. + + Returns: + A new Select widget with the provided values as options. """ - self._options: list[tuple[RenderableType, SelectType | None]] = list(options) + options_iterator = [(str(value), value) for value in values] + + return cls( + options_iterator, + prompt=prompt, + allow_blank=allow_blank, + value=value, + name=name, + id=id, + classes=classes, + disabled=disabled, + ) + def _setup_variables_for_options( + self, + options: Iterable[tuple[RenderableType, SelectType]], + ) -> None: + """Setup function for the auxiliary variables related to options. + + This method sets up `self._options` and `self._legal_values`. + """ + self._options: list[tuple[RenderableType, SelectType | NoSelection]] = [] if self._allow_blank: - self._options.insert(0, ("", None)) + self._options.append(("", self.BLANK)) + self._options.extend(options) + + if not self._options: + raise EmptySelectError( + "Select options cannot be empty if selection can't be blank." + ) + + self._legal_values: set[SelectType | NoSelection] = { + value for _, value in self._options + } + def _setup_options_renderables(self) -> None: + """Sets up the `Option` renderables associated with the `Select` options.""" self._select_options: list[Option] = [ ( Option(Text(self.prompt, style="dim")) - if value is None + if value == self.BLANK else Option(prompt) ) for prompt, value in self._options @@ -303,7 +395,54 @@ def set_options(self, options: Iterable[tuple[RenderableType, SelectType]]) -> N for option in self._select_options: option_list.add_option(option) - def _watch_value(self, value: SelectType | None) -> None: + def _init_selected_option(self, hint: SelectType | NoSelection = BLANK) -> None: + """Initialises the selected option for the `Select`.""" + if hint == self.BLANK and not self._allow_blank: + hint = self._options[0][1] + self.value = hint + + def set_options(self, options: Iterable[tuple[RenderableType, SelectType]]) -> None: + """Set the options for the Select. + + This will reset the selection. The selection will be empty, if allowed, otherwise + the first valid option is picked. + + Args: + options: An iterable of tuples containing the renderable to display for each + option and the corresponding internal value. + + Raises: + EmptySelectError: If the options iterable is empty and `allow_blank` is + `False`. + """ + self._setup_variables_for_options(options) + self._setup_options_renderables() + self._init_selected_option() + + def _validate_value( + self, value: SelectType | NoSelection + ) -> SelectType | NoSelection: + """Ensure the new value is a valid option. + + If `allow_blank` is `True`, `None` is also a valid value and corresponds to no + selection. + + Raises: + InvalidSelectValueError: If the new value does not correspond to any known + value. + """ + if value not in self._legal_values: + # It would make sense to use `None` to flag that the Select has no selection, + # so we provide a helpful message to catch this mistake in case people didn't + # realise we use a special value to flag "no selection". + help_text = " Did you mean to use Select.clear()?" if value is None else "" + raise InvalidSelectValueError( + f"Illegal select value {value!r}." + help_text + ) + + return value + + def _watch_value(self, value: SelectType | NoSelection) -> None: """Update the current value when it changes.""" self._value = value try: @@ -311,17 +450,15 @@ def _watch_value(self, value: SelectType | None) -> None: except NoMatches: pass else: - if value is None: - self.query_one(SelectCurrent).update(None) + if value == self.BLANK: + select_current.update(self.BLANK) else: for index, (prompt, _value) in enumerate(self._options): if _value == value: select_overlay = self.query_one(SelectOverlay) select_overlay.highlighted = index - self.query_one(SelectCurrent).update(prompt) + select_current.update(prompt) break - else: - self.query_one(SelectCurrent).update(None) def compose(self) -> ComposeResult: """Compose Select with overlay and current value.""" @@ -330,8 +467,8 @@ def compose(self) -> ComposeResult: def _on_mount(self, _event: events.Mount) -> None: """Set initial values.""" - self.set_options(self._initial_options) - self.value = self._value + self._setup_options_renderables() + self._init_selected_option(self._value) def _watch_expanded(self, expanded: bool) -> None: """Display or hide overlay.""" @@ -339,7 +476,7 @@ def _watch_expanded(self, expanded: bool) -> None: self.set_class(expanded, "-expanded") if expanded: overlay.focus() - if self.value is None: + if self.value is self.BLANK: overlay.select(None) self.query_one(SelectCurrent).has_value = False else: @@ -370,7 +507,9 @@ def _update_selection(self, event: SelectOverlay.UpdateSelection) -> None: """Update the current selection.""" event.stop() value = self._options[event.option_index][1] - self.value = value + if value != self.value: + self.value = value + self.post_message(self.Changed(self, value)) async def update_focus() -> None: """Update focus and reset overlay.""" @@ -378,10 +517,42 @@ async def update_focus() -> None: self.expanded = False self.call_after_refresh(update_focus) # Prevents a little flicker - self.post_message(self.Changed(self, value)) def action_show_overlay(self) -> None: """Show the overlay.""" select_current = self.query_one(SelectCurrent) select_current.has_value = True self.expanded = True + + def is_blank(self) -> bool: + """Indicates whether this `Select` is blank or not. + + Returns: + True if the selection is blank, False otherwise. + """ + return self.value == self.BLANK + + def clear(self) -> None: + """Clear the selection if `allow_blank` is `True`. + + Raises: + InvalidSelectValueError: If `allow_blank` is set to `False`. + """ + try: + self.value = self.BLANK + except InvalidSelectValueError: + raise InvalidSelectValueError( + "Can't clear selection if allow_blank is set to False." + ) from None + + def _watch_prompt(self, prompt: str) -> None: + if not self.is_mounted: + return + select_current = self.query_one(SelectCurrent) + select_current.placeholder = prompt + if not self._allow_blank: + return + if self.value == self.BLANK: + select_current.update(self.BLANK) + option_list = self.query_one(SelectOverlay) + option_list.replace_option_prompt_at_index(0, Text(prompt, style="dim")) diff --git a/src/textual/widgets/_selection_list.py b/src/textual/widgets/_selection_list.py index a448c5e412..f8dece7142 100644 --- a/src/textual/widgets/_selection_list.py +++ b/src/textual/widgets/_selection_list.py @@ -97,15 +97,15 @@ class SelectionList(Generic[SelectionType], OptionList): height: auto; } - .-light-mode SelectionList:focus > .selection-list--button-selected { + SelectionList:light:focus > .selection-list--button-selected { color: $primary; } - .-light-mode SelectionList > .selection-list--button-selected-highlighted { + SelectionList:light > .selection-list--button-selected-highlighted { color: $primary; } - .-light-mode SelectionList:focus > .selection-list--button-selected-highlighted { + SelectionList:light:focus > .selection-list--button-selected-highlighted { color: $primary; } diff --git a/src/textual/widgets/_tabbed_content.py b/src/textual/widgets/_tabbed_content.py index 3dafb5579f..47fa6865c0 100644 --- a/src/textual/widgets/_tabbed_content.py +++ b/src/textual/widgets/_tabbed_content.py @@ -3,17 +3,17 @@ from asyncio import gather from dataclasses import dataclass from itertools import zip_longest -from typing import Generator from rich.repr import Result from rich.text import Text, TextType +from typing_extensions import Final from ..app import ComposeResult -from ..await_remove import AwaitRemove +from ..await_complete import AwaitComplete from ..css.query import NoMatches from ..message import Message from ..reactive import reactive -from ..widget import AwaitMount, Widget +from ..widget import Widget from ._content_switcher import ContentSwitcher from ._tabs import Tab, Tabs @@ -27,7 +27,38 @@ class ContentTab(Tab): """A Tab with an associated content id.""" - def __init__(self, label: Text, content_id: str, disabled: bool = False): + _PREFIX: Final[str] = "--content-tab-" + """The prefix given to the tab IDs.""" + + @classmethod + def add_prefix(cls, content_id: str) -> str: + """Add the prefix to the given ID. + + Args: + content_id: The ID to add the prefix to. + + Returns: + The ID with the prefix added. + """ + return f"{cls._PREFIX}{content_id}" if content_id else content_id + + @classmethod + def sans_prefix(cls, content_id: str) -> str: + """Remove the prefix from the given ID. + + Args: + content_id: The ID to remove the prefix from. + + Returns: + The ID with the prefix removed. + """ + return ( + content_id[len(cls._PREFIX) :] + if content_id.startswith(cls._PREFIX) + else content_id + ) + + def __init__(self, label: Text, content_id: str, disabled: bool = False) -> None: """Initialize a ContentTab. Args: @@ -35,7 +66,96 @@ def __init__(self, label: Text, content_id: str, disabled: bool = False): content_id: The id of the content associated with the tab. disabled: Is the tab disabled? """ - super().__init__(label, id=content_id, disabled=disabled) + super().__init__(label, id=self.add_prefix(content_id), disabled=disabled) + + +class ContentTabs(Tabs): + """A Tabs which is associated with a TabbedContent.""" + + def __init__( + self, + *tabs: Tab | TextType, + active: str | None = None, + tabbed_content: TabbedContent, + ): + """Initialize a ContentTabs. + + Args: + *tabs: The child tabs. + active: ID of the tab which should be active on start. + tabbed_content: The associated TabbedContent instance. + """ + super().__init__( + *tabs, active=active if active is None else ContentTab.add_prefix(active) + ) + self.tabbed_content = tabbed_content + + def get_content_tab(self, tab_id: str) -> ContentTab: + """Get the `ContentTab` associated with the given `TabPane` ID. + + Args: + tab_id: The ID of the tab to get. + + Returns: + The tab associated with that ID. + """ + return self.query_one(f"#{ContentTab.add_prefix(tab_id)}", ContentTab) + + def disable(self, tab_id: str) -> Tab: + """Disable the indicated tab. + + Args: + tab_id: The ID of the [`Tab`][textual.widgets.Tab] to disable. + + Returns: + The [`Tab`][textual.widgets.Tab] that was targeted. + + Raises: + TabError: If there are any issues with the request. + """ + return super().disable(ContentTab.add_prefix(tab_id)) + + def enable(self, tab_id: str) -> Tab: + """Enable the indicated tab. + + Args: + tab_id: The ID of the [`Tab`][textual.widgets.Tab] to enable. + + Returns: + The [`Tab`][textual.widgets.Tab] that was targeted. + + Raises: + TabError: If there are any issues with the request. + """ + return super().enable(ContentTab.add_prefix(tab_id)) + + def hide(self, tab_id: str) -> Tab: + """Hide the indicated tab. + + Args: + tab_id: The ID of the [`Tab`][textual.widgets.Tab] to hide. + + Returns: + The [`Tab`][textual.widgets.Tab] that was targeted. + + Raises: + TabError: If there are any issues with the request. + """ + return super().hide(ContentTab.add_prefix(tab_id)) + + def show(self, tab_id: str) -> Tab: + """Show the indicated tab. + + Args: + tab_id: The ID of the [`Tab`][textual.widgets.Tab] to show. + + Returns: + The [`Tab`][textual.widgets.Tab] that was targeted. + + Raises: + TabError: If there are any issues with the request. + """ + return super().show(ContentTab.add_prefix(tab_id)) class TabPane(Widget): @@ -104,25 +224,6 @@ def _watch_disabled(self, disabled: bool) -> None: self.post_message(self.Disabled(self) if disabled else self.Enabled(self)) -class AwaitTabbedContent: - """An awaitable returned by [`TabbedContent`][textual.widgets.TabbedContent] methods that modify the tabs.""" - - def __init__(self, *awaitables: AwaitMount | AwaitRemove) -> None: - """Initialise the awaitable. - - Args: - *awaitables: The collection of awaitables to await. - """ - super().__init__() - self._awaitables = awaitables - - def __await__(self) -> Generator[None, None, None]: - async def await_tabbed_content() -> None: - await gather(*self._awaitables) - - return await_tabbed_content().__await__() - - class TabbedContent(Widget): """A container with associated tabs to toggle content visibility.""" @@ -142,10 +243,10 @@ class TabbedContent(Widget): class TabActivated(Message): """Posted when the active tab changes.""" - ALLOW_SELECTOR_MATCH = {"tab"} + ALLOW_SELECTOR_MATCH = {"pane"} """Additional message attributes that can be used with the [`on` decorator][textual.on].""" - def __init__(self, tabbed_content: TabbedContent, tab: Tab) -> None: + def __init__(self, tabbed_content: TabbedContent, tab: ContentTab) -> None: """Initialize message. Args: @@ -156,6 +257,8 @@ def __init__(self, tabbed_content: TabbedContent, tab: Tab) -> None: """The `TabbedContent` widget that contains the tab activated.""" self.tab = tab """The `Tab` widget that was selected (contains the tab label).""" + self.pane = tabbed_content.get_pane(tab) + """The `TabPane` widget that was activated by selecting the tab.""" super().__init__() @property @@ -170,6 +273,7 @@ def control(self) -> TabbedContent: def __rich_repr__(self) -> Result: yield self.tabbed_content yield self.tab + yield self.pane class Cleared(Message): """Posted when there are no more tab panes.""" @@ -265,11 +369,21 @@ def compose(self) -> ComposeResult: ] # Get a tab for each pane tabs = [ - ContentTab(content._title, content.id or "", disabled=content.disabled) + ContentTab( + content._title, + content.id or "", + disabled=content.disabled, + ) for content in pane_content ] - # Yield the tabs - yield Tabs(*tabs, active=self._initial or None) + + # Yield the tabs, and ensure they're linked to this TabbedContent. + # It's important to associate the Tabs with the TabbedContent, so that this + # TabbedContent can determine whether a message received from a Tabs instance + # has been sent from this Tabs, or from a Tabs that may exist as a descendant + # deeper in the DOM. + yield ContentTabs(*tabs, active=self._initial or None, tabbed_content=self) + # Yield the content switcher and panes with ContentSwitcher(initial=self._initial or None): yield from pane_content @@ -280,7 +394,7 @@ def add_pane( *, before: TabPane | str | None = None, after: TabPane | str | None = None, - ) -> AwaitTabbedContent: + ) -> AwaitComplete: """Add a new pane to the tabbed content. Args: @@ -289,40 +403,49 @@ def add_pane( after: Optional pane or pane ID to add the pane after. Returns: - An awaitable object that waits for the pane to be added. + An optionally awaitable object that waits for the pane to be added. Raises: Tabs.TabError: If there is a problem with the addition request. Note: Only one of `before` or `after` can be provided. If both are - provided a `Tabs.TabError` will be raised. + provided an exception is raised. """ if isinstance(before, TabPane): before = before.id if isinstance(after, TabPane): after = after.id - tabs = self.get_child_by_type(Tabs) + tabs = self.get_child_by_type(ContentTabs) pane = self._set_id(pane, tabs.tab_count + 1) assert pane.id is not None pane.display = False - return AwaitTabbedContent( - tabs.add_tab(ContentTab(pane._title, pane.id), before=before, after=after), + return AwaitComplete( + tabs.add_tab( + ContentTab(pane._title, pane.id), + before=before if before is None else ContentTab.add_prefix(before), + after=after if after is None else ContentTab.add_prefix(after), + ), self.get_child_by_type(ContentSwitcher).mount(pane), ) - def remove_pane(self, pane_id: str) -> AwaitTabbedContent: + def remove_pane(self, pane_id: str) -> AwaitComplete: """Remove a given pane from the tabbed content. Args: pane_id: The ID of the pane to remove. Returns: - An awaitable object that waits for the pane to be removed. + An optionally awaitable object that waits for the pane to be removed + and the Cleared message to be posted. """ - removals = [self.get_child_by_type(Tabs).remove_tab(pane_id)] + removal_awaitables = [ + self.get_child_by_type(ContentTabs).remove_tab( + ContentTab.add_prefix(pane_id) + ) + ] try: - removals.append( + removal_awaitables.append( self.get_child_by_type(ContentSwitcher) .get_child_by_id(pane_id) .remove() @@ -331,26 +454,27 @@ def remove_pane(self, pane_id: str) -> AwaitTabbedContent: # It's possible that the content itself may have gone away via # other means; so allow that to be a no-op. pass - await_remove = AwaitTabbedContent(*removals) async def _remove_content(cleared_message: TabbedContent.Cleared) -> None: - await await_remove + await gather(*removal_awaitables) if self.tab_count == 0: self.post_message(cleared_message) - # Note that I create the message out here, rather than in + # 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 - self.call_after_refresh(_remove_content, self.Cleared(self)) + return AwaitComplete(_remove_content(self.Cleared(self))) - return await_remove + def clear_panes(self) -> AwaitComplete: + """Remove all the panes in the tabbed content. - def clear_panes(self) -> AwaitTabbedContent: - """Remove all the panes in the tabbed content.""" - await_clear = AwaitTabbedContent( - self.get_child_by_type(Tabs).clear(), + Returns: + An optionally awaitable object which waits for all panes to be removed + and the Cleared message to be posted. + """ + await_clear = gather( + self.get_child_by_type(ContentTabs).clear(), self.get_child_by_type(ContentSwitcher).remove_children(), ) @@ -358,14 +482,11 @@ async def _clear_content(cleared_message: TabbedContent.Cleared) -> None: await await_clear self.post_message(cleared_message) - # Note that I create the message out here, rather than in + # 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 - self.call_after_refresh(_clear_content, self.Cleared(self)) - - return await_clear + return AwaitComplete(_clear_content(self.Cleared(self))) def compose_add_child(self, widget: Widget) -> None: """When using the context manager compose syntax, we want to attach nodes to the switcher. @@ -377,35 +498,97 @@ def compose_add_child(self, widget: Widget) -> None: def _on_tabs_tab_activated(self, event: Tabs.TabActivated) -> None: """User clicked a tab.""" - assert isinstance(event.tab, ContentTab) - assert isinstance(event.tab.id, str) - event.stop() - switcher = self.get_child_by_type(ContentSwitcher) - switcher.current = event.tab.id - self.active = event.tab.id - self.post_message( - TabbedContent.TabActivated( - tabbed_content=self, - tab=event.tab, + if self._is_associated_tabs(event.tabs): + # The message is relevant, so consume it and update state accordingly. + event.stop() + switcher = self.get_child_by_type(ContentSwitcher) + switcher.current = ContentTab.sans_prefix(event.tab.id) + self.active = ContentTab.sans_prefix(event.tab.id) + self.post_message( + TabbedContent.TabActivated( + tabbed_content=self, + tab=event.tab, + ) ) - ) def _on_tabs_cleared(self, event: Tabs.Cleared) -> None: - """All tabs were removed.""" - event.stop() - self.get_child_by_type(ContentSwitcher).current = None - self.active = "" + """Called when there are no active tabs. The tabs may have been cleared, + or they may all be hidden.""" + if self._is_associated_tabs(event.tabs): + event.stop() + self.get_child_by_type(ContentSwitcher).current = None + self.active = "" + + def _is_associated_tabs(self, tabs: Tabs) -> bool: + """Determine whether a tab is associated with this TabbedContent or not. + + A tab is "associated" with a `TabbedContent`, if it's one of the tabs that can + be used to control it. These have a special type: `ContentTab`, and are linked + back to this `TabbedContent` instance via a `tabbed_content` attribute. + + Args: + tabs: The Tabs instance to check. + + Returns: + True if the tab is associated with this `TabbedContent`. + """ + return isinstance(tabs, ContentTabs) and tabs.tabbed_content is self def _watch_active(self, active: str) -> None: """Switch tabs when the active attributes changes.""" with self.prevent(Tabs.TabActivated): - self.get_child_by_type(Tabs).active = active + self.get_child_by_type(ContentTabs).active = ContentTab.add_prefix(active) self.get_child_by_type(ContentSwitcher).current = active @property def tab_count(self) -> int: """Total number of tabs.""" - return self.get_child_by_type(Tabs).tab_count + return self.get_child_by_type(ContentTabs).tab_count + + def get_tab(self, pane_id: str | TabPane) -> Tab: + """Get the `Tab` associated with the given ID or `TabPane`. + + Args: + pane_id: The ID of the pane, or the pane itself. + + Returns: + The Tab associated with the ID. + + Raises: + ValueError: Raised if no ID was available. + """ + if target_id := (pane_id if isinstance(pane_id, str) else pane_id.id): + return self.get_child_by_type(ContentTabs).get_content_tab(target_id) + raise ValueError( + "'pane_id' must be a non-empty string or a TabPane with an id." + ) + + def get_pane(self, pane_id: str | ContentTab) -> TabPane: + """Get the `TabPane` associated with the given ID or tab. + + Args: + pane_id: The ID of the pane to get, or the Tab it is associated with. + + Returns: + The `TabPane` associated with the ID or the given tab. + + Raises: + ValueError: Raised if no ID was available. + """ + target_id: str | None = None + if isinstance(pane_id, ContentTab): + target_id = ( + pane_id.id if pane_id.id is None else ContentTab.sans_prefix(pane_id.id) + ) + else: + target_id = pane_id + if target_id: + pane = self.get_child_by_type(ContentSwitcher).get_child_by_id(target_id) + assert isinstance(pane, TabPane) + return pane + raise ValueError( + "'pane_id' must be a non-empty string or a ContentTab with an id." + ) def _on_tabs_tab_disabled(self, event: Tabs.TabDisabled) -> None: """Disable the corresponding tab pane.""" @@ -414,7 +597,7 @@ def _on_tabs_tab_disabled(self, event: Tabs.TabDisabled) -> None: try: with self.prevent(TabPane.Disabled): self.get_child_by_type(ContentSwitcher).get_child_by_id( - tab_id, expect_type=TabPane + ContentTab.sans_prefix(tab_id), expect_type=TabPane ).disabled = True except NoMatches: return @@ -422,12 +605,9 @@ def _on_tabs_tab_disabled(self, event: Tabs.TabDisabled) -> None: def _on_tab_pane_disabled(self, event: TabPane.Disabled) -> None: """Disable the corresponding tab.""" event.stop() - tab_pane_id = event.tab_pane.id or "" try: with self.prevent(Tab.Disabled): - self.get_child_by_type(Tabs).query_one( - f"Tab#{tab_pane_id}" - ).disabled = True + self.get_tab(event.tab_pane).disabled = True except NoMatches: return @@ -438,7 +618,7 @@ def _on_tabs_tab_enabled(self, event: Tabs.TabEnabled) -> None: try: with self.prevent(TabPane.Enabled): self.get_child_by_type(ContentSwitcher).get_child_by_id( - tab_id, expect_type=TabPane + ContentTab.sans_prefix(tab_id), expect_type=TabPane ).disabled = False except NoMatches: return @@ -446,12 +626,9 @@ def _on_tabs_tab_enabled(self, event: Tabs.TabEnabled) -> None: def _on_tab_pane_enabled(self, event: TabPane.Enabled) -> None: """Enable the corresponding tab.""" event.stop() - tab_pane_id = event.tab_pane.id or "" try: - with self.prevent(Tab.Enabled): - self.get_child_by_type(Tabs).query_one( - f"Tab#{tab_pane_id}" - ).disabled = False + with self.prevent(Tab.Disabled): + self.get_tab(event.tab_pane).disabled = False except NoMatches: return @@ -465,7 +642,7 @@ def disable_tab(self, tab_id: str) -> None: Tabs.TabError: If there are any issues with the request. """ - self.get_child_by_type(Tabs).disable(tab_id) + self.get_child_by_type(ContentTabs).disable(tab_id) def enable_tab(self, tab_id: str) -> None: """Enables the tab with the given ID. @@ -477,7 +654,7 @@ def enable_tab(self, tab_id: str) -> None: Tabs.TabError: If there are any issues with the request. """ - self.get_child_by_type(Tabs).enable(tab_id) + self.get_child_by_type(ContentTabs).enable(tab_id) def hide_tab(self, tab_id: str) -> None: """Hides the tab with the given ID. @@ -489,7 +666,7 @@ def hide_tab(self, tab_id: str) -> None: Tabs.TabError: If there are any issues with the request. """ - self.get_child_by_type(Tabs).hide(tab_id) + self.get_child_by_type(ContentTabs).hide(tab_id) def show_tab(self, tab_id: str) -> None: """Shows the tab with the given ID. @@ -501,4 +678,4 @@ def show_tab(self, tab_id: str) -> None: Tabs.TabError: If there are any issues with the request. """ - self.get_child_by_type(Tabs).show(tab_id) + self.get_child_by_type(ContentTabs).show(tab_id) diff --git a/src/textual/widgets/_tabs.py b/src/textual/widgets/_tabs.py index ca03433644..123ade6c13 100644 --- a/src/textual/widgets/_tabs.py +++ b/src/textual/widgets/_tabs.py @@ -1,5 +1,6 @@ from __future__ import annotations +import asyncio from dataclasses import dataclass from typing import ClassVar @@ -9,7 +10,7 @@ from .. import events from ..app import ComposeResult, RenderResult -from ..await_remove import AwaitRemove +from ..await_complete import AwaitComplete from ..binding import Binding, BindingType from ..containers import Container, Horizontal, Vertical from ..css.query import NoMatches @@ -18,7 +19,7 @@ from ..message import Message from ..reactive import reactive from ..renderables.bar import Bar -from ..widget import AwaitMount, Widget +from ..widget import Widget from ..widgets import Static @@ -264,7 +265,10 @@ class TabShown(TabMessage): """Sent when a tab is shown.""" class Cleared(Message): - """Sent when there are no active tabs.""" + """Sent when there are no active tabs. + + This can occur when Tabs are cleared, or if all tabs are hidden. + """ def __init__(self, tabs: Tabs) -> None: """Initialize the event. @@ -362,7 +366,6 @@ def _potentially_active_tabs(self) -> list[Tab]: @property def _next_active(self) -> Tab | None: """Next tab to make active if the active tab is removed.""" - active_tab = self.active_tab tabs = self._potentially_active_tabs if self.active_tab is None: return None @@ -386,7 +389,7 @@ def add_tab( *, before: Tab | str | None = None, after: Tab | str | None = None, - ) -> AwaitMount: + ) -> AwaitComplete: """Add a new tab to the end of the tab list. Args: @@ -395,7 +398,8 @@ def add_tab( after: Optional tab or tab ID to add the tab after. Returns: - An awaitable object that waits for the tab to be mounted. + An optionally awaitable object that waits for the tab to be mounted and + internal state to be fully updated to reflect the new tab. Raises: Tabs.TabError: If there is a problem with the addition request. @@ -447,17 +451,23 @@ def add_tab( async def refresh_active() -> None: """Wait for things to be mounted before highlighting.""" + await mount_await self.active = tab_widget.id or "" self._highlight_active(animate=False) self.post_message(activated_message) - self.call_after_refresh(refresh_active) + return AwaitComplete(refresh_active()) elif before or after: - self.call_after_refresh(self._highlight_active, animate=False) - return mount_await + async def refresh_active() -> None: + await mount_await + self._highlight_active(animate=False) + + return AwaitComplete(refresh_active()) + + return AwaitComplete(mount_await()) - def clear(self) -> AwaitRemove: + def clear(self) -> AwaitComplete: """Clear all the tabs. Returns: @@ -467,50 +477,50 @@ def clear(self) -> AwaitRemove: underline.highlight_start = 0 underline.highlight_end = 0 self.call_after_refresh(self.post_message, self.Cleared(self)) - return self.query("#tabs-list > Tab").remove() + self.active = "" + return AwaitComplete(self.query("#tabs-list > Tab").remove()()) - def remove_tab(self, tab_or_id: Tab | str | None) -> AwaitRemove: + def remove_tab(self, tab_or_id: Tab | str | None) -> AwaitComplete: """Remove a tab. Args: tab_or_id: The Tab to remove or its id. Returns: - An awaitable object that waits for the tab to be removed. + An optionally awaitable object that waits for the tab to be removed. """ - if tab_or_id is None: - return self.app._remove_nodes([], None) + if not tab_or_id: + return AwaitComplete(self.app._remove_nodes([], None)()) + if isinstance(tab_or_id, Tab): remove_tab = tab_or_id else: try: remove_tab = self.query_one(f"#tabs-list > #{tab_or_id}", Tab) except NoMatches: - return self.app._remove_nodes([], None) - removing_active_tab = remove_tab.has_class("-active") + return AwaitComplete(self.app._remove_nodes([], None)()) + removing_active_tab = remove_tab.has_class("-active") next_tab = self._next_active - result_message: Tabs.Cleared | Tabs.TabActivated | None = None - if removing_active_tab and next_tab is not None: - result_message = self.TabActivated(self, next_tab) - elif self.tab_count == 1: - result_message = self.Cleared(self) - remove_await = remove_tab.remove() + highlight_updated = asyncio.Event() + async def do_remove() -> None: """Perform the remove after refresh so the underline bar gets new positions.""" await remove_await - if removing_active_tab: - if next_tab is not None: - next_tab.add_class("-active") - self.call_after_refresh(self._highlight_active, animate=True) - if result_message is not None: - self.post_message(result_message) + if next_tab is None: + self.active = "" + elif removing_active_tab: + self.active = next_tab.id + next_tab.add_class("-active") + + highlight_updated.set() - self.call_after_refresh(do_remove) + async def wait_for_highlight_update() -> None: + await highlight_updated.wait() - return remove_await + return AwaitComplete(do_remove(), wait_for_highlight_update()) def validate_active(self, active: str) -> str: """Check id assigned to active attribute is a valid tab.""" @@ -554,7 +564,7 @@ def watch_active(self, previously_active: str, active: str) -> None: return self.query("#tabs-list > Tab.-active").remove_class("-active") active_tab.add_class("-active") - self.call_later(self._highlight_active, animate=previously_active != "") + self._highlight_active(animate=previously_active != "") self.post_message(self.TabActivated(self, active_tab)) else: underline = self.query_one(Underline) @@ -580,8 +590,22 @@ def _highlight_active(self, animate: bool = True) -> None: tab_region = active_tab.virtual_region.shrink(active_tab.styles.gutter) start, end = tab_region.column_span if animate: - underline.animate("highlight_start", start, duration=0.3) - underline.animate("highlight_end", end, duration=0.3) + + def animate_underline() -> None: + """Animate the underline.""" + try: + active_tab = self.query_one(f"#tabs-list > Tab.-active") + except NoMatches: + pass + else: + tab_region = active_tab.virtual_region.shrink( + 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) + + self.set_timer(0.02, lambda: self.call_after_refresh(animate_underline)) else: underline.highlight_start = start underline.highlight_end = end diff --git a/src/textual/widgets/_text_area.py b/src/textual/widgets/_text_area.py new file mode 100644 index 0000000000..f534883253 --- /dev/null +++ b/src/textual/widgets/_text_area.py @@ -0,0 +1,1930 @@ +from __future__ import annotations + +import re +from collections import defaultdict +from dataclasses import dataclass, field +from functools import lru_cache +from pathlib import Path +from typing import TYPE_CHECKING, Any, Iterable, Optional, Tuple + +from rich.style import Style +from rich.text import Text +from typing_extensions import Literal, Protocol, runtime_checkable + +from textual._text_area_theme import TextAreaTheme +from textual._tree_sitter import TREE_SITTER +from textual.color import Color +from textual.document._document import ( + Document, + DocumentBase, + EditResult, + Location, + Selection, + _utf8_encode, +) +from textual.document._languages import BUILTIN_LANGUAGES +from textual.document._syntax_aware_document import ( + SyntaxAwareDocument, + SyntaxAwareDocumentError, +) +from textual.expand_tabs import expand_tabs_inline + +if TYPE_CHECKING: + from tree_sitter import Language + +from textual import events, log +from textual._cells import cell_len, cell_width_to_column_index +from textual.binding import Binding +from textual.events import Message, MouseEvent +from textual.geometry import Offset, Region, Size, Spacing, clamp +from textual.reactive import Reactive, reactive +from textual.scroll_view import ScrollView +from textual.strip import Strip + +_OPENING_BRACKETS = {"{": "}", "[": "]", "(": ")"} +_CLOSING_BRACKETS = {v: k for k, v in _OPENING_BRACKETS.items()} +_TREE_SITTER_PATH = Path(__file__).parent / "../tree-sitter/" +_HIGHLIGHTS_PATH = _TREE_SITTER_PATH / "highlights/" + +StartColumn = int +EndColumn = Optional[int] +HighlightName = str +Highlight = Tuple[StartColumn, EndColumn, HighlightName] +"""A tuple representing a syntax highlight within one line.""" + + +class ThemeDoesNotExist(Exception): + """Raised when the user tries to use a theme which does not exist. + 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: + """A container for a language which has been registered with the TextArea. + + Attributes: + name: The name of the language. + language: The tree-sitter Language. + highlight_query: The tree-sitter highlight query corresponding to the language, as a string. + """ + + name: str + language: "Language" + highlight_query: str + + +class TextArea(ScrollView, can_focus=True): + DEFAULT_CSS = """\ +TextArea { + width: 1fr; + height: 1fr; +} +""" + + 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), + Binding("left", "cursor_left", "cursor left", show=False), + Binding("right", "cursor_right", "cursor right", show=False), + Binding("ctrl+left", "cursor_word_left", "cursor word left", show=False), + Binding("ctrl+right", "cursor_word_right", "cursor word right", show=False), + Binding("home,ctrl+a", "cursor_line_start", "cursor line start", show=False), + Binding("end,ctrl+e", "cursor_line_end", "cursor line end", show=False), + Binding("pageup", "cursor_page_up", "cursor page up", show=False), + Binding("pagedown", "cursor_page_down", "cursor page down", show=False), + # Making selections (generally holding the shift key and moving cursor) + Binding( + "ctrl+shift+left", + "cursor_word_left(True)", + "cursor left word select", + show=False, + ), + Binding( + "ctrl+shift+right", + "cursor_word_right(True)", + "cursor right word select", + show=False, + ), + Binding( + "shift+home", + "cursor_line_start(True)", + "cursor line start select", + show=False, + ), + Binding( + "shift+end", "cursor_line_end(True)", "cursor line end select", show=False + ), + Binding("shift+up", "cursor_up(True)", "cursor up select", show=False), + Binding("shift+down", "cursor_down(True)", "cursor down select", show=False), + Binding("shift+left", "cursor_left(True)", "cursor left select", show=False), + Binding("shift+right", "cursor_right(True)", "cursor right select", show=False), + # Shortcut ways of making selections + # Binding("f5", "select_word", "select word", show=False), + Binding("f6", "select_line", "select line", show=False), + Binding("f7", "select_all", "select all", show=False), + # Deletion + Binding("backspace", "delete_left", "delete left", show=False), + Binding( + "ctrl+w", "delete_word_left", "delete left to start of word", show=False + ), + Binding("delete,ctrl+d", "delete_right", "delete right", show=False), + Binding( + "ctrl+f", "delete_word_right", "delete right to start of word", show=False + ), + Binding("ctrl+x", "delete_line", "delete line", show=False), + 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), + ] + """ + | Key(s) | Description | + | :- | :- | + | escape | Focus on the next item. | + | up | Move the cursor up. | + | down | Move the cursor down. | + | left | Move the cursor left. | + | ctrl+left | Move the cursor to the start of the word. | + | ctrl+shift+left | Move the cursor to the start of the word and select. | + | right | Move the cursor right. | + | ctrl+right | Move the cursor to the end of the word. | + | ctrl+shift+right | Move the cursor to the end of the word and select. | + | home,ctrl+a | Move the cursor to the start of the line. | + | end,ctrl+e | Move the cursor to the end of the line. | + | shift+home | Move the cursor to the start of the line and select. | + | shift+end | Move the cursor to the end of the line and select. | + | pageup | Move the cursor one page up. | + | pagedown | Move the cursor one page down. | + | shift+up | Select while moving the cursor up. | + | shift+down | Select while moving the cursor down. | + | shift+left | Select while moving the cursor left. | + | shift+right | Select while moving the cursor right. | + | backspace | Delete character to the left of cursor. | + | ctrl+w | Delete from cursor to start of the word. | + | delete,ctrl+d | Delete character to the right of cursor. | + | ctrl+f | Delete from cursor to end of the word. | + | ctrl+x | Delete the current line. | + | ctrl+u | Delete from cursor to the start of the line. | + | ctrl+k | Delete from cursor to the end of the line. | + | f6 | Select the current line. | + | f7 | Select all text in the document. | + """ + + language: Reactive[str | None] = reactive(None, always_update=True, init=False) + """The language to use. + + This must be set to a valid, non-None value for syntax highlighting to work. + + If the value is a string, a built-in language parser will be used if available. + + If you wish to use an unsupported language, you'll have to register + it first using [`TextArea.register_language`][textual.widgets._text_area.TextArea.register_language]. + """ + + theme: Reactive[str | None] = reactive(None, 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. + + Syntax highlighting is only possible when the `language` attribute is set. + """ + + selection: Reactive[Selection] = reactive( + Selection(), always_update=True, init=False + ) + """The selection start and end locations (zero-based line_index, offset). + + This represents the cursor location and the current selection. + + The `Selection.end` always refers to the cursor location. + + If no text is selected, then `Selection.end == Selection.start` is True. + + The text selected in the document is available via the `TextArea.selected_text` property. + """ + + show_line_numbers: Reactive[bool] = reactive(True) + """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) + """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) + """If the cursor is at a bracket, highlight the matching bracket (if found).""" + + cursor_blink: Reactive[bool] = reactive(True) + """True if the cursor should blink.""" + + _cursor_blink_visible: Reactive[bool] = reactive(True, repaint=False) + """Indicates where the cursor is in the blink cycle. If it's currently + not visible due to blinking, this is False.""" + + @dataclass + class Changed(Message): + """Posted when the content inside the TextArea changes. + + Handle this message using the `on` decorator - `@on(TextArea.Changed)` + or a method named `on_text_area_changed`. + """ + + text_area: TextArea + """The `text_area` that sent this message.""" + + @property + def control(self) -> TextArea: + """The `TextArea` that sent this message.""" + return self.text_area + + @dataclass + class SelectionChanged(Message): + """Posted when the selection changes. + + This includes when the cursor moves or when text is selected.""" + + selection: Selection + """The new selection.""" + text_area: TextArea + """The `text_area` that sent this message.""" + + @property + def control(self) -> TextArea: + return self.text_area + + def __init__( + self, + text: str = "", + *, + language: str | None = None, + theme: str | None = None, + name: str | None = None, + id: str | None = None, + classes: str | None = None, + disabled: bool = False, + ) -> None: + """Construct a new `TextArea`. + + Args: + text: The initial text to load into the TextArea. + language: The language to use. + theme: The theme to use. + 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.""" + + self._themes: dict[str, TextAreaTheme] = {} + """Maps theme names to TextAreaTheme.""" + + self.indent_type: Literal["tabs", "spaces"] = "spaces" + """Whether to indent using tabs or spaces.""" + + 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] = [] + """A stack (the end of the list is the top of the stack) for tracking edits.""" + + self._selecting = False + """True if we're currently selecting text using the mouse, otherwise False.""" + + self._matching_bracket_location: Location | None = None + """The location (row, column) of the bracket which matches the bracket the + cursor is currently at. If the cursor is at a bracket, or there's no matching + bracket, this will be `None`.""" + + 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 + """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 + """The `TextAreaTheme` corresponding to the set theme name. When the `theme` + reactive is set as a string, the watcher will update this attribute to the + corresponding `TextAreaTheme` object.""" + + self.language = language + + self.theme = theme + + @staticmethod + def _get_builtin_highlight_query(language_name: str) -> str: + """Get the highlight query for a builtin language. + + Args: + language_name: The name of the builtin language. + + Returns: + The highlight query. + """ + try: + highlight_query_path = ( + Path(_HIGHLIGHTS_PATH.resolve()) / f"{language_name}.scm" + ) + highlight_query = highlight_query_path.read_text() + except OSError as e: + log.warning(f"Unable to load highlight query. {e}") + highlight_query = "" + + return highlight_query + + def _build_highlight_map(self) -> None: + """Query the tree for ranges to highlights, and update the internal highlights mapping.""" + highlights = self._highlights + highlights.clear() + if not self._highlight_query: + return + + captures = self.document.query_syntax_tree(self._highlight_query) + for capture in captures: + node, highlight_name = capture + node_start_row, node_start_column = node.start_point + node_end_row, node_end_column = node.end_point + + if node_start_row == node_end_row: + highlight = (node_start_column, node_end_column, highlight_name) + highlights[node_start_row].append(highlight) + else: + # Add the first line of the node range + highlights[node_start_row].append( + (node_start_column, None, highlight_name) + ) + + # Add the middle lines - entire row of this node is highlighted + for node_row in range(node_start_row + 1, node_end_row): + highlights[node_row].append((0, None, highlight_name)) + + # 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: + """When the cursor moves, scroll it into view.""" + self.scroll_cursor_visible() + cursor_location = selection.end + cursor_row, cursor_column = cursor_location + + try: + character = self.document[cursor_row][cursor_column] + except IndexError: + character = "" + + # Record the location of a matching closing/opening bracket. + 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) + + self.app.cursor_position = self.cursor_screen_offset + self.post_message(self.SelectionChanged(selection, self)) + + def find_matching_bracket( + self, bracket: str, search_from: Location + ) -> Location | None: + """If the character is a bracket, find the matching bracket. + + Args: + bracket: The character we're searching for the matching bracket of. + search_from: The location to start the search. + + Returns: + The `Location` of the matching bracket, or `None` if it's not found. + If the character is not available for bracket matching, `None` is returned. + """ + match_location = None + bracket_stack = [] + if bracket in _OPENING_BRACKETS: + for candidate, candidate_location in self._yield_character_locations( + search_from + ): + if candidate in _OPENING_BRACKETS: + bracket_stack.append(candidate) + elif candidate in _CLOSING_BRACKETS: + if ( + bracket_stack + and bracket_stack[-1] == _CLOSING_BRACKETS[candidate] + ): + bracket_stack.pop() + if not bracket_stack: + match_location = candidate_location + break + elif bracket in _CLOSING_BRACKETS: + for ( + candidate, + candidate_location, + ) in self._yield_character_locations_reverse(search_from): + if candidate in _CLOSING_BRACKETS: + bracket_stack.append(candidate) + elif candidate in _OPENING_BRACKETS: + if ( + bracket_stack + and bracket_stack[-1] == _OPENING_BRACKETS[candidate] + ): + bracket_stack.pop() + if not bracket_stack: + match_location = candidate_location + break + + return match_location + + def _validate_selection(self, selection: Selection) -> Selection: + """Clamp the selection to valid locations.""" + start, end = selection + clamp_visitable = self.clamp_visitable + return Selection(clamp_visitable(start), clamp_visitable(end)) + + def _watch_language(self, language: str | None) -> None: + """When the language is updated, update the type of document.""" + if language is not None and language not in self.available_languages: + raise LanguageDoesNotExist( + f"{language!r} is not a builtin language, or it has not been registered. " + f"To use a custom language, register it first using `register_language`, " + 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 = "" + + def _watch_show_line_numbers(self) -> None: + """The line number gutter contributes to virtual size, so recalculate.""" + self._refresh_size() + + def _watch_indent_width(self) -> None: + """Changing width of tabs will change document display width.""" + self._refresh_size() + + def _watch_theme(self, theme: str | None) -> None: + """We set the styles on this widget when the theme changes, to ensure that + if padding is applied, the colours match.""" + + if theme is None: + # If the theme is None, use the default. + theme_object = TextAreaTheme.default() + else: + # If the user supplied a string theme name, find it and apply it. + try: + theme_object = self._themes[theme] + except KeyError: + theme_object = TextAreaTheme.get_builtin_theme(theme) + + if 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." + ) + + self._theme = theme_object + if theme_object: + base_style = theme_object.base_style + if base_style: + color = base_style.color + background = base_style.bgcolor + if color: + self.styles.color = Color.from_rich_color(color) + if background: + self.styles.background = Color.from_rich_color(background) + + @property + def available_themes(self) -> set[str]: + """A list of the names of the themes available to the `TextArea`. + + The values in this list can be assigned `theme` reactive attribute of + `TextArea`. + + You can retrieve the full specification for a theme by passing one of + the strings from this list into `TextAreaTheme.get_by_name(theme_name: str)`. + + Alternatively, you can directly retrieve a list of `TextAreaTheme` objects + (which contain the full theme specification) by calling + `TextAreaTheme.builtin_themes()`. + """ + return { + theme.name for theme in TextAreaTheme.builtin_themes() + } | self._themes.keys() + + def register_theme(self, theme: TextAreaTheme) -> None: + """Register a theme for use by the `TextArea`. + + After registering a theme, you can set themes by assigning the theme + name to the `TextArea.theme` reactive attribute. For example + `text_area.theme = "my_custom_theme"` where `"my_custom_theme"` is the + name of the theme you registered. + + If you supply a theme with a name that already exists that theme + will be overwritten. + """ + self._themes[theme.name] = theme + + @property + def available_languages(self) -> set[str]: + """A list of the names of languages available to the `TextArea`. + + The values in this list can be assigned to the `language` reactive attribute + of `TextArea`. + + The returned list contains the builtin languages plus those registered via the + `register_language` method. Builtin languages will be listed before + user-registered languages, but there are no other ordering guarantees. + """ + return set(BUILTIN_LANGUAGES) | self._languages.keys() + + def register_language( + self, + language: str | "Language", + highlight_query: str, + ) -> None: + """Register a language and corresponding highlight query. + + Calling this method does not change the language of the `TextArea`. + On switching to this language (via the `language` reactive attribute), + syntax highlighting will be performed using the given highlight query. + + If a string `name` is supplied for a builtin supported language, then + this method will update the default highlight query for that language. + + Registering a language only registers it to this instance of `TextArea`. + + Args: + language: A string referring to a builtin language or a tree-sitter `Language` object. + highlight_query: The highlight query to use for syntax highlighting this language. + """ + + # If tree-sitter is unavailable, do nothing. + if not TREE_SITTER: + return + + from tree_sitter_languages import get_language + + if isinstance(language, str): + language_name = language + language = get_language(language_name) + else: + language_name = language.name + + # Update the custom languages. When changing the document, + # we should first look in here for a language specification. + # If nothing is found, then we can go to the builtin languages. + self._languages[language_name] = TextAreaLanguage( + name=language_name, + language=language, + highlight_query=highlight_query, + ) + # If we updated the currently set language, rebuild the highlights + # using the newly updated highlights query. + if language_name == self.language: + self._set_document(self.text, language_name) + + def _set_document(self, text: str, language: str | None) -> None: + """Construct and return an appropriate document. + + Args: + text: The text of the document. + language: The name of the language to use. This must either be a + built-in supported language, or a language previously registered + via the `register_language` method. + """ + self._highlight_query = None + if TREE_SITTER and language: + # Attempt to get the override language. + text_area_language = self._languages.get(language, None) + document_language: str | "Language" + if text_area_language: + document_language = text_area_language.language + highlight_query = text_area_language.highlight_query + else: + document_language = language + highlight_query = self._get_builtin_highlight_query(language) + document: DocumentBase + try: + document = SyntaxAwareDocument(text, document_language) + except SyntaxAwareDocumentError: + document = Document(text) + log.warning( + f"Parser not found for language {document_language!r}. Parsing disabled." + ) + else: + self._highlight_query = document.prepare_query(highlight_query) + elif language and not TREE_SITTER: + log.warning( + "tree-sitter not available in this environment. Parsing disabled.\n" + "You may need to install the `syntax` extras alongside textual.\n" + "Try `pip install 'textual[syntax]'` or '`poetry add textual[syntax]'." + ) + document = Document(text) + else: + document = Document(text) + + self.document = document + self._build_highlight_map() + + @property + def _visible_line_indices(self) -> tuple[int, int]: + """Return the visible line indices as a tuple (top, bottom). + + Returns: + A tuple (top, bottom) indicating the top and bottom visible line indices. + """ + _, scroll_offset_y = self.scroll_offset + return scroll_offset_y, scroll_offset_y + self.size.height + + def _watch_scroll_x(self) -> None: + self.app.cursor_position = self.cursor_screen_offset + + def _watch_scroll_y(self) -> None: + self.app.cursor_position = self.cursor_screen_offset + + def load_text(self, text: str) -> None: + """Load text into the TextArea. + + This will replace the text currently in the TextArea. + + Args: + text: The text to load into the TextArea. + """ + self._set_document(text, self.language) + self.move_cursor((0, 0)) + self._refresh_size() + + def load_document(self, document: DocumentBase) -> None: + """Load a document into the TextArea. + + Args: + document: The document to load into the TextArea. + """ + self.document = document + self.move_cursor((0, 0)) + self._refresh_size() + + @property + def is_syntax_aware(self) -> bool: + """True if the TextArea is currently syntax aware - i.e. it's parsing document content.""" + return isinstance(self.document, SyntaxAwareDocument) + + def _yield_character_locations( + self, start: Location + ) -> Iterable[tuple[str, Location]]: + """Yields character locations starting from the given location. + + Does not yield location of line separator characters like `\\n`. + + Args: + start: The location to start yielding from. + + Returns: + Yields tuples of (character, (row, column)). + """ + row, column = start + document = self.document + line_count = document.line_count + + while 0 <= row < line_count: + line = document[row] + while column < len(line): + yield line[column], (row, column) + column += 1 + column = 0 + row += 1 + + def _yield_character_locations_reverse( + self, start: Location + ) -> Iterable[tuple[str, Location]]: + row, column = start + document = self.document + line_count = document.line_count + + while line_count > row >= 0: + line = document[row] + if column == -1: + column = len(line) - 1 + while column >= 0: + yield line[column], (row, column) + column -= 1 + row -= 1 + + 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) + + def render_line(self, widget_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. + + Returns: + A rendered line. + """ + document = self.document + scroll_x, scroll_y = self.scroll_offset + + # Account for how much the TextArea is scrolled. + line_index = widget_y + scroll_y + + # 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 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) + + selection = self.selection + start, end = selection + 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) + + # Selection styling + if start != end and selection_top_row <= line_index <= selection_bottom_row: + # If this row intersects with the selection range + selection_style = theme.selection_style if theme else None + cursor_row, _ = end + if selection_style: + 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 + line.stylize( + selection_style, + start=selection_top_column, + end=selection_bottom_column, + ) + else: + # Selection spanning multiple lines + if line_index == selection_top_row: + line.stylize( + selection_style, + start=selection_top_column, + end=line_character_count, + ) + elif line_index == selection_bottom_row: + line.stylize(selection_style, end=selection_bottom_column) + else: + line.stylize(selection_style, end=line_character_count) + + # Highlight the cursor + matching_bracket = self._matching_bracket_location + match_cursor_bracket = self.match_cursor_bracket + draw_matched_brackets = ( + match_cursor_bracket and matching_bracket is not None and start == end + ) + + if cursor_row == line_index: + draw_cursor = not self.cursor_blink or ( + self.cursor_blink and self._cursor_blink_visible + ) + if draw_matched_brackets: + matching_bracket_style = theme.bracket_matching_style if theme else None + if matching_bracket_style: + line.stylize( + matching_bracket_style, + cursor_column, + cursor_column + 1, + ) + + if draw_cursor: + cursor_style = theme.cursor_style if theme else None + if cursor_style: + line.stylize(cursor_style, cursor_column, cursor_column + 1) + + # Highlight the partner opening/closing bracket. + if draw_matched_brackets: + # mypy doesn't know matching bracket is guaranteed to be non-None + assert matching_bracket is not None + bracket_match_row, bracket_match_column = matching_bracket + if theme and bracket_match_row == line_index: + matching_bracket_style = theme.bracket_matching_style + if matching_bracket_style: + line.stylize( + matching_bracket_style, + bracket_match_column, + bracket_match_column + 1, + ) + + # Build the gutter text for this line + 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 + else: + gutter_style = theme.gutter_style if theme else None + + gutter_width_no_margin = gutter_width - 2 + gutter = Text( + f"{line_index + 1:>{gutter_width_no_margin}} ", + style=gutter_style or "", + end="", + ) + else: + gutter = Text("", end="") + + # Render the gutter and the text of this line + console = self.app.console + gutter_segments = console.render(gutter) + text_segments = console.render( + line, + console.options.update_width(expanded_length), + ) + + # 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 + ) + + # 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 + ) + else: + text_strip = text_strip.extend_cell_length( + expanded_length, theme.base_style if theme else None + ) + + # Join and return the gutter and the visible portion of this line + strip = Strip.join([gutter_strip, text_strip]).simplify() + + return strip.apply_style( + theme.base_style + if theme and theme.base_style is not None + else self.rich_style + ) + + @property + def text(self) -> str: + """The entire text content of the document.""" + return self.document.text + + @text.setter + def text(self, value: str) -> None: + """Replace the text currently in the TextArea. This is an alias of `load_text`. + + Args: + value: The text to load into the TextArea. + """ + self.load_text(value) + + @property + def selected_text(self) -> str: + """The text between the start and end points of the current selection.""" + start, end = self.selection + return self.get_text_range(start, end) + + def get_text_range(self, start: Location, end: Location) -> str: + """Get the text between a start and end location. + + Args: + start: The start location. + end: The end location. + + Returns: + The text between start and end. + """ + start, end = sorted((start, end)) + return self.document.get_text_range(start, end) + + def edit(self, edit: Edit) -> Any: + """Perform an Edit. + + Args: + edit: The Edit to perform. + + Returns: + Data relating to the edit that may be useful. The data returned + may be different depending on the edit performed. + """ + result = edit.do(self) + self._refresh_size() + edit.after(self) + self._build_highlight_map() + self.post_message(self.Changed(self)) + return result + + async def _on_key(self, event: events.Key) -> None: + """Handle key presses which correspond to document inserts.""" + key = event.key + insert_values = { + "tab": " " * self._find_columns_to_next_tab_stop(), + "enter": "\n", + } + self._restart_blink() + if event.is_printable or key in insert_values: + event.stop() + event.prevent_default() + insert = insert_values.get(key, event.character) + # `insert` is not None because event.character cannot be + # 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) + + 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. + + If the cursor is already at a tab stop, this returns the *next* tab stop location. + + Returns: + The number of cells to the next tab stop from the current cursor column. + """ + cursor_row, cursor_column = self.cursor_location + line_text = self.document[cursor_row] + indent_width = self.indent_width + if not line_text: + return indent_width + + width_before_cursor = self.get_column_width(cursor_row, cursor_column) + spaces_to_insert = indent_width - ( + (indent_width + width_before_cursor) % indent_width + ) + + return spaces_to_insert + + def get_target_document_location(self, event: MouseEvent) -> Location: + """Given a MouseEvent, return the row and column offset of the event in document-space. + + Args: + event: The MouseEvent. + + Returns: + The location of the mouse event within the document. + """ + 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 + + # --- Lower level event/key handling + @property + def gutter_width(self) -> int: + """The width of the gutter (the left column containing line numbers). + + Returns: + The cell-width of the line number column. If `show_line_numbers` is `False` returns 0. + """ + # The longest number in the gutter plus two extra characters: `│ `. + gutter_margin = 2 + gutter_width = ( + len(str(self.document.line_count + 1)) + gutter_margin + if self.show_line_numbers + else 0 + ) + return gutter_width + + def _on_mount(self, _: 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) + + def _restart_blink(self) -> None: + """Reset the cursor blink timer.""" + if self.cursor_blink: + self._cursor_blink_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.blink_timer.pause() + + async def _on_mouse_down(self, event: events.MouseDown) -> None: + """Update the cursor position, and begin a selection using the mouse.""" + target = self.get_target_document_location(event) + self.selection = Selection.cursor(target) + self._selecting = True + # Capture the mouse so that if the cursor moves outside the + # TextArea widget while selecting, the widget still scrolls. + self.capture_mouse() + self._pause_blink(visible=True) + + async def _on_mouse_move(self, event: events.MouseMove) -> None: + """Handles click and drag to expand and contract the selection.""" + if self._selecting: + target = self.get_target_document_location(event) + 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.""" + self._selecting = False + self.release_mouse() + self.record_cursor_width() + self._restart_blink() + + 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) + + 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. + + Args: + cell_width: The cell width to convert. + row_index: The index of the row to examine. + + Returns: + The column corresponding to the cell width on that row. + """ + line = self.document[row_index] + return cell_width_to_column_index(line, cell_width, self.indent_width) + + def clamp_visitable(self, location: Location) -> Location: + """Clamp the given location to the nearest visitable location. + + Args: + location: The location to clamp. + + Returns: + The nearest location that we could conceivably navigate to using the cursor. + """ + document = self.document + + row, column = location + try: + line_text = document[row] + except IndexError: + line_text = "" + + row = clamp(row, 0, document.line_count - 1) + column = clamp(column, 0, len(line_text)) + + return row, column + + # --- Cursor/selection utilities + def scroll_cursor_visible( + self, center: bool = False, animate: bool = False + ) -> Offset: + """Scroll the `TextArea` such that the cursor is visible on screen. + + Args: + center: True if the cursor should be scrolled to the center. + animate: True if we should animate while scrolling. + + 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)) + scroll_offset = self.scroll_to_region( + Region(x=column_offset, y=row, width=3, height=1), + spacing=Spacing(right=self.gutter_width), + animate=animate, + force=True, + center=center, + ) + return scroll_offset + + def move_cursor( + self, + location: Location, + select: bool = False, + center: bool = False, + record_width: bool = True, + ) -> None: + """Move the cursor to a location. + + Args: + location: The location to move the cursor to. + select: If True, select text between the old and new location. + center: If True, scroll such that the cursor is centered. + record_width: If True, record the cursor column cell width after navigating + so that we jump back to the same width the next time we move to a row + that is wide enough. + """ + if select: + start, end = self.selection + self.selection = Selection(start, location) + else: + self.selection = Selection.cursor(location) + + if record_width: + self.record_cursor_width() + + if center: + self.scroll_cursor_visible(center) + + def move_cursor_relative( + self, + rows: int = 0, + columns: int = 0, + select: bool = False, + center: bool = False, + record_width: bool = True, + ) -> None: + """Move the cursor relative to its current location. + + Args: + rows: The number of rows to move down by (negative to move up) + columns: The number of columns to move right by (negative to move left) + select: If True, select text between the old and new location. + center: If True, scroll such that the cursor is centered. + record_width: If True, record the cursor column cell width after navigating + so that we jump back to the same width the next time we move to a row + that is wide enough. + """ + clamp_visitable = self.clamp_visitable + 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) + + def select_line(self, index: int) -> None: + """Select all the text in the specified line. + + Args: + index: The index of the line to select (starting from 0). + """ + try: + line = self.document[index] + except IndexError: + return + else: + self.selection = Selection((index, 0), (index, len(line))) + self.record_cursor_width() + + def action_select_line(self) -> None: + """Select all the text on the current line.""" + cursor_row, _ = self.cursor_location + self.select_line(cursor_row) + + def select_all(self) -> None: + """Select all of the text in the `TextArea`.""" + last_line = self.document.line_count - 1 + length_of_last_line = len(self.document[last_line]) + selection_start = (0, 0) + selection_end = (last_line, length_of_last_line) + self.selection = Selection(selection_start, selection_end) + self.record_cursor_width() + + def action_select_all(self) -> None: + """Select all the text in the document.""" + self.select_all() + + @property + def cursor_location(self) -> Location: + """The current location of the cursor in the document. + + This is a utility for accessing the `end` of `TextArea.selection`. + """ + return self.selection.end + + @cursor_location.setter + def cursor_location(self, location: Location) -> None: + """Set the cursor_location to a new location. + + If a selection is in progress, the anchor point will remain. + """ + self.move_cursor(location, select=not self.selection.is_empty) + + @property + def cursor_screen_offset(self) -> Offset: + """The offset of the cursor relative to the screen.""" + cursor_row, cursor_column = self.cursor_location + 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 + + return Offset(offset_x, offset_y) + + @property + def cursor_at_first_line(self) -> bool: + """True if and only if the cursor is on the first line.""" + return self.selection.end[0] == 0 + + @property + def cursor_at_last_line(self) -> bool: + """True if and only if the cursor is on the last line.""" + return self.selection.end[0] == self.document.line_count - 1 + + @property + def cursor_at_start_of_line(self) -> bool: + """True if and only if the cursor is at column 0.""" + return self.selection.end[1] == 0 + + @property + def cursor_at_end_of_line(self) -> bool: + """True if and only if the cursor is at the end of a row.""" + cursor_row, cursor_column = self.selection.end + row_length = len(self.document[cursor_row]) + cursor_at_end = cursor_column == row_length + return cursor_at_end + + @property + def cursor_at_start_of_text(self) -> bool: + """True if and only if the cursor is at location (0, 0)""" + return self.selection.end == (0, 0) + + @property + def cursor_at_end_of_text(self) -> bool: + """True if and only if the cursor is at the very end of the document.""" + return self.cursor_at_last_line and self.cursor_at_end_of_line + + # ------ Cursor movement actions + def action_cursor_left(self, select: bool = False) -> None: + """Move the cursor one location to the left. + + If the cursor is at the left edge of the document, try to move it to + the end of the previous line. + + 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) + + def get_cursor_left_location(self) -> Location: + """Get the location the cursor will move to if it moves left. + + 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 + + def action_cursor_right(self, select: bool = False) -> None: + """Move the cursor one location to the right. + + If the cursor is at the end of a line, attempt to go to the start of the next line. + + Args: + select: If True, select the text while moving. + """ + target = self.get_cursor_right_location() + self.move_cursor(target, select=select) + + def get_cursor_right_location(self) -> Location: + """Get the location the cursor will move to if it moves right. + + 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 + + def action_cursor_down(self, select: bool = False) -> None: + """Move the cursor down one cell. + + Args: + select: If True, select the text while moving. + """ + target = self.get_cursor_down_location() + self.move_cursor(target, record_width=False, select=select) + + def get_cursor_down_location(self) -> Location: + """Get the location the cursor will move to if it moves down. + + 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 + + def action_cursor_up(self, select: bool = False) -> None: + """Move the cursor up one cell. + + Args: + select: If True, select the text while moving. + """ + target = self.get_cursor_up_location() + self.move_cursor(target, record_width=False, select=select) + + def get_cursor_up_location(self) -> Location: + """Get the location the cursor will move to if it moves up. + + 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 + + def action_cursor_line_end(self, select: bool = False) -> None: + """Move the cursor to the end of the line.""" + location = self.get_cursor_line_end_location() + self.move_cursor(location, select=select) + + def get_cursor_line_end_location(self) -> Location: + """Get the location of the end of the current line. + + 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 + + def action_cursor_line_start(self, select: bool = False) -> None: + """Move the cursor to the start of the line.""" + + 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: + """Get the location of the start of the current line. + + 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 + + def action_cursor_word_left(self, select: bool = False) -> None: + """Move the cursor left by a single word, skipping trailing whitespace. + + Args: + select: Whether to select while moving the cursor. + """ + if self.cursor_at_start_of_text: + return + target = self.get_cursor_word_left_location() + self.move_cursor(target, select=select) + + def get_cursor_word_left_location(self) -> Location: + """Get the location the cursor will jump to if it goes 1 word left. + + Returns: + The location the cursor will jump on "jump word left". + """ + cursor_row, cursor_column = self.cursor_location + if cursor_row > 0 and cursor_column == 0: + # Going to the previous row + return cursor_row - 1, len(self.document[cursor_row - 1]) + + # Staying on the same row + line = self.document[cursor_row][:cursor_column] + search_string = line.rstrip() + matches = list(re.finditer(self._word_pattern, search_string)) + cursor_column = matches[-1].start() if matches else 0 + return cursor_row, cursor_column + + def action_cursor_word_right(self, select: bool = False) -> None: + """Move the cursor right by a single word, skipping leading whitespace.""" + + if self.cursor_at_end_of_text: + return + + target = self.get_cursor_word_right_location() + self.move_cursor(target, select=select) + + def get_cursor_word_right_location(self) -> Location: + """Get the location the cursor will jump to if it goes 1 word right. + + Returns: + The location the cursor will jump on "jump word right". + """ + cursor_row, cursor_column = self.selection.end + line = self.document[cursor_row] + if cursor_row < self.document.line_count - 1 and cursor_column == len(line): + # Moving to the line below + return cursor_row + 1, 0 + + # Staying on the same line + search_string = line[cursor_column:] + pre_strip_length = len(search_string) + search_string = search_string.lstrip() + strip_offset = pre_strip_length - len(search_string) + + matches = list(re.finditer(self._word_pattern, search_string)) + if matches: + cursor_column += matches[0].start() + strip_offset + else: + cursor_column = len(line) + + return cursor_row, cursor_column + + 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) + self.scroll_relative(y=-height, animate=False) + self.move_cursor(target) + + 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) + self.scroll_relative(y=height, animate=False) + self.move_cursor(target) + + def get_column_width(self, row: int, column: int) -> int: + """Get the cell offset of the column from the start of the row. + + Args: + row: The row index. + column: The column index (codepoint offset from start of row). + + Returns: + The cell width of the column relative to the start of the row. + """ + line = self.document[row] + return cell_len(expand_tabs_inline(line[:column], self.indent_width)) + + def record_cursor_width(self) -> None: + """Record the current cell width of the cursor. + + This is used where we navigate up and down through rows. + If we're in the middle of a row, and go down to a row with no + 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 + + # --- Editor operations + def insert( + self, + text: str, + location: Location | None = None, + *, + maintain_selection_offset: bool = True, + ) -> EditResult: + """Insert text into the document. + + Args: + text: The text to insert. + location: The location to insert text, or None to use the cursor location. + maintain_selection_offset: If True, the active Selection will be updated + such that the same text is selected before and after the selection, + if possible. Otherwise, the cursor will jump to the end point of the + edit. + + Returns: + An `EditResult` containing information about the edit. + """ + if location is None: + location = self.cursor_location + return self.edit(Edit(text, location, location, maintain_selection_offset)) + + def delete( + self, + start: Location, + end: Location, + *, + maintain_selection_offset: bool = True, + ) -> EditResult: + """Delete the text between two locations in the document. + + Args: + start: The start location. + end: The end location. + maintain_selection_offset: If True, the active Selection will be updated + such that the same text is selected before and after the selection, + if possible. Otherwise, the cursor will jump to the end point of the + edit. + + Returns: + An `EditResult` containing information about the edit. + """ + top, bottom = sorted((start, end)) + return self.edit(Edit("", top, bottom, maintain_selection_offset)) + + def replace( + self, + insert: str, + start: Location, + end: Location, + *, + maintain_selection_offset: bool = True, + ) -> EditResult: + """Replace text in the document with new text. + + Args: + insert: The text to insert. + start: The start location + end: The end location. + maintain_selection_offset: If True, the active Selection will be updated + such that the same text is selected before and after the selection, + if possible. Otherwise, the cursor will jump to the end point of the + edit. + + Returns: + An `EditResult` containing information about the edit. + """ + return self.edit(Edit(insert, start, end, maintain_selection_offset)) + + def clear(self) -> None: + """Delete all text from the document.""" + 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) + + def action_delete_left(self) -> None: + """Deletes the character to the left of the cursor and updates the cursor location. + + If there's a selection, then the selected range is deleted.""" + + selection = self.selection + start, end = selection + + if selection.is_empty: + end = self.get_cursor_left_location() + + self.delete(start, end, maintain_selection_offset=False) + + def action_delete_right(self) -> None: + """Deletes the character to the right of the cursor and keeps the cursor at the same location. + + If there's a selection, then the selected range is deleted.""" + + selection = self.selection + start, end = selection + + if selection.is_empty: + end = self.get_cursor_right_location() + + self.delete(start, end, maintain_selection_offset=False) + + 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 + end_row, end_column = end + + # Generally editors will only delete line the end line of the + # selection if the cursor is not at column 0 of that line. + if start_row != end_row and end_column == 0 and end_row >= 0: + end_row -= 1 + + from_location = (start_row, 0) + to_location = (end_row + 1, 0) + + self.delete(from_location, to_location, maintain_selection_offset=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) + + 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) + + def action_delete_word_left(self) -> None: + """Deletes the word to the left of the cursor and updates the cursor location.""" + if self.cursor_at_start_of_text: + return + + # If there's a non-zero selection, then "delete word left" typically only + # 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) + return + + to_location = self.get_cursor_word_left_location() + self.delete(self.selection.end, to_location, maintain_selection_offset=False) + + def action_delete_word_right(self) -> None: + """Deletes the word to the right of the cursor and keeps the cursor at the same location. + + Note that the location that we delete to using this action is not the same + as the location we move to when we move the cursor one word to the right. + This action does not skip leading whitespace, whereas cursor movement does. + """ + if self.cursor_at_end_of_text: + return + + start, end = self.selection + if start != end: + self.delete(start, end, maintain_selection_offset=False) + return + + cursor_row, cursor_column = end + + # Check the current line for a word boundary + line = self.document[cursor_row][cursor_column:] + matches = list(re.finditer(self._word_pattern, line)) + + current_row_length = len(self.document[cursor_row]) + if matches: + to_location = (cursor_row, cursor_column + matches[0].end()) + elif ( + cursor_row < self.document.line_count - 1 + and cursor_column == current_row_length + ): + to_location = (cursor_row + 1, 0) + 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. + """ + + +@lru_cache(maxsize=128) +def build_byte_to_codepoint_dict(data: bytes) -> dict[int, int]: + """Build a mapping of utf-8 byte offsets to codepoint offsets for the given data. + + Args: + data: utf-8 bytes. + + Returns: + A `dict[int, int]` mapping byte indices to codepoint indices within `data`. + """ + byte_to_codepoint = {} + current_byte_offset = 0 + code_point_offset = 0 + + while current_byte_offset < len(data): + byte_to_codepoint[current_byte_offset] = code_point_offset + first_byte = data[current_byte_offset] + + # Single-byte character + if (first_byte & 0b10000000) == 0: + current_byte_offset += 1 + # 2-byte character + elif (first_byte & 0b11100000) == 0b11000000: + current_byte_offset += 2 + # 3-byte character + elif (first_byte & 0b11110000) == 0b11100000: + current_byte_offset += 3 + # 4-byte character + elif (first_byte & 0b11111000) == 0b11110000: + current_byte_offset += 4 + else: + raise ValueError(f"Invalid UTF-8 byte: {first_byte}") + + code_point_offset += 1 + + # Mapping for the end of the string + byte_to_codepoint[current_byte_offset] = code_point_offset + return byte_to_codepoint diff --git a/src/textual/widgets/_toast.py b/src/textual/widgets/_toast.py index dff62b5a5d..ab920ebe63 100644 --- a/src/textual/widgets/_toast.py +++ b/src/textual/widgets/_toast.py @@ -45,6 +45,12 @@ class Toast(Static, inherit_css=False): padding: 1 1; background: $panel; tint: white 5%; + link-background: initial; + link-color: $text; + link-style: underline; + link-background-hover: $accent; + link-color-hover: $text; + link-style-hover: bold not underline; } .toast--title { @@ -52,11 +58,11 @@ class Toast(Static, inherit_css=False): } Toast { - border-right: wide $background; + border-right: outer $background; } Toast.-information { - border-left: wide $success; + border-left: outer $success; } Toast.-information .toast--title { @@ -64,7 +70,7 @@ class Toast(Static, inherit_css=False): } Toast.-warning { - border-left: wide $warning; + border-left: outer $warning; } Toast.-warning .toast--title { @@ -72,7 +78,7 @@ class Toast(Static, inherit_css=False): } Toast.-error { - border-left: wide $error; + border-left: outer $error; } Toast.-error .toast--title { diff --git a/src/textual/widgets/_toggle_button.py b/src/textual/widgets/_toggle_button.py index 4c29c236ae..93059f610b 100644 --- a/src/textual/widgets/_toggle_button.py +++ b/src/textual/widgets/_toggle_button.py @@ -94,16 +94,16 @@ class ToggleButton(Static, can_focus=True): /* Light mode overrides. */ - App.-light-mode ToggleButton > .toggle--button { + ToggleButton:light > .toggle--button { color: $background; background: $foreground 10%; } - App.-light-mode ToggleButton:focus > .toggle--button { + ToggleButton:light:focus > .toggle--button { background: $foreground 25%; } - App.-light-mode ToggleButton.-on > .toggle--button { + ToggleButton:light.-on > .toggle--button { color: $primary; } """ # TODO: https://github.com/Textualize/textual/issues/1780 @@ -147,18 +147,35 @@ def __init__( # NOTE: Don't send a Changed message in response to the initial set. with self.prevent(self.Changed): self.value = value - self._label = Text.from_markup(label) if isinstance(label, str) else label + self._label = self._make_label(label) + + def _make_label(self, label: TextType) -> Text: + """Make a `Text` label from a `TextType` value. + + Args: + label: The source value for the label. + + Returns: + A `Text` rendering of the label for use in the button. + """ + label = Text.from_markup(label) if isinstance(label, str) else label try: # Only use the first line if it's a multi-line label. - self._label = self._label.split()[0] + label = label.split()[0] except IndexError: pass + return label @property def label(self) -> Text: """The label associated with the button.""" return self._label + @label.setter + def label(self, label: TextType) -> None: + self._label = self._make_label(label) + self.refresh(layout=True) + @property def _button(self) -> Text: """The button, reflecting the current value.""" diff --git a/src/textual/widgets/_tree.py b/src/textual/widgets/_tree.py index b094f75681..501efefd66 100644 --- a/src/textual/widgets/_tree.py +++ b/src/textual/widgets/_tree.py @@ -42,6 +42,14 @@ TOGGLE_STYLE = Style.from_meta({"toggle": True}) +class RemoveRootError(Exception): + """Exception raised when trying to remove the root of a [`TreeNode`][textual.widgets.tree.TreeNode].""" + + +class UnknownNodeID(Exception): + """Exception raised when referring to an unknown [`TreeNode`][textual.widgets.tree.TreeNode] ID.""" + + @dataclass class _TreeLine(Generic[TreeDataType]): path: list[TreeNode[TreeDataType]] @@ -352,9 +360,6 @@ def add_leaf( node = self.add(label, data, expand=False, allow_expand=False) return node - class RemoveRootError(Exception): - """Exception raised when trying to remove a tree's root node.""" - def _remove_children(self) -> None: """Remove child nodes of this node. @@ -381,10 +386,10 @@ def remove(self) -> None: """Remove this node from the tree. Raises: - TreeNode.RemoveRootError: If there is an attempt to remove the root. + RemoveRootError: If there is an attempt to remove the root. """ if self.is_root: - raise self.RemoveRootError("Attempt to remove the root node of a Tree.") + raise RemoveRootError("Attempt to remove the root node of a Tree.") self._remove() self._tree._invalidate() @@ -393,6 +398,11 @@ def remove_children(self) -> None: self._remove_children() self._tree._invalidate() + def refresh(self) -> None: + """Initiate a refresh (repaint) of this node.""" + self._updates += 1 + self._tree._refresh_line(self._line) + class Tree(Generic[TreeDataType], ScrollView, can_focus=True): """A widget for displaying and navigating data in a tree.""" @@ -597,8 +607,6 @@ def __init__( disabled: Whether the tree is disabled or not. """ - super().__init__(name=name, id=id, classes=classes, disabled=disabled) - text_label = self.process_label(label) self._updates = 0 @@ -610,6 +618,8 @@ def __init__( self._tree_lines_cached: list[_TreeLine] | None = None self._cursor_node: TreeNode[TreeDataType] | None = None + super().__init__(name=name, id=id, classes=classes, disabled=disabled) + @property def cursor_node(self) -> TreeNode[TreeDataType] | None: """The currently selected node, or ``None`` if no selection.""" @@ -707,13 +717,14 @@ def clear(self) -> Self: self._current_id = 0 root_label = self.root._label root_data = self.root.data + root_expanded = self.root.is_expanded self.root = TreeNode( self, None, self._new_id(), root_label, root_data, - expanded=True, + expanded=root_expanded, ) self._updates += 1 self.refresh() @@ -758,9 +769,6 @@ def get_node_at_line(self, line_no: int) -> TreeNode[TreeDataType] | None: else: return line.node - class UnknownNodeID(Exception): - """Exception raised when referring to an unknown `TreeNode` ID.""" - def get_node_by_id(self, node_id: NodeID) -> TreeNode[TreeDataType]: """Get a tree node by its ID. @@ -771,12 +779,12 @@ def get_node_by_id(self, node_id: NodeID) -> TreeNode[TreeDataType]: The node associated with that ID. Raises: - Tree.UnknownID: Raised if the `TreeNode` ID is unknown. + UnknownNodeID: Raised if the `TreeNode` ID is unknown. """ try: return self._tree_nodes[node_id] except KeyError: - raise self.UnknownNodeID(f"Unknown NodeID ({node_id}) in tree") from None + raise UnknownNodeID(f"Unknown NodeID ({node_id}) in tree") from None def validate_cursor_line(self, value: int) -> int: """Prevent cursor line from going outside of range. @@ -897,7 +905,7 @@ def scroll_to_line(self, line: int, animate: bool = True) -> None: """ region = self._get_label_region(line) if region is not None: - self.scroll_to_region(region, animate=animate) + self.scroll_to_region(region, animate=animate, force=True) def scroll_to_node( self, node: TreeNode[TreeDataType], animate: bool = True @@ -912,7 +920,7 @@ def scroll_to_node( if line != -1: self.scroll_to_line(line, animate=animate) - def refresh_line(self, line: int) -> None: + def _refresh_line(self, line: int) -> None: """Refresh (repaint) a given line in the tree. Args: @@ -937,7 +945,7 @@ def _refresh_node(self, node: TreeNode[TreeDataType]) -> None: visible_lines = self._tree_lines[scroll_y : scroll_y + height] for line_no, line in enumerate(visible_lines, scroll_y): if node in line.path: - self.refresh_line(line_no) + self._refresh_line(line_no) @property def _tree_lines(self) -> list[_TreeLine]: diff --git a/src/textual/widgets/input.py b/src/textual/widgets/input.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/textual/widgets/markdown.py b/src/textual/widgets/markdown.py index b9b1fec2fd..2e2fa97057 100644 --- a/src/textual/widgets/markdown.py +++ b/src/textual/widgets/markdown.py @@ -1,3 +1,13 @@ -from ._markdown import Markdown, MarkdownBlock, MarkdownTableOfContents +from ._markdown import ( + Markdown, + MarkdownBlock, + MarkdownTableOfContents, + TableOfContentsType, +) -__all__ = ["MarkdownTableOfContents", "Markdown", "MarkdownBlock"] +__all__ = [ + "MarkdownTableOfContents", + "Markdown", + "MarkdownBlock", + "TableOfContentsType", +] diff --git a/src/textual/widgets/rule.py b/src/textual/widgets/rule.py index ef4f57d56d..a9ab5d23e9 100644 --- a/src/textual/widgets/rule.py +++ b/src/textual/widgets/rule.py @@ -1,9 +1,4 @@ -from ._rule import ( - InvalidLineStyle, - InvalidRuleOrientation, - LineStyle, - RuleOrientation, -) +from ._rule import InvalidLineStyle, InvalidRuleOrientation, LineStyle, RuleOrientation __all__ = [ "InvalidLineStyle", diff --git a/src/textual/widgets/select.py b/src/textual/widgets/select.py new file mode 100644 index 0000000000..e26c4f721d --- /dev/null +++ b/src/textual/widgets/select.py @@ -0,0 +1,3 @@ +from ._select import EmptySelectError, InvalidSelectValueError + +__all__ = ["EmptySelectError", "InvalidSelectValueError"] diff --git a/src/textual/widgets/text_area.py b/src/textual/widgets/text_area.py new file mode 100644 index 0000000000..82a69e38b3 --- /dev/null +++ b/src/textual/widgets/text_area.py @@ -0,0 +1,37 @@ +from textual._text_area_theme import TextAreaTheme +from textual.document._document import ( + Document, + DocumentBase, + EditResult, + Location, + Selection, +) +from textual.document._languages import BUILTIN_LANGUAGES +from textual.document._syntax_aware_document import SyntaxAwareDocument +from textual.widgets._text_area import ( + Edit, + EndColumn, + Highlight, + HighlightName, + LanguageDoesNotExist, + StartColumn, + ThemeDoesNotExist, +) + +__all__ = [ + "BUILTIN_LANGUAGES", + "Document", + "DocumentBase", + "Edit", + "EditResult", + "EndColumn", + "Highlight", + "HighlightName", + "LanguageDoesNotExist", + "Location", + "Selection", + "StartColumn", + "SyntaxAwareDocument", + "TextAreaTheme", + "ThemeDoesNotExist", +] diff --git a/src/textual/widgets/tree.py b/src/textual/widgets/tree.py index 2e315bc23d..70296bcaa2 100644 --- a/src/textual/widgets/tree.py +++ b/src/textual/widgets/tree.py @@ -1,5 +1,19 @@ """Make non-widget Tree support classes available.""" -from ._tree import TreeNode +from ._tree import ( + EventTreeDataType, + NodeID, + RemoveRootError, + TreeDataType, + TreeNode, + UnknownNodeID, +) -__all__ = ["TreeNode"] +__all__ = [ + "EventTreeDataType", + "NodeID", + "RemoveRootError", + "TreeDataType", + "TreeNode", + "UnknownNodeID", +] diff --git a/src/textual/worker.py b/src/textual/worker.py index 6e8a0234c3..d858fbe8c5 100644 --- a/src/textual/worker.py +++ b/src/textual/worker.py @@ -21,7 +21,6 @@ ) import rich.repr -from rich.traceback import Traceback from typing_extensions import TypeAlias from .message import Message @@ -369,6 +368,8 @@ async def _run(self, app: App) -> None: self.state = WorkerState.ERROR self._error = error app.log.worker(self, "failed", repr(error)) + from rich.traceback import Traceback + app.log.worker(Traceback()) if self.exit_on_error: app._fatal_error() diff --git a/tests/command_palette/test_click_away.py b/tests/command_palette/test_click_away.py index 383f39cdb2..ad8f989113 100644 --- a/tests/command_palette/test_click_away.py +++ b/tests/command_palette/test_click_away.py @@ -20,6 +20,6 @@ def on_mount(self) -> None: async def test_clicking_outside_command_palette_closes_it() -> None: """Clicking 'outside' the command palette should make it go away.""" async with CommandPaletteApp().run_test() as pilot: - assert len(pilot.app.query(CommandPalette)) == 1 + assert isinstance(pilot.app.screen, CommandPalette) await pilot.click() - assert len(pilot.app.query(CommandPalette)) == 0 + assert not isinstance(pilot.app.screen, CommandPalette) diff --git a/tests/command_palette/test_command_source_environment.py b/tests/command_palette/test_command_source_environment.py index af9b691d70..29145cdf04 100644 --- a/tests/command_palette/test_command_source_environment.py +++ b/tests/command_palette/test_command_source_environment.py @@ -1,7 +1,7 @@ from __future__ import annotations from textual.app import App, ComposeResult -from textual.command import CommandPalette, Hit, Hits, Provider +from textual.command import Hit, Hits, Provider from textual.screen import Screen from textual.widget import Widget from textual.widgets import Input @@ -31,7 +31,7 @@ def on_mount(self) -> None: async def test_command_source_environment() -> None: """The command source should see the app and default screen.""" async with CommandPaletteApp().run_test() as pilot: - base_screen = pilot.app.query_one(CommandPalette)._calling_screen + base_screen, *_ = pilot.app.children assert base_screen is not None await pilot.press(*"test") assert len(SimpleSource.environment) == 1 diff --git a/tests/command_palette/test_declare_sources.py b/tests/command_palette/test_declare_sources.py index c5bae17904..e547fc4eb1 100644 --- a/tests/command_palette/test_declare_sources.py +++ b/tests/command_palette/test_declare_sources.py @@ -1,3 +1,4 @@ +from textual._system_commands import SystemCommands from textual.app import App from textual.command import CommandPalette, Hit, Hits, Provider from textual.screen import Screen @@ -28,7 +29,8 @@ class AppWithNoSources(AppWithActiveCommandPalette): async def test_no_app_command_sources() -> None: """An app with no sources declared should work fine.""" async with AppWithNoSources().run_test() as pilot: - assert pilot.app.query_one(CommandPalette)._provider_classes == App.COMMANDS + assert isinstance(pilot.app.screen, CommandPalette) + assert pilot.app.screen._provider_classes == {SystemCommands} class AppWithSources(AppWithActiveCommandPalette): @@ -38,10 +40,8 @@ class AppWithSources(AppWithActiveCommandPalette): async def test_app_command_sources() -> None: """Command sources declared on an app should be in the command palette.""" async with AppWithSources().run_test() as pilot: - assert ( - pilot.app.query_one(CommandPalette)._provider_classes - == AppWithSources.COMMANDS - ) + assert isinstance(pilot.app.screen, CommandPalette) + assert pilot.app.screen._provider_classes == {ExampleCommandSource} class AppWithInitialScreen(App[None]): @@ -61,7 +61,8 @@ def on_mount(self) -> None: async def test_no_screen_command_sources() -> None: """An app with a screen with no sources declared should work fine.""" async with AppWithInitialScreen(ScreenWithNoSources()).run_test() as pilot: - assert pilot.app.query_one(CommandPalette)._provider_classes == App.COMMANDS + assert isinstance(pilot.app.screen, CommandPalette) + assert pilot.app.screen._provider_classes == {SystemCommands} class ScreenWithSources(ScreenWithNoSources): @@ -71,10 +72,11 @@ class ScreenWithSources(ScreenWithNoSources): async def test_screen_command_sources() -> None: """Command sources declared on a screen should be in the command palette.""" async with AppWithInitialScreen(ScreenWithSources()).run_test() as pilot: - assert ( - pilot.app.query_one(CommandPalette)._provider_classes - == App.COMMANDS | ScreenWithSources.COMMANDS - ) + assert isinstance(pilot.app.screen, CommandPalette) + assert pilot.app.screen._provider_classes == { + SystemCommands, + ExampleCommandSource, + } class AnotherCommandSource(ExampleCommandSource): @@ -91,7 +93,8 @@ def on_mount(self) -> None: async def test_app_and_screen_command_sources_combine() -> None: """If an app and the screen have command sources they should combine.""" async with CombinedSourceApp().run_test() as pilot: + assert isinstance(pilot.app.screen, CommandPalette) assert ( - pilot.app.query_one(CommandPalette)._provider_classes + pilot.app.screen._provider_classes == CombinedSourceApp.COMMANDS | ScreenWithSources.COMMANDS ) diff --git a/tests/command_palette/test_escaping.py b/tests/command_palette/test_escaping.py index 2ac2013b6c..83bf36ad15 100644 --- a/tests/command_palette/test_escaping.py +++ b/tests/command_palette/test_escaping.py @@ -20,31 +20,31 @@ def on_mount(self) -> None: async def test_escape_closes_when_no_list_visible() -> None: """Pressing escape when no list is visible should close the command palette.""" async with CommandPaletteApp().run_test() as pilot: - assert len(pilot.app.query(CommandPalette)) == 1 + assert CommandPalette.is_open(pilot.app) await pilot.press("escape") - assert len(pilot.app.query(CommandPalette)) == 0 + assert not CommandPalette.is_open(pilot.app) async def test_escape_does_not_close_when_list_visible() -> None: """Pressing escape when a hit list is visible should not close the command palette.""" async with CommandPaletteApp().run_test() as pilot: - assert len(pilot.app.query(CommandPalette)) == 1 + assert CommandPalette.is_open(pilot.app) await pilot.press("a") await pilot.press("escape") - assert len(pilot.app.query(CommandPalette)) == 1 + assert CommandPalette.is_open(pilot.app) await pilot.press("escape") - assert len(pilot.app.query(CommandPalette)) == 0 + assert not CommandPalette.is_open(pilot.app) async def test_down_arrow_should_undo_closing_of_list_via_escape() -> None: """Down arrow should reopen the hit list if escape closed it before.""" async with CommandPaletteApp().run_test() as pilot: - assert len(pilot.app.query(CommandPalette)) == 1 + assert CommandPalette.is_open(pilot.app) await pilot.press("a") await pilot.press("escape") - assert len(pilot.app.query(CommandPalette)) == 1 + assert CommandPalette.is_open(pilot.app) await pilot.press("down") await pilot.press("escape") - assert len(pilot.app.query(CommandPalette)) == 1 + assert CommandPalette.is_open(pilot.app) await pilot.press("escape") - assert len(pilot.app.query(CommandPalette)) == 0 + assert not CommandPalette.is_open(pilot.app) diff --git a/tests/command_palette/test_interaction.py b/tests/command_palette/test_interaction.py index d243a35565..f5d64093b4 100644 --- a/tests/command_palette/test_interaction.py +++ b/tests/command_palette/test_interaction.py @@ -21,46 +21,46 @@ def on_mount(self) -> None: async def test_initial_list_no_highlight() -> None: """When the list initially appears, nothing will be highlighted.""" async with CommandPaletteApp().run_test() as pilot: - assert len(pilot.app.query(CommandPalette)) == 1 - assert pilot.app.query_one(CommandList).visible is False + assert CommandPalette.is_open(pilot.app) + assert pilot.app.screen.query_one(CommandList).visible is False await pilot.press("a") - assert pilot.app.query_one(CommandList).visible is True - assert pilot.app.query_one(CommandList).highlighted is None + assert pilot.app.screen.query_one(CommandList).visible is True + assert pilot.app.screen.query_one(CommandList).highlighted is None async def test_down_arrow_selects_an_item() -> None: """Typing in a search value then pressing down should select a command.""" async with CommandPaletteApp().run_test() as pilot: - assert len(pilot.app.query(CommandPalette)) == 1 - assert pilot.app.query_one(CommandList).visible is False + assert CommandPalette.is_open(pilot.app) + assert pilot.app.screen.query_one(CommandList).visible is False await pilot.press("a") - assert pilot.app.query_one(CommandList).visible is True - assert pilot.app.query_one(CommandList).highlighted is None + assert pilot.app.screen.query_one(CommandList).visible is True + assert pilot.app.screen.query_one(CommandList).highlighted is None await pilot.press("down") - assert pilot.app.query_one(CommandList).highlighted is not None + assert pilot.app.screen.query_one(CommandList).highlighted is not None async def test_enter_selects_an_item() -> None: """Typing in a search value then pressing enter should select a command.""" async with CommandPaletteApp().run_test() as pilot: - assert len(pilot.app.query(CommandPalette)) == 1 - assert pilot.app.query_one(CommandList).visible is False + assert CommandPalette.is_open(pilot.app) + assert pilot.app.screen.query_one(CommandList).visible is False await pilot.press("a") - assert pilot.app.query_one(CommandList).visible is True - assert pilot.app.query_one(CommandList).highlighted is None + assert pilot.app.screen.query_one(CommandList).visible is True + assert pilot.app.screen.query_one(CommandList).highlighted is None await pilot.press("enter") - assert pilot.app.query_one(CommandList).highlighted is not None + assert pilot.app.screen.query_one(CommandList).highlighted is not None async def test_selection_of_command_closes_command_palette() -> None: """Selecting a command from the list should close the list.""" async with CommandPaletteApp().run_test() as pilot: - assert len(pilot.app.query(CommandPalette)) == 1 - assert pilot.app.query_one(CommandList).visible is False + assert CommandPalette.is_open(pilot.app) + assert pilot.app.screen.query_one(CommandList).visible is False await pilot.press("a") - assert pilot.app.query_one(CommandList).visible is True - assert pilot.app.query_one(CommandList).highlighted is None + assert pilot.app.screen.query_one(CommandList).visible is True + assert pilot.app.screen.query_one(CommandList).highlighted is None await pilot.press("enter") - assert pilot.app.query_one(CommandList).highlighted is not None + assert pilot.app.screen.query_one(CommandList).highlighted is not None await pilot.press("enter") - assert len(pilot.app.query(CommandPalette)) == 0 + assert not CommandPalette.is_open(pilot.app) diff --git a/tests/command_palette/test_no_results.py b/tests/command_palette/test_no_results.py index 427892cc93..8328853b83 100644 --- a/tests/command_palette/test_no_results.py +++ b/tests/command_palette/test_no_results.py @@ -13,12 +13,14 @@ def on_mount(self) -> None: async def test_no_results() -> None: """Receiving no results from a search for a command should not be a problem.""" async with CommandPaletteApp().run_test() as pilot: - assert len(pilot.app.query(CommandPalette)) == 1 + assert CommandPalette.is_open(pilot.app) results = pilot.app.screen.query_one(OptionList) assert results.visible is False assert results.option_count == 0 await pilot.press("a") - await pilot.pause() + # https://github.com/Textualize/textual/issues/3700 -- note the + # little bit of wiggle room to allow for Windows. + await pilot.pause(delay=CommandPalette._NO_MATCHES_COUNTDOWN + 0.1) assert results.visible is True assert results.option_count == 1 assert "No matches found" in str(results.get_option_at_index(0).prompt) diff --git a/tests/command_palette/test_run_on_select.py b/tests/command_palette/test_run_on_select.py index a652096b56..31946a127b 100644 --- a/tests/command_palette/test_run_on_select.py +++ b/tests/command_palette/test_run_on_select.py @@ -37,7 +37,7 @@ async def test_with_run_on_select_on() -> None: assert isinstance(pilot.app, CommandPaletteRunOnSelectApp) pilot.app.action_command_palette() await pilot.press("0") - await pilot.app.query_one(CommandPalette).workers.wait_for_complete() + await pilot.app.screen.workers.wait_for_complete() await pilot.press("down") await pilot.press("enter") assert pilot.app.selection is not None @@ -57,11 +57,11 @@ async def test_with_run_on_select_off() -> None: assert isinstance(pilot.app, CommandPaletteDoNotRunOnSelectApp) pilot.app.action_command_palette() await pilot.press("0") - await pilot.app.query_one(CommandPalette).workers.wait_for_complete() + await pilot.app.screen.workers.wait_for_complete() await pilot.press("down") await pilot.press("enter") assert pilot.app.selection is None - assert pilot.app.query_one(Input).value != "" + assert pilot.app.screen.query_one(Input).value != "" await pilot.press("enter") assert pilot.app.selection is not None CommandPalette.run_on_select = save diff --git a/tests/command_palette/test_worker_interference.py b/tests/command_palette/test_worker_interference.py new file mode 100644 index 0000000000..b259d748e5 --- /dev/null +++ b/tests/command_palette/test_worker_interference.py @@ -0,0 +1,50 @@ +"""Tests for https://github.com/Textualize/textual/issues/3615""" + +from asyncio import sleep + +from textual import work +from textual.app import App +from textual.command import Hit, Hits, Provider + + +class SimpleSource(Provider): + async def search(self, query: str) -> Hits: + def goes_nowhere_does_nothing() -> None: + pass + + for _ in range(100): + yield Hit(1, query, goes_nowhere_does_nothing, query) + + +class CommandPaletteNoWorkerApp(App[None]): + COMMANDS = {SimpleSource} + + +async def test_no_command_palette_worker_droppings() -> None: + """The command palette should not leave any workers behind..""" + async with CommandPaletteNoWorkerApp().run_test() as pilot: + assert len(pilot.app.workers) == 0 + pilot.app.action_command_palette() + await pilot.press("a", "enter") + assert len(pilot.app.workers) == 0 + + +class CommandPaletteWithWorkerApp(App[None]): + COMMANDS = {SimpleSource} + + def on_mount(self) -> None: + self.innocent_worker() + + @work + async def innocent_worker(self) -> None: + while True: + await sleep(1) + + +async def test_innocent_worker_is_untouched() -> None: + """Using the command palette should not halt other workers.""" + async with CommandPaletteWithWorkerApp().run_test() as pilot: + assert len(pilot.app.workers) > 0 + pilot.app.action_command_palette() + await pilot.press("a", "enter") + assert len(pilot.app.workers) > 0 diff --git a/tests/css/test_inheritance.py b/tests/css/test_inheritance.py new file mode 100644 index 0000000000..2dd6f25a4e --- /dev/null +++ b/tests/css/test_inheritance.py @@ -0,0 +1,39 @@ +import pytest + +from textual.app import App, ComposeResult +from textual.color import Color +from textual.widget import Widget + + +class Widget1(Widget): + DEFAULT_CSS = """ + Widget1 { + background: red; + } + """ + + +class Widget2(Widget1): + DEFAULT_CSS = """ + Widget1 { + background: green; + } + """ + + +# TODO: tie breaker on CSS +@pytest.mark.xfail( + reason="Overlapping styles should prioritize the most recent widget in the inheritance chain" +) +async def test_inheritance(): + class InheritanceApp(App): + def compose(self) -> ComposeResult: + yield Widget1(id="widget1") + yield Widget2(id="widget2") + + app = InheritanceApp() + async with app.run_test(): + widget1 = app.query_one("#widget1", Widget1) + widget2 = app.query_one("#widget2", Widget2) + + assert widget2.styles.background == Color.parse("green") diff --git a/tests/css/test_initial.py b/tests/css/test_initial.py new file mode 100644 index 0000000000..a0b880c479 --- /dev/null +++ b/tests/css/test_initial.py @@ -0,0 +1,95 @@ +from textual.app import App, ComposeResult +from textual.color import Color +from textual.widget import Widget + + +class Base(Widget): + DEFAULT_CSS = """ + Base { + color: magenta; + } + """ + + +class CustomWidget1(Base): + DEFAULT_CSS = """ + CustomWidget1 { + background: red + } + """ + + +class CustomWidget2(CustomWidget1): + DEFAULT_CSS = """ + CustomWidget2 { + background: initial; + } + """ + + +class CustomWidget3(CustomWidget2): + pass + + +async def test_initial_default(): + class InitialApp(App): + def compose(self) -> ComposeResult: + yield Base(id="base") + yield CustomWidget1(id="custom1") + yield CustomWidget2(id="custom2") + + app = InitialApp() + async with app.run_test(): + base = app.query_one("#base", Base) + custom1 = app.query_one("#custom1", CustomWidget1) + custom2 = app.query_one("#custom2", CustomWidget2) + + # No background set on base + default_background = base.styles.background + assert default_background == Color.parse("rgba(0,0,0,0)") + # Customized background value, should be red + assert custom1.styles.background == Color.parse("red") + # Background has default value + assert custom2.styles.background == default_background + + +async def test_initial(): + class InitialApp(App): + CSS = """ + CustomWidget1 { + color: red; + } + + CustomWidget2 { + color: initial; + } + + CustomWidget3 { + color: blue; + } + """ + + def compose(self) -> ComposeResult: + yield Base(id="base") + yield CustomWidget1(id="custom1") + yield CustomWidget2(id="custom2") + yield CustomWidget3(id="custom3") + + app = InitialApp() + async with app.run_test(): + base = app.query_one("#base") + custom1 = app.query_one("#custom1") + custom2 = app.query_one("#custom2") + custom3 = app.query_one("#custom3") + + # Default color + assert base.styles.color == Color.parse("magenta") + + # Explicitly set to red + assert custom1.styles.color == Color.parse("red") + + # Set to initial, should be same as base + assert custom2.styles.color == Color.parse("magenta") + + # Set to blue + assert custom3.styles.color == Color.parse("blue") diff --git a/tests/css/test_nested_css.py b/tests/css/test_nested_css.py new file mode 100644 index 0000000000..821c655ff3 --- /dev/null +++ b/tests/css/test_nested_css.py @@ -0,0 +1,65 @@ +from __future__ import annotations + +import pytest + +from textual.app import App, ComposeResult +from textual.color import Color +from textual.containers import Vertical +from textual.css.parse import parse +from textual.css.tokenizer import EOFError, TokenError +from textual.widgets import Label + + +class NestedApp(App): + CSS = """ + Screen { + & > #foo { + background: red; + #egg { + background: green; + } + .paul { + background: blue; + } + &.jessica { + color: magenta; + } + } + } + """ + + def compose(self) -> ComposeResult: + with Vertical(id="foo", classes="jessica"): + yield Label("Hello", id="egg") + yield Label("World", classes="paul") + + +async def test_nest_app(): + """Test nested CSS works as expected.""" + app = NestedApp() + async with app.run_test(): + assert app.query_one("#foo").styles.background == Color.parse("red") + assert app.query_one("#foo").styles.color == Color.parse("magenta") + assert app.query_one("#egg").styles.background == Color.parse("green") + assert app.query_one("#foo .paul").styles.background == Color.parse("blue") + + +@pytest.mark.parametrize( + ("css", "exception"), + [ + ("Selector {", EOFError), + ("Selector{ Foo {", EOFError), + ("Selector{ Foo {}", EOFError), + ("> {}", TokenError), + ("&", TokenError), + ("&&", TokenError), + ("&.foo", TokenError), + ("& .foo", TokenError), + ("{", TokenError), + ("*{", EOFError), + ], +) +def test_parse_errors(css: str, exception: type[Exception]) -> None: + """Check some CSS which should fail.""" + with pytest.raises(exception): + list(parse("", css, ("foo", ""))) diff --git a/tests/css/test_parse.py b/tests/css/test_parse.py index cb6e6aad0d..febe4f06bb 100644 --- a/tests/css/test_parse.py +++ b/tests/css/test_parse.py @@ -17,12 +17,12 @@ class TestVariableReferenceSubstitution: def test_simple_reference(self): css = "$x: 1; #some-widget{border: $x;}" - variables = substitute_references(tokenize(css, "")) + variables = substitute_references(tokenize(css, ("", ""))) assert list(variables) == [ Token( name="variable_name", value="$x:", - path="", + read_from=("", ""), code=css, location=(0, 0), referenced_by=None, @@ -30,7 +30,7 @@ def test_simple_reference(self): Token( name="whitespace", value=" ", - path="", + read_from=("", ""), code=css, location=(0, 3), referenced_by=None, @@ -38,7 +38,7 @@ def test_simple_reference(self): Token( name="number", value="1", - path="", + read_from=("", ""), code=css, location=(0, 4), referenced_by=None, @@ -46,7 +46,7 @@ def test_simple_reference(self): Token( name="variable_value_end", value=";", - path="", + read_from=("", ""), code=css, location=(0, 5), referenced_by=None, @@ -54,7 +54,7 @@ def test_simple_reference(self): Token( name="whitespace", value=" ", - path="", + read_from=("", ""), code=css, location=(0, 6), referenced_by=None, @@ -62,7 +62,7 @@ def test_simple_reference(self): Token( name="selector_start_id", value="#some-widget", - path="", + read_from=("", ""), code=css, location=(0, 7), referenced_by=None, @@ -70,7 +70,7 @@ def test_simple_reference(self): Token( name="declaration_set_start", value="{", - path="", + read_from=("", ""), code=css, location=(0, 19), referenced_by=None, @@ -78,7 +78,7 @@ def test_simple_reference(self): Token( name="declaration_name", value="border:", - path="", + read_from=("", ""), code=css, location=(0, 20), referenced_by=None, @@ -86,7 +86,7 @@ def test_simple_reference(self): Token( name="whitespace", value=" ", - path="", + read_from=("", ""), code=css, location=(0, 27), referenced_by=None, @@ -94,7 +94,7 @@ def test_simple_reference(self): Token( name="number", value="1", - path="", + read_from=("", ""), code=css, location=(0, 4), referenced_by=ReferencedBy( @@ -104,7 +104,7 @@ def test_simple_reference(self): Token( name="declaration_end", value=";", - path="", + read_from=("", ""), code=css, location=(0, 30), referenced_by=None, @@ -112,7 +112,7 @@ def test_simple_reference(self): Token( name="declaration_set_end", value="}", - path="", + read_from=("", ""), code=css, location=(0, 31), referenced_by=None, @@ -121,12 +121,12 @@ def test_simple_reference(self): def test_simple_reference_no_whitespace(self): css = "$x:1; #some-widget{border: $x;}" - variables = substitute_references(tokenize(css, "")) + variables = substitute_references(tokenize(css, ("", ""))) assert list(variables) == [ Token( name="variable_name", value="$x:", - path="", + read_from=("", ""), code=css, location=(0, 0), referenced_by=None, @@ -134,7 +134,7 @@ def test_simple_reference_no_whitespace(self): Token( name="number", value="1", - path="", + read_from=("", ""), code=css, location=(0, 3), referenced_by=None, @@ -142,7 +142,7 @@ def test_simple_reference_no_whitespace(self): Token( name="variable_value_end", value=";", - path="", + read_from=("", ""), code=css, location=(0, 4), referenced_by=None, @@ -150,7 +150,7 @@ def test_simple_reference_no_whitespace(self): Token( name="whitespace", value=" ", - path="", + read_from=("", ""), code=css, location=(0, 5), referenced_by=None, @@ -158,7 +158,7 @@ def test_simple_reference_no_whitespace(self): Token( name="selector_start_id", value="#some-widget", - path="", + read_from=("", ""), code=css, location=(0, 6), referenced_by=None, @@ -166,7 +166,7 @@ def test_simple_reference_no_whitespace(self): Token( name="declaration_set_start", value="{", - path="", + read_from=("", ""), code=css, location=(0, 18), referenced_by=None, @@ -174,7 +174,7 @@ def test_simple_reference_no_whitespace(self): Token( name="declaration_name", value="border:", - path="", + read_from=("", ""), code=css, location=(0, 19), referenced_by=None, @@ -182,7 +182,7 @@ def test_simple_reference_no_whitespace(self): Token( name="whitespace", value=" ", - path="", + read_from=("", ""), code=css, location=(0, 26), referenced_by=None, @@ -190,7 +190,7 @@ def test_simple_reference_no_whitespace(self): Token( name="number", value="1", - path="", + read_from=("", ""), code=css, location=(0, 3), referenced_by=ReferencedBy( @@ -200,7 +200,7 @@ def test_simple_reference_no_whitespace(self): Token( name="declaration_end", value=";", - path="", + read_from=("", ""), code=css, location=(0, 29), referenced_by=None, @@ -208,7 +208,7 @@ def test_simple_reference_no_whitespace(self): Token( name="declaration_set_end", value="}", - path="", + read_from=("", ""), code=css, location=(0, 30), referenced_by=None, @@ -218,11 +218,11 @@ def test_simple_reference_no_whitespace(self): def test_undefined_variable(self): css = ".thing { border: $not-defined; }" with pytest.raises(UnresolvedVariableError): - list(substitute_references(tokenize(css, ""))) + list(substitute_references(tokenize(css, ("", "")))) def test_empty_variable(self): css = "$x:\n* { background:$x; }" - result = list(substitute_references(tokenize(css, ""))) + result = list(substitute_references(tokenize(css, ("", "")))) assert [(t.name, t.value) for t in result] == [ ("variable_name", "$x:"), ("variable_value_end", "\n"), @@ -238,11 +238,11 @@ def test_empty_variable(self): def test_transitive_reference(self): css = "$x: 1\n$y: $x\n.thing { border: $y }" - assert list(substitute_references(tokenize(css, ""))) == [ + assert list(substitute_references(tokenize(css, ("", "")))) == [ Token( name="variable_name", value="$x:", - path="", + read_from=("", ""), code=css, location=(0, 0), referenced_by=None, @@ -250,7 +250,7 @@ def test_transitive_reference(self): Token( name="whitespace", value=" ", - path="", + read_from=("", ""), code=css, location=(0, 3), referenced_by=None, @@ -258,7 +258,7 @@ def test_transitive_reference(self): Token( name="number", value="1", - path="", + read_from=("", ""), code=css, location=(0, 4), referenced_by=None, @@ -266,7 +266,7 @@ def test_transitive_reference(self): Token( name="variable_value_end", value="\n", - path="", + read_from=("", ""), code=css, location=(0, 5), referenced_by=None, @@ -274,7 +274,7 @@ def test_transitive_reference(self): Token( name="variable_name", value="$y:", - path="", + read_from=("", ""), code=css, location=(1, 0), referenced_by=None, @@ -282,7 +282,7 @@ def test_transitive_reference(self): Token( name="whitespace", value=" ", - path="", + read_from=("", ""), code=css, location=(1, 3), referenced_by=None, @@ -290,7 +290,7 @@ def test_transitive_reference(self): Token( name="number", value="1", - path="", + read_from=("", ""), code=css, location=(0, 4), referenced_by=ReferencedBy( @@ -300,7 +300,7 @@ def test_transitive_reference(self): Token( name="variable_value_end", value="\n", - path="", + read_from=("", ""), code=css, location=(1, 6), referenced_by=None, @@ -308,7 +308,7 @@ def test_transitive_reference(self): Token( name="selector_start_class", value=".thing", - path="", + read_from=("", ""), code=css, location=(2, 0), referenced_by=None, @@ -316,7 +316,7 @@ def test_transitive_reference(self): Token( name="whitespace", value=" ", - path="", + read_from=("", ""), code=css, location=(2, 6), referenced_by=None, @@ -324,7 +324,7 @@ def test_transitive_reference(self): Token( name="declaration_set_start", value="{", - path="", + read_from=("", ""), code=css, location=(2, 7), referenced_by=None, @@ -332,7 +332,7 @@ def test_transitive_reference(self): Token( name="whitespace", value=" ", - path="", + read_from=("", ""), code=css, location=(2, 8), referenced_by=None, @@ -340,7 +340,7 @@ def test_transitive_reference(self): Token( name="declaration_name", value="border:", - path="", + read_from=("", ""), code=css, location=(2, 9), referenced_by=None, @@ -348,7 +348,7 @@ def test_transitive_reference(self): Token( name="whitespace", value=" ", - path="", + read_from=("", ""), code=css, location=(2, 16), referenced_by=None, @@ -356,7 +356,7 @@ def test_transitive_reference(self): Token( name="number", value="1", - path="", + read_from=("", ""), code=css, location=(0, 4), referenced_by=ReferencedBy( @@ -366,7 +366,7 @@ def test_transitive_reference(self): Token( name="whitespace", value=" ", - path="", + read_from=("", ""), code=css, location=(2, 19), referenced_by=None, @@ -374,7 +374,7 @@ def test_transitive_reference(self): Token( name="declaration_set_end", value="}", - path="", + read_from=("", ""), code=css, location=(2, 20), referenced_by=None, @@ -383,11 +383,11 @@ def test_transitive_reference(self): def test_multi_value_variable(self): css = "$x: 2 4\n$y: 6 $x 2\n.thing { border: $y }" - assert list(substitute_references(tokenize(css, ""))) == [ + assert list(substitute_references(tokenize(css, ("", "")))) == [ Token( name="variable_name", value="$x:", - path="", + read_from=("", ""), code=css, location=(0, 0), referenced_by=None, @@ -395,7 +395,7 @@ def test_multi_value_variable(self): Token( name="whitespace", value=" ", - path="", + read_from=("", ""), code=css, location=(0, 3), referenced_by=None, @@ -403,7 +403,7 @@ def test_multi_value_variable(self): Token( name="number", value="2", - path="", + read_from=("", ""), code=css, location=(0, 4), referenced_by=None, @@ -411,7 +411,7 @@ def test_multi_value_variable(self): Token( name="whitespace", value=" ", - path="", + read_from=("", ""), code=css, location=(0, 5), referenced_by=None, @@ -419,7 +419,7 @@ def test_multi_value_variable(self): Token( name="number", value="4", - path="", + read_from=("", ""), code=css, location=(0, 6), referenced_by=None, @@ -427,7 +427,7 @@ def test_multi_value_variable(self): Token( name="variable_value_end", value="\n", - path="", + read_from=("", ""), code=css, location=(0, 7), referenced_by=None, @@ -435,7 +435,7 @@ def test_multi_value_variable(self): Token( name="variable_name", value="$y:", - path="", + read_from=("", ""), code=css, location=(1, 0), referenced_by=None, @@ -443,7 +443,7 @@ def test_multi_value_variable(self): Token( name="whitespace", value=" ", - path="", + read_from=("", ""), code=css, location=(1, 3), referenced_by=None, @@ -451,7 +451,7 @@ def test_multi_value_variable(self): Token( name="number", value="6", - path="", + read_from=("", ""), code=css, location=(1, 4), referenced_by=None, @@ -459,7 +459,7 @@ def test_multi_value_variable(self): Token( name="whitespace", value=" ", - path="", + read_from=("", ""), code=css, location=(1, 5), referenced_by=None, @@ -467,7 +467,7 @@ def test_multi_value_variable(self): Token( name="number", value="2", - path="", + read_from=("", ""), code=css, location=(0, 4), referenced_by=ReferencedBy( @@ -477,7 +477,7 @@ def test_multi_value_variable(self): Token( name="whitespace", value=" ", - path="", + read_from=("", ""), code=css, location=(0, 5), referenced_by=ReferencedBy( @@ -487,7 +487,7 @@ def test_multi_value_variable(self): Token( name="number", value="4", - path="", + read_from=("", ""), code=css, location=(0, 6), referenced_by=ReferencedBy( @@ -497,7 +497,7 @@ def test_multi_value_variable(self): Token( name="whitespace", value=" ", - path="", + read_from=("", ""), code=css, location=(1, 8), referenced_by=None, @@ -505,7 +505,7 @@ def test_multi_value_variable(self): Token( name="number", value="2", - path="", + read_from=("", ""), code=css, location=(1, 9), referenced_by=None, @@ -513,7 +513,7 @@ def test_multi_value_variable(self): Token( name="variable_value_end", value="\n", - path="", + read_from=("", ""), code=css, location=(1, 10), referenced_by=None, @@ -521,7 +521,7 @@ def test_multi_value_variable(self): Token( name="selector_start_class", value=".thing", - path="", + read_from=("", ""), code=css, location=(2, 0), referenced_by=None, @@ -529,7 +529,7 @@ def test_multi_value_variable(self): Token( name="whitespace", value=" ", - path="", + read_from=("", ""), code=css, location=(2, 6), referenced_by=None, @@ -537,7 +537,7 @@ def test_multi_value_variable(self): Token( name="declaration_set_start", value="{", - path="", + read_from=("", ""), code=css, location=(2, 7), referenced_by=None, @@ -545,7 +545,7 @@ def test_multi_value_variable(self): Token( name="whitespace", value=" ", - path="", + read_from=("", ""), code=css, location=(2, 8), referenced_by=None, @@ -553,7 +553,7 @@ def test_multi_value_variable(self): Token( name="declaration_name", value="border:", - path="", + read_from=("", ""), code=css, location=(2, 9), referenced_by=None, @@ -561,7 +561,7 @@ def test_multi_value_variable(self): Token( name="whitespace", value=" ", - path="", + read_from=("", ""), code=css, location=(2, 16), referenced_by=None, @@ -569,7 +569,7 @@ def test_multi_value_variable(self): Token( name="number", value="6", - path="", + read_from=("", ""), code=css, location=(1, 4), referenced_by=ReferencedBy( @@ -579,7 +579,7 @@ def test_multi_value_variable(self): Token( name="whitespace", value=" ", - path="", + read_from=("", ""), code=css, location=(1, 5), referenced_by=ReferencedBy( @@ -589,7 +589,7 @@ def test_multi_value_variable(self): Token( name="number", value="2", - path="", + read_from=("", ""), code=css, location=(0, 4), referenced_by=ReferencedBy( @@ -599,7 +599,7 @@ def test_multi_value_variable(self): Token( name="whitespace", value=" ", - path="", + read_from=("", ""), code=css, location=(0, 5), referenced_by=ReferencedBy( @@ -609,7 +609,7 @@ def test_multi_value_variable(self): Token( name="number", value="4", - path="", + read_from=("", ""), code=css, location=(0, 6), referenced_by=ReferencedBy( @@ -619,7 +619,7 @@ def test_multi_value_variable(self): Token( name="whitespace", value=" ", - path="", + read_from=("", ""), code=css, location=(1, 8), referenced_by=ReferencedBy( @@ -629,7 +629,7 @@ def test_multi_value_variable(self): Token( name="number", value="2", - path="", + read_from=("", ""), code=css, location=(1, 9), referenced_by=ReferencedBy( @@ -639,7 +639,7 @@ def test_multi_value_variable(self): Token( name="whitespace", value=" ", - path="", + read_from=("", ""), code=css, location=(2, 19), referenced_by=None, @@ -647,7 +647,7 @@ def test_multi_value_variable(self): Token( name="declaration_set_end", value="}", - path="", + read_from=("", ""), code=css, location=(2, 20), referenced_by=None, @@ -656,11 +656,11 @@ def test_multi_value_variable(self): def test_variable_used_inside_property_value(self): css = "$x: red\n.thing { border: on $x; }" - assert list(substitute_references(tokenize(css, ""))) == [ + assert list(substitute_references(tokenize(css, ("", "")))) == [ Token( name="variable_name", value="$x:", - path="", + read_from=("", ""), code=css, location=(0, 0), referenced_by=None, @@ -668,7 +668,7 @@ def test_variable_used_inside_property_value(self): Token( name="whitespace", value=" ", - path="", + read_from=("", ""), code=css, location=(0, 3), referenced_by=None, @@ -676,7 +676,7 @@ def test_variable_used_inside_property_value(self): Token( name="token", value="red", - path="", + read_from=("", ""), code=css, location=(0, 4), referenced_by=None, @@ -684,7 +684,7 @@ def test_variable_used_inside_property_value(self): Token( name="variable_value_end", value="\n", - path="", + read_from=("", ""), code=css, location=(0, 7), referenced_by=None, @@ -692,7 +692,7 @@ def test_variable_used_inside_property_value(self): Token( name="selector_start_class", value=".thing", - path="", + read_from=("", ""), code=css, location=(1, 0), referenced_by=None, @@ -700,7 +700,7 @@ def test_variable_used_inside_property_value(self): Token( name="whitespace", value=" ", - path="", + read_from=("", ""), code=css, location=(1, 6), referenced_by=None, @@ -708,7 +708,7 @@ def test_variable_used_inside_property_value(self): Token( name="declaration_set_start", value="{", - path="", + read_from=("", ""), code=css, location=(1, 7), referenced_by=None, @@ -716,7 +716,7 @@ def test_variable_used_inside_property_value(self): Token( name="whitespace", value=" ", - path="", + read_from=("", ""), code=css, location=(1, 8), referenced_by=None, @@ -724,7 +724,7 @@ def test_variable_used_inside_property_value(self): Token( name="declaration_name", value="border:", - path="", + read_from=("", ""), code=css, location=(1, 9), referenced_by=None, @@ -732,7 +732,7 @@ def test_variable_used_inside_property_value(self): Token( name="whitespace", value=" ", - path="", + read_from=("", ""), code=css, location=(1, 16), referenced_by=None, @@ -740,7 +740,7 @@ def test_variable_used_inside_property_value(self): Token( name="token", value="on", - path="", + read_from=("", ""), code=css, location=(1, 17), referenced_by=None, @@ -748,7 +748,7 @@ def test_variable_used_inside_property_value(self): Token( name="whitespace", value=" ", - path="", + read_from=("", ""), code=css, location=(1, 19), referenced_by=None, @@ -756,7 +756,7 @@ def test_variable_used_inside_property_value(self): Token( name="token", value="red", - path="", + read_from=("", ""), code=css, location=(0, 4), referenced_by=ReferencedBy( @@ -766,7 +766,7 @@ def test_variable_used_inside_property_value(self): Token( name="declaration_end", value=";", - path="", + read_from=("", ""), code=css, location=(1, 22), referenced_by=None, @@ -774,7 +774,7 @@ def test_variable_used_inside_property_value(self): Token( name="whitespace", value=" ", - path="", + read_from=("", ""), code=css, location=(1, 23), referenced_by=None, @@ -782,7 +782,7 @@ def test_variable_used_inside_property_value(self): Token( name="declaration_set_end", value="}", - path="", + read_from=("", ""), code=css, location=(1, 24), referenced_by=None, @@ -791,11 +791,11 @@ def test_variable_used_inside_property_value(self): def test_variable_definition_eof(self): css = "$x: 1" - assert list(substitute_references(tokenize(css, ""))) == [ + assert list(substitute_references(tokenize(css, ("", "")))) == [ Token( name="variable_name", value="$x:", - path="", + read_from=("", ""), code=css, location=(0, 0), referenced_by=None, @@ -803,7 +803,7 @@ def test_variable_definition_eof(self): Token( name="whitespace", value=" ", - path="", + read_from=("", ""), code=css, location=(0, 3), referenced_by=None, @@ -811,7 +811,7 @@ def test_variable_definition_eof(self): Token( name="number", value="1", - path="", + read_from=("", ""), code=css, location=(0, 4), referenced_by=None, @@ -820,11 +820,11 @@ def test_variable_definition_eof(self): def test_variable_reference_whitespace_trimming(self): css = "$x: 123;.thing{border: $x}" - assert list(substitute_references(tokenize(css, ""))) == [ + assert list(substitute_references(tokenize(css, ("", "")))) == [ Token( name="variable_name", value="$x:", - path="", + read_from=("", ""), code=css, location=(0, 0), referenced_by=None, @@ -832,7 +832,7 @@ def test_variable_reference_whitespace_trimming(self): Token( name="whitespace", value=" ", - path="", + read_from=("", ""), code=css, location=(0, 3), referenced_by=None, @@ -840,7 +840,7 @@ def test_variable_reference_whitespace_trimming(self): Token( name="number", value="123", - path="", + read_from=("", ""), code=css, location=(0, 7), referenced_by=None, @@ -848,7 +848,7 @@ def test_variable_reference_whitespace_trimming(self): Token( name="variable_value_end", value=";", - path="", + read_from=("", ""), code=css, location=(0, 10), referenced_by=None, @@ -856,7 +856,7 @@ def test_variable_reference_whitespace_trimming(self): Token( name="selector_start_class", value=".thing", - path="", + read_from=("", ""), code=css, location=(0, 11), referenced_by=None, @@ -864,7 +864,7 @@ def test_variable_reference_whitespace_trimming(self): Token( name="declaration_set_start", value="{", - path="", + read_from=("", ""), code=css, location=(0, 17), referenced_by=None, @@ -872,7 +872,7 @@ def test_variable_reference_whitespace_trimming(self): Token( name="declaration_name", value="border:", - path="", + read_from=("", ""), code=css, location=(0, 18), referenced_by=None, @@ -880,7 +880,7 @@ def test_variable_reference_whitespace_trimming(self): Token( name="whitespace", value=" ", - path="", + read_from=("", ""), code=css, location=(0, 25), referenced_by=None, @@ -888,7 +888,7 @@ def test_variable_reference_whitespace_trimming(self): Token( name="number", value="123", - path="", + read_from=("", ""), code=css, location=(0, 7), referenced_by=ReferencedBy( @@ -898,7 +898,7 @@ def test_variable_reference_whitespace_trimming(self): Token( name="declaration_set_end", value="}", - path="", + read_from=("", ""), code=css, location=(0, 28), referenced_by=None, @@ -1200,12 +1200,6 @@ def test_text_align_invalid(self): rules = stylesheet._parse_rules(css, "foo") assert rules[0].errors - def test_text_align_empty_uses_default(self): - css = "#foo { text-align: ; }" - stylesheet = Stylesheet() - stylesheet.add_source(css) - assert stylesheet.rules[0].styles.text_align == "start" - class TestTypeNames: def test_type_no_number(self): @@ -1244,7 +1238,7 @@ def test_combined_type_starts_with_number(self): stylesheet.parse() -def test_parse_bad_psuedo_selector(): +def test_parse_bad_pseudo_selector(): """Check unknown selector raises a token error.""" bad_selector = """\ @@ -1254,9 +1248,27 @@ def test_parse_bad_psuedo_selector(): """ stylesheet = Stylesheet() - stylesheet.add_source(bad_selector, "foo") + stylesheet.add_source(bad_selector, None) + + with pytest.raises(TokenError) as error: + stylesheet.parse() + + assert error.value.start == (1, 7) + + +def test_parse_bad_pseudo_selector_with_suggestion(): + """Check unknown pseudo selector raises token error with correct position.""" + + bad_selector = """ +Widget:blu { + border: red; +} +""" + + stylesheet = Stylesheet() + stylesheet.add_source(bad_selector, None) with pytest.raises(TokenError) as error: stylesheet.parse() - assert error.value.start == (0, 6) + assert error.value.start == (2, 7) diff --git a/tests/css/test_programmatic_style_changes.py b/tests/css/test_programmatic_style_changes.py index f15ecdd55d..c81d88fa31 100644 --- a/tests/css/test_programmatic_style_changes.py +++ b/tests/css/test_programmatic_style_changes.py @@ -2,7 +2,6 @@ from textual.app import App from textual.containers import Grid -from textual.screen import Screen from textual.widgets import Label diff --git a/tests/css/test_screen_css.py b/tests/css/test_screen_css.py index 42821a62ee..54138fb8a5 100644 --- a/tests/css/test_screen_css.py +++ b/tests/css/test_screen_css.py @@ -16,6 +16,7 @@ def compose(self): class ScreenWithCSS(Screen): + SCOPED_CSS = False CSS = """ #screen-css { background: #ff0000; diff --git a/tests/css/test_styles.py b/tests/css/test_styles.py index e21572478a..7a4578200c 100644 --- a/tests/css/test_styles.py +++ b/tests/css/test_styles.py @@ -115,7 +115,7 @@ def test_get_opacity_default(): def test_styles_css_property(): css = "opacity: 50%; text-opacity: 20%; background: green; color: red; tint: dodgerblue 20%;" - styles = Styles().parse(css, path="") + styles = Styles().parse(css, read_from=("", "")) assert styles.css == ( "background: #008000;\n" "color: #FF0000;\n" @@ -148,6 +148,13 @@ def test_opacity_set_invalid_type_error(): styles.text_opacity = "invalid value" +def test_opacity_set_allows_integer_value(): + """Regression test for https://github.com/Textualize/textual/issues/3414""" + styles = RenderStyles(DOMNode(), Styles(), Styles()) + styles.text_opacity = 0 + assert styles.text_opacity == 0.0 + + @pytest.mark.parametrize( "size_dimension_input,size_dimension_expected_output", [ diff --git a/tests/css/test_stylesheet.py b/tests/css/test_stylesheet.py index abb1afe91a..258190d836 100644 --- a/tests/css/test_stylesheet.py +++ b/tests/css/test_stylesheet.py @@ -1,5 +1,4 @@ from contextlib import nullcontext as does_not_raise -from typing import Any import pytest @@ -204,7 +203,7 @@ def test_did_you_mean_for_css_property_names( 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}"?' + expected_summary += f". Did you mean '{expected_property_name_suggestion}'?" assert help_text.summary == expected_summary @@ -244,6 +243,6 @@ def test_did_you_mean_for_color_names( ) if expected_color_suggestion is not None: - expected_error_summary += f'. Did you mean "{expected_color_suggestion}"?' + expected_error_summary += f". Did you mean '{expected_color_suggestion}'?" assert help_text.summary == expected_error_summary diff --git a/tests/css/test_tokenize.py b/tests/css/test_tokenize.py index 01945c9dba..d4dfba888e 100644 --- a/tests/css/test_tokenize.py +++ b/tests/css/test_tokenize.py @@ -21,11 +21,11 @@ @pytest.mark.parametrize("name", VALID_VARIABLE_NAMES) def test_variable_declaration_valid_names(name): css = f"${name}: black on red;" - assert list(tokenize(css, "")) == [ + assert list(tokenize(css, ("", ""))) == [ Token( name="variable_name", value=f"${name}:", - path="", + read_from=("", ""), code=css, location=(0, 0), referenced_by=None, @@ -33,7 +33,7 @@ def test_variable_declaration_valid_names(name): Token( name="whitespace", value=" ", - path="", + read_from=("", ""), code=css, location=(0, 14), referenced_by=None, @@ -41,7 +41,7 @@ def test_variable_declaration_valid_names(name): Token( name="token", value="black", - path="", + read_from=("", ""), code=css, location=(0, 15), referenced_by=None, @@ -49,7 +49,7 @@ def test_variable_declaration_valid_names(name): Token( name="whitespace", value=" ", - path="", + read_from=("", ""), code=css, location=(0, 20), referenced_by=None, @@ -57,7 +57,7 @@ def test_variable_declaration_valid_names(name): Token( name="token", value="on", - path="", + read_from=("", ""), code=css, location=(0, 21), referenced_by=None, @@ -65,7 +65,7 @@ def test_variable_declaration_valid_names(name): Token( name="whitespace", value=" ", - path="", + read_from=("", ""), code=css, location=(0, 23), referenced_by=None, @@ -73,7 +73,7 @@ def test_variable_declaration_valid_names(name): Token( name="token", value="red", - path="", + read_from=("", ""), code=css, location=(0, 24), referenced_by=None, @@ -81,7 +81,7 @@ def test_variable_declaration_valid_names(name): Token( name="variable_value_end", value=";", - path="", + read_from=("", ""), code=css, location=(0, 27), referenced_by=None, @@ -91,11 +91,11 @@ def test_variable_declaration_valid_names(name): def test_variable_declaration_multiple_values(): css = "$x: 2vw\t4% 6s red;" - assert list(tokenize(css, "")) == [ + assert list(tokenize(css, ("", ""))) == [ Token( name="variable_name", value="$x:", - path="", + read_from=("", ""), code=css, location=(0, 0), referenced_by=None, @@ -103,7 +103,7 @@ def test_variable_declaration_multiple_values(): Token( name="whitespace", value=" ", - path="", + read_from=("", ""), code=css, location=(0, 3), referenced_by=None, @@ -111,7 +111,7 @@ def test_variable_declaration_multiple_values(): Token( name="scalar", value="2vw", - path="", + read_from=("", ""), code=css, location=(0, 4), referenced_by=None, @@ -119,7 +119,7 @@ def test_variable_declaration_multiple_values(): Token( name="whitespace", value="\t", - path="", + read_from=("", ""), code=css, location=(0, 7), referenced_by=None, @@ -127,7 +127,7 @@ def test_variable_declaration_multiple_values(): Token( name="scalar", value="4%", - path="", + read_from=("", ""), code=css, location=(0, 8), referenced_by=None, @@ -135,7 +135,7 @@ def test_variable_declaration_multiple_values(): Token( name="whitespace", value=" ", - path="", + read_from=("", ""), code=css, location=(0, 10), referenced_by=None, @@ -143,7 +143,7 @@ def test_variable_declaration_multiple_values(): Token( name="duration", value="6s", - path="", + read_from=("", ""), code=css, location=(0, 11), referenced_by=None, @@ -151,7 +151,7 @@ def test_variable_declaration_multiple_values(): Token( name="whitespace", value=" ", - path="", + read_from=("", ""), code=css, location=(0, 13), referenced_by=None, @@ -159,7 +159,7 @@ def test_variable_declaration_multiple_values(): Token( name="token", value="red", - path="", + read_from=("", ""), code=css, location=(0, 15), referenced_by=None, @@ -167,7 +167,7 @@ def test_variable_declaration_multiple_values(): Token( name="variable_value_end", value=";", - path="", + read_from=("", ""), code=css, location=(0, 18), referenced_by=None, @@ -183,112 +183,112 @@ def test_single_line_comment(): } # Nada""" # Check the css parses # list(parse(css, "")) - result = list(tokenize(css, "")) + result = list(tokenize(css, ("", ""))) print(result) expected = [ Token( name="whitespace", value="\n", - path="", + read_from=("", ""), code=css, location=(0, 9), ), Token( name="selector_start_id", value="#foo", - path="", + read_from=("", ""), code=css, location=(1, 0), ), Token( name="whitespace", value=" ", - path="", + read_from=("", ""), code=css, location=(1, 4), ), Token( name="declaration_set_start", value="{", - path="", + read_from=("", ""), code=css, location=(1, 5), ), Token( name="whitespace", value=" ", - path="", + read_from=("", ""), code=css, location=(1, 6), ), Token( name="whitespace", value="\n", - path="", + read_from=("", ""), code=css, location=(1, 16), ), Token( name="whitespace", value=" ", - path="", + read_from=("", ""), code=css, location=(2, 0), ), Token( name="declaration_name", value="color:", - path="", + read_from=("", ""), code=css, location=(2, 4), ), Token( name="whitespace", value=" ", - path="", + read_from=("", ""), code=css, location=(2, 10), ), Token( name="token", value="red", - path="", + read_from=("", ""), code=css, location=(2, 11), ), Token( name="declaration_end", value=";", - path="", + read_from=("", ""), code=css, location=(2, 14), ), Token( name="whitespace", value=" ", - path="", + read_from=("", ""), code=css, location=(2, 15), ), Token( name="whitespace", value="\n", - path="", + read_from=("", ""), code=css, location=(2, 30), ), Token( name="declaration_set_end", value="}", - path="", + read_from=("", ""), code=css, location=(3, 0), ), Token( name="whitespace", value=" ", - path="", + read_from=("", ""), code=css, location=(3, 1), ), @@ -298,11 +298,11 @@ def test_single_line_comment(): def test_variable_declaration_comment_ignored(): css = "$x: red; /* comment */" - assert list(tokenize(css, "")) == [ + assert list(tokenize(css, ("", ""))) == [ Token( name="variable_name", value="$x:", - path="", + read_from=("", ""), code=css, location=(0, 0), referenced_by=None, @@ -310,7 +310,7 @@ def test_variable_declaration_comment_ignored(): Token( name="whitespace", value=" ", - path="", + read_from=("", ""), code=css, location=(0, 3), referenced_by=None, @@ -318,7 +318,7 @@ def test_variable_declaration_comment_ignored(): Token( name="token", value="red", - path="", + read_from=("", ""), code=css, location=(0, 4), referenced_by=None, @@ -326,7 +326,7 @@ def test_variable_declaration_comment_ignored(): Token( name="variable_value_end", value=";", - path="", + read_from=("", ""), code=css, location=(0, 7), referenced_by=None, @@ -334,7 +334,7 @@ def test_variable_declaration_comment_ignored(): Token( name="whitespace", value=" ", - path="", + read_from=("", ""), code=css, location=(0, 8), referenced_by=None, @@ -344,11 +344,11 @@ def test_variable_declaration_comment_ignored(): def test_variable_declaration_comment_interspersed_ignored(): css = "$x: re/* comment */d;" - assert list(tokenize(css, "")) == [ + assert list(tokenize(css, ("", ""))) == [ Token( name="variable_name", value="$x:", - path="", + read_from=("", ""), code=css, location=(0, 0), referenced_by=None, @@ -356,7 +356,7 @@ def test_variable_declaration_comment_interspersed_ignored(): Token( name="whitespace", value=" ", - path="", + read_from=("", ""), code=css, location=(0, 3), referenced_by=None, @@ -364,7 +364,7 @@ def test_variable_declaration_comment_interspersed_ignored(): Token( name="token", value="re", - path="", + read_from=("", ""), code=css, location=(0, 4), referenced_by=None, @@ -372,7 +372,7 @@ def test_variable_declaration_comment_interspersed_ignored(): Token( name="token", value="d", - path="", + read_from=("", ""), code=css, location=(0, 19), referenced_by=None, @@ -380,7 +380,7 @@ def test_variable_declaration_comment_interspersed_ignored(): Token( name="variable_value_end", value=";", - path="", + read_from=("", ""), code=css, location=(0, 20), referenced_by=None, @@ -390,11 +390,11 @@ def test_variable_declaration_comment_interspersed_ignored(): def test_variable_declaration_no_semicolon(): css = "$x: 1\n$y: 2" - assert list(tokenize(css, "")) == [ + assert list(tokenize(css, ("", ""))) == [ Token( name="variable_name", value="$x:", - path="", + read_from=("", ""), code=css, location=(0, 0), referenced_by=None, @@ -402,7 +402,7 @@ def test_variable_declaration_no_semicolon(): Token( name="whitespace", value=" ", - path="", + read_from=("", ""), code=css, location=(0, 3), referenced_by=None, @@ -410,7 +410,7 @@ def test_variable_declaration_no_semicolon(): Token( name="number", value="1", - path="", + read_from=("", ""), code=css, location=(0, 4), referenced_by=None, @@ -418,7 +418,7 @@ def test_variable_declaration_no_semicolon(): Token( name="variable_value_end", value="\n", - path="", + read_from=("", ""), code=css, location=(0, 5), referenced_by=None, @@ -426,7 +426,7 @@ def test_variable_declaration_no_semicolon(): Token( name="variable_name", value="$y:", - path="", + read_from=("", ""), code=css, location=(1, 0), referenced_by=None, @@ -434,7 +434,7 @@ def test_variable_declaration_no_semicolon(): Token( name="whitespace", value=" ", - path="", + read_from=("", ""), code=css, location=(1, 3), referenced_by=None, @@ -442,7 +442,7 @@ def test_variable_declaration_no_semicolon(): Token( name="number", value="2", - path="", + read_from=("", ""), code=css, location=(1, 4), referenced_by=None, @@ -453,17 +453,17 @@ def test_variable_declaration_no_semicolon(): def test_variable_declaration_invalid_value(): css = "$x:(@$12x)" with pytest.raises(TokenError): - list(tokenize(css, "")) + list(tokenize(css, ("", ""))) def test_variables_declarations_amongst_rulesets(): css = "$x:1; .thing{text:red;} $y:2;" - tokens = list(tokenize(css, "")) + tokens = list(tokenize(css, ("", ""))) assert tokens == [ Token( name="variable_name", value="$x:", - path="", + read_from=("", ""), code=css, location=(0, 0), referenced_by=None, @@ -471,7 +471,7 @@ def test_variables_declarations_amongst_rulesets(): Token( name="number", value="1", - path="", + read_from=("", ""), code=css, location=(0, 3), referenced_by=None, @@ -479,7 +479,7 @@ def test_variables_declarations_amongst_rulesets(): Token( name="variable_value_end", value=";", - path="", + read_from=("", ""), code=css, location=(0, 4), referenced_by=None, @@ -487,7 +487,7 @@ def test_variables_declarations_amongst_rulesets(): Token( name="whitespace", value=" ", - path="", + read_from=("", ""), code=css, location=(0, 5), referenced_by=None, @@ -495,7 +495,7 @@ def test_variables_declarations_amongst_rulesets(): Token( name="selector_start_class", value=".thing", - path="", + read_from=("", ""), code=css, location=(0, 6), referenced_by=None, @@ -503,7 +503,7 @@ def test_variables_declarations_amongst_rulesets(): Token( name="declaration_set_start", value="{", - path="", + read_from=("", ""), code=css, location=(0, 12), referenced_by=None, @@ -511,7 +511,7 @@ def test_variables_declarations_amongst_rulesets(): Token( name="declaration_name", value="text:", - path="", + read_from=("", ""), code=css, location=(0, 13), referenced_by=None, @@ -519,7 +519,7 @@ def test_variables_declarations_amongst_rulesets(): Token( name="token", value="red", - path="", + read_from=("", ""), code=css, location=(0, 18), referenced_by=None, @@ -527,7 +527,7 @@ def test_variables_declarations_amongst_rulesets(): Token( name="declaration_end", value=";", - path="", + read_from=("", ""), code=css, location=(0, 21), referenced_by=None, @@ -535,7 +535,7 @@ def test_variables_declarations_amongst_rulesets(): Token( name="declaration_set_end", value="}", - path="", + read_from=("", ""), code=css, location=(0, 22), referenced_by=None, @@ -543,7 +543,7 @@ def test_variables_declarations_amongst_rulesets(): Token( name="whitespace", value=" ", - path="", + read_from=("", ""), code=css, location=(0, 23), referenced_by=None, @@ -551,7 +551,7 @@ def test_variables_declarations_amongst_rulesets(): Token( name="variable_name", value="$y:", - path="", + read_from=("", ""), code=css, location=(0, 24), referenced_by=None, @@ -559,7 +559,7 @@ def test_variables_declarations_amongst_rulesets(): Token( name="number", value="2", - path="", + read_from=("", ""), code=css, location=(0, 27), referenced_by=None, @@ -567,7 +567,7 @@ def test_variables_declarations_amongst_rulesets(): Token( name="variable_value_end", value=";", - path="", + read_from=("", ""), code=css, location=(0, 28), referenced_by=None, @@ -577,11 +577,11 @@ def test_variables_declarations_amongst_rulesets(): def test_variables_reference_in_rule_declaration_value(): css = ".warn{text: $warning;}" - assert list(tokenize(css, "")) == [ + assert list(tokenize(css, ("", ""))) == [ Token( name="selector_start_class", value=".warn", - path="", + read_from=("", ""), code=css, location=(0, 0), referenced_by=None, @@ -589,7 +589,7 @@ def test_variables_reference_in_rule_declaration_value(): Token( name="declaration_set_start", value="{", - path="", + read_from=("", ""), code=css, location=(0, 5), referenced_by=None, @@ -597,7 +597,7 @@ def test_variables_reference_in_rule_declaration_value(): Token( name="declaration_name", value="text:", - path="", + read_from=("", ""), code=css, location=(0, 6), referenced_by=None, @@ -605,7 +605,7 @@ def test_variables_reference_in_rule_declaration_value(): Token( name="whitespace", value=" ", - path="", + read_from=("", ""), code=css, location=(0, 11), referenced_by=None, @@ -613,7 +613,7 @@ def test_variables_reference_in_rule_declaration_value(): Token( name="variable_ref", value="$warning", - path="", + read_from=("", ""), code=css, location=(0, 12), referenced_by=None, @@ -621,7 +621,7 @@ def test_variables_reference_in_rule_declaration_value(): Token( name="declaration_end", value=";", - path="", + read_from=("", ""), code=css, location=(0, 20), referenced_by=None, @@ -629,7 +629,7 @@ def test_variables_reference_in_rule_declaration_value(): Token( name="declaration_set_end", value="}", - path="", + read_from=("", ""), code=css, location=(0, 21), referenced_by=None, @@ -639,11 +639,11 @@ def test_variables_reference_in_rule_declaration_value(): def test_variables_reference_in_rule_declaration_value_multiple(): css = ".card{padding: $pad-y $pad-x;}" - assert list(tokenize(css, "")) == [ + assert list(tokenize(css, ("", ""))) == [ Token( name="selector_start_class", value=".card", - path="", + read_from=("", ""), code=css, location=(0, 0), referenced_by=None, @@ -651,7 +651,7 @@ def test_variables_reference_in_rule_declaration_value_multiple(): Token( name="declaration_set_start", value="{", - path="", + read_from=("", ""), code=css, location=(0, 5), referenced_by=None, @@ -659,7 +659,7 @@ def test_variables_reference_in_rule_declaration_value_multiple(): Token( name="declaration_name", value="padding:", - path="", + read_from=("", ""), code=css, location=(0, 6), referenced_by=None, @@ -667,7 +667,7 @@ def test_variables_reference_in_rule_declaration_value_multiple(): Token( name="whitespace", value=" ", - path="", + read_from=("", ""), code=css, location=(0, 14), referenced_by=None, @@ -675,7 +675,7 @@ def test_variables_reference_in_rule_declaration_value_multiple(): Token( name="variable_ref", value="$pad-y", - path="", + read_from=("", ""), code=css, location=(0, 15), referenced_by=None, @@ -683,7 +683,7 @@ def test_variables_reference_in_rule_declaration_value_multiple(): Token( name="whitespace", value=" ", - path="", + read_from=("", ""), code=css, location=(0, 21), referenced_by=None, @@ -691,7 +691,7 @@ def test_variables_reference_in_rule_declaration_value_multiple(): Token( name="variable_ref", value="$pad-x", - path="", + read_from=("", ""), code=css, location=(0, 22), referenced_by=None, @@ -699,7 +699,7 @@ def test_variables_reference_in_rule_declaration_value_multiple(): Token( name="declaration_end", value=";", - path="", + read_from=("", ""), code=css, location=(0, 28), referenced_by=None, @@ -707,7 +707,7 @@ def test_variables_reference_in_rule_declaration_value_multiple(): Token( name="declaration_set_end", value="}", - path="", + read_from=("", ""), code=css, location=(0, 29), referenced_by=None, @@ -717,11 +717,11 @@ def test_variables_reference_in_rule_declaration_value_multiple(): def test_variables_reference_in_variable_declaration(): css = "$x: $y;" - assert list(tokenize(css, "")) == [ + assert list(tokenize(css, ("", ""))) == [ Token( name="variable_name", value="$x:", - path="", + read_from=("", ""), code=css, location=(0, 0), referenced_by=None, @@ -729,7 +729,7 @@ def test_variables_reference_in_variable_declaration(): Token( name="whitespace", value=" ", - path="", + read_from=("", ""), code=css, location=(0, 3), referenced_by=None, @@ -737,7 +737,7 @@ def test_variables_reference_in_variable_declaration(): Token( name="variable_ref", value="$y", - path="", + read_from=("", ""), code=css, location=(0, 4), referenced_by=None, @@ -745,7 +745,7 @@ def test_variables_reference_in_variable_declaration(): Token( name="variable_value_end", value=";", - path="", + read_from=("", ""), code=css, location=(0, 6), referenced_by=None, @@ -755,11 +755,11 @@ def test_variables_reference_in_variable_declaration(): def test_variable_references_in_variable_declaration_multiple(): css = "$x: $y $z\n" - assert list(tokenize(css, "")) == [ + assert list(tokenize(css, ("", ""))) == [ Token( name="variable_name", value="$x:", - path="", + read_from=("", ""), code=css, location=(0, 0), referenced_by=None, @@ -767,7 +767,7 @@ def test_variable_references_in_variable_declaration_multiple(): Token( name="whitespace", value=" ", - path="", + read_from=("", ""), code=css, location=(0, 3), referenced_by=None, @@ -775,7 +775,7 @@ def test_variable_references_in_variable_declaration_multiple(): Token( name="variable_ref", value="$y", - path="", + read_from=("", ""), code=css, location=(0, 4), referenced_by=None, @@ -783,7 +783,7 @@ def test_variable_references_in_variable_declaration_multiple(): Token( name="whitespace", value=" ", - path="", + read_from=("", ""), code=css, location=(0, 6), referenced_by=None, @@ -791,7 +791,7 @@ def test_variable_references_in_variable_declaration_multiple(): Token( name="variable_ref", value="$z", - path="", + read_from=("", ""), code=css, location=(0, 8), referenced_by=None, @@ -799,7 +799,7 @@ def test_variable_references_in_variable_declaration_multiple(): Token( name="variable_value_end", value="\n", - path="", + read_from=("", ""), code=css, location=(0, 10), referenced_by=None, @@ -809,92 +809,92 @@ def test_variable_references_in_variable_declaration_multiple(): def test_allow_new_lines(): css = ".foo{margin: 1\n1 0 0}" - tokens = list(tokenize(css, "")) + tokens = list(tokenize(css, ("", ""))) print(repr(tokens)) expected = [ Token( name="selector_start_class", value=".foo", - path="", + read_from=("", ""), code=".foo{margin: 1\n1 0 0}", location=(0, 0), ), Token( name="declaration_set_start", value="{", - path="", + read_from=("", ""), code=".foo{margin: 1\n1 0 0}", location=(0, 4), ), Token( name="declaration_name", value="margin:", - path="", + read_from=("", ""), code=".foo{margin: 1\n1 0 0}", location=(0, 5), ), Token( name="whitespace", value=" ", - path="", + read_from=("", ""), code=".foo{margin: 1\n1 0 0}", location=(0, 12), ), Token( name="number", value="1", - path="", + read_from=("", ""), code=".foo{margin: 1\n1 0 0}", location=(0, 13), ), Token( name="whitespace", value="\n", - path="", + read_from=("", ""), code=".foo{margin: 1\n1 0 0}", location=(0, 14), ), Token( name="number", value="1", - path="", + read_from=("", ""), code=".foo{margin: 1\n1 0 0}", location=(1, 0), ), Token( name="whitespace", value=" ", - path="", + read_from=("", ""), code=".foo{margin: 1\n1 0 0}", location=(1, 1), ), Token( name="number", value="0", - path="", + read_from=("", ""), code=".foo{margin: 1\n1 0 0}", location=(1, 2), ), Token( name="whitespace", value=" ", - path="", + read_from=("", ""), code=".foo{margin: 1\n1 0 0}", location=(1, 3), ), Token( name="number", value="0", - path="", + read_from=("", ""), code=".foo{margin: 1\n1 0 0}", location=(1, 4), ), Token( name="declaration_set_end", value="}", - path="", + read_from=("", ""), code=".foo{margin: 1\n1 0 0}", location=(1, 5), ), ] - assert list(tokenize(css, "")) == expected + assert list(tokenize(css, ("", ""))) == expected diff --git a/tests/document/test_document.py b/tests/document/test_document.py new file mode 100644 index 0000000000..ad1c82b834 --- /dev/null +++ b/tests/document/test_document.py @@ -0,0 +1,139 @@ +import pytest + +from textual.widgets.text_area import Document + +TEXT = """I must not fear. +Fear is the mind-killer.""" + +TEXT_NEWLINE = TEXT + "\n" +TEXT_WINDOWS = TEXT.replace("\n", "\r\n") +TEXT_WINDOWS_NEWLINE = TEXT_NEWLINE.replace("\n", "\r\n") + + +@pytest.mark.parametrize( + "text", [TEXT, TEXT_NEWLINE, TEXT_WINDOWS, TEXT_WINDOWS_NEWLINE] +) +def test_text(text): + """The text we put in is the text we get out.""" + document = Document(text) + assert document.text == text + + +def test_lines_newline_eof(): + document = Document(TEXT_NEWLINE) + assert document.lines == ["I must not fear.", "Fear is the mind-killer.", ""] + + +def test_lines_no_newline_eof(): + document = Document(TEXT) + assert document.lines == [ + "I must not fear.", + "Fear is the mind-killer.", + ] + + +def test_lines_windows(): + document = Document(TEXT_WINDOWS) + assert document.lines == ["I must not fear.", "Fear is the mind-killer."] + + +def test_lines_windows_newline(): + document = Document(TEXT_WINDOWS_NEWLINE) + assert document.lines == ["I must not fear.", "Fear is the mind-killer.", ""] + + +def test_newline_unix(): + document = Document(TEXT) + assert document.newline == "\n" + + +def test_newline_windows(): + document = Document(TEXT_WINDOWS) + assert document.newline == "\r\n" + + +def test_get_selected_text_no_selection(): + document = Document(TEXT) + selection = document.get_text_range((0, 0), (0, 0)) + assert selection == "" + + +def test_get_selected_text_single_line(): + document = Document(TEXT_WINDOWS) + selection = document.get_text_range((0, 2), (0, 6)) + assert selection == "must" + + +def test_get_selected_text_multiple_lines_unix(): + document = Document(TEXT) + selection = document.get_text_range((0, 2), (1, 2)) + assert selection == "must not fear.\nFe" + + +def test_get_selected_text_multiple_lines_windows(): + document = Document(TEXT_WINDOWS) + selection = document.get_text_range((0, 2), (1, 2)) + assert selection == "must not fear.\r\nFe" + + +def test_get_selected_text_including_final_newline_unix(): + document = Document(TEXT_NEWLINE) + selection = document.get_text_range((0, 0), (2, 0)) + assert selection == TEXT_NEWLINE + + +def test_get_selected_text_including_final_newline_windows(): + document = Document(TEXT_WINDOWS_NEWLINE) + selection = document.get_text_range((0, 0), (2, 0)) + assert selection == TEXT_WINDOWS_NEWLINE + + +def test_get_selected_text_no_newline_at_end_of_file(): + document = Document(TEXT) + selection = document.get_text_range((0, 0), (2, 0)) + assert selection == TEXT + + +def test_get_selected_text_no_newline_at_end_of_file_windows(): + document = Document(TEXT_WINDOWS) + selection = document.get_text_range((0, 0), (2, 0)) + assert selection == TEXT_WINDOWS + + +@pytest.mark.parametrize( + "text", [TEXT, TEXT_NEWLINE, TEXT_WINDOWS, TEXT_WINDOWS_NEWLINE] +) +def test_index_from_location(text): + document = Document(text) + lines = text.split(document.newline) + assert document.get_index_from_location((0, 0)) == 0 + assert document.get_index_from_location((0, len(lines[0]))) == len(lines[0]) + assert document.get_index_from_location((1, 0)) == len(lines[0]) + len( + document.newline + ) + assert document.get_index_from_location((len(lines) - 1, len(lines[-1]))) == len( + text + ) + + +@pytest.mark.parametrize( + "text", [TEXT, TEXT_NEWLINE, TEXT_WINDOWS, TEXT_WINDOWS_NEWLINE] +) +def test_location_from_index(text): + document = Document(text) + lines = text.split(document.newline) + assert document.get_location_from_index(0) == (0, 0) + assert document.get_location_from_index(len(lines[0])) == (0, len(lines[0])) + if len(document.newline) > 1: + assert document.get_location_from_index(len(lines[0]) + 1) == ( + 0, + len(lines[0]) + 1, + ) + assert document.get_location_from_index(len(lines[0]) + len(document.newline)) == ( + 1, + 0, + ) + assert document.get_location_from_index(len(text)) == ( + len(lines) - 1, + len(lines[-1]), + ) diff --git a/tests/document/test_document_delete.py b/tests/document/test_document_delete.py new file mode 100644 index 0000000000..d00fa686c9 --- /dev/null +++ b/tests/document/test_document_delete.py @@ -0,0 +1,146 @@ +import pytest + +from textual.widgets.text_area import Document, EditResult + +TEXT = """I must not fear. +Fear is the mind-killer. +I forgot the rest of the quote. +Sorry Will.""" + + +@pytest.fixture +def document(): + document = Document(TEXT) + return document + + +def test_delete_single_character(document): + replace_result = document.replace_range((0, 0), (0, 1), "") + assert replace_result == EditResult(end_location=(0, 0), replaced_text="I") + assert document.lines == [ + " must not fear.", + "Fear is the mind-killer.", + "I forgot the rest of the quote.", + "Sorry Will.", + ] + + +def test_delete_single_newline(document): + """Testing deleting newline from right to left""" + replace_result = document.replace_range((1, 0), (0, 16), "") + assert replace_result == EditResult(end_location=(0, 16), replaced_text="\n") + assert document.lines == [ + "I must not fear.Fear is the mind-killer.", + "I forgot the rest of the quote.", + "Sorry Will.", + ] + + +def test_delete_near_end_of_document(document): + """Test deleting a range near the end of a document.""" + replace_result = document.replace_range((1, 0), (3, 11), "") + assert replace_result == EditResult( + end_location=(1, 0), + replaced_text="Fear is the mind-killer.\n" + "I forgot the rest of the quote.\n" + "Sorry Will.", + ) + assert document.lines == [ + "I must not fear.", + "", + ] + + +def test_delete_clearing_the_document(document): + replace_result = document.replace_range((0, 0), (4, 0), "") + assert replace_result == EditResult( + end_location=(0, 0), + replaced_text=TEXT, + ) + assert document.lines == [""] + + +def test_delete_multiple_characters_on_one_line(document): + replace_result = document.replace_range((0, 2), (0, 7), "") + assert replace_result == EditResult( + end_location=(0, 2), + replaced_text="must ", + ) + assert document.lines == [ + "I not fear.", + "Fear is the mind-killer.", + "I forgot the rest of the quote.", + "Sorry Will.", + ] + + +def test_delete_multiple_lines_partially_spanned(document): + """Deleting a selection that partially spans the first and final lines of the selection.""" + replace_result = document.replace_range((0, 2), (2, 2), "") + assert replace_result == EditResult( + end_location=(0, 2), + replaced_text="must not fear.\nFear is the mind-killer.\nI ", + ) + assert document.lines == [ + "I forgot the rest of the quote.", + "Sorry Will.", + ] + + +def test_delete_end_of_line(document): + """Testing deleting newline from left to right""" + replace_result = document.replace_range((0, 16), (1, 0), "") + assert replace_result == EditResult( + end_location=(0, 16), + replaced_text="\n", + ) + assert document.lines == [ + "I must not fear.Fear is the mind-killer.", + "I forgot the rest of the quote.", + "Sorry Will.", + ] + + +def test_delete_single_line_excluding_newline(document): + """Delete from the start to the end of the line.""" + replace_result = document.replace_range((2, 0), (2, 31), "") + assert replace_result == EditResult( + end_location=(2, 0), + replaced_text="I forgot the rest of the quote.", + ) + assert document.lines == [ + "I must not fear.", + "Fear is the mind-killer.", + "", + "Sorry Will.", + ] + + +def test_delete_single_line_including_newline(document): + """Delete from the start of a line to the start of the line below.""" + replace_result = document.replace_range((2, 0), (3, 0), "") + assert replace_result == EditResult( + end_location=(2, 0), + replaced_text="I forgot the rest of the quote.\n", + ) + assert document.lines == [ + "I must not fear.", + "Fear is the mind-killer.", + "Sorry Will.", + ] + + +TEXT_NEWLINE_EOF = """\ +I must not fear. +Fear is the mind-killer. +""" + + +def test_delete_end_of_file_newline(): + document = Document(TEXT_NEWLINE_EOF) + replace_result = document.replace_range((2, 0), (1, 24), "") + assert replace_result == EditResult(end_location=(1, 24), replaced_text="\n") + assert document.lines == [ + "I must not fear.", + "Fear is the mind-killer.", + ] diff --git a/tests/document/test_document_insert.py b/tests/document/test_document_insert.py new file mode 100644 index 0000000000..ea706c9abf --- /dev/null +++ b/tests/document/test_document_insert.py @@ -0,0 +1,107 @@ +from textual.widgets.text_area import Document + +TEXT = """I must not fear. +Fear is the mind-killer.""" + + +def test_insert_no_newlines(): + document = Document(TEXT) + document.replace_range((0, 1), (0, 1), " really") + assert document.lines == [ + "I really must not fear.", + "Fear is the mind-killer.", + ] + + +def test_insert_empty_string(): + document = Document(TEXT) + document.replace_range((0, 1), (0, 1), "") + assert document.lines == ["I must not fear.", "Fear is the mind-killer."] + + +def test_insert_invalid_column(): + document = Document(TEXT) + document.replace_range((0, 999), (0, 999), " really") + assert document.lines == ["I must not fear. really", "Fear is the mind-killer."] + + +def test_insert_invalid_row_and_column(): + document = Document(TEXT) + document.replace_range((999, 0), (999, 0), " really") + assert document.lines == ["I must not fear.", "Fear is the mind-killer.", " really"] + + +def test_insert_range_newline_file_start(): + document = Document(TEXT) + document.replace_range((0, 0), (0, 0), "\n") + assert document.lines == ["", "I must not fear.", "Fear is the mind-killer."] + + +def test_insert_newline_splits_line(): + document = Document(TEXT) + document.replace_range((0, 1), (0, 1), "\n") + assert document.lines == ["I", " must not fear.", "Fear is the mind-killer."] + + +def test_insert_newline_splits_line_selection(): + document = Document(TEXT) + document.replace_range((0, 1), (0, 6), "\n") + assert document.lines == ["I", " not fear.", "Fear is the mind-killer."] + + +def test_insert_multiple_lines_ends_with_newline(): + document = Document(TEXT) + document.replace_range((0, 1), (0, 1), "Hello,\nworld!\n") + assert document.lines == [ + "IHello,", + "world!", + " must not fear.", + "Fear is the mind-killer.", + ] + + +def test_insert_multiple_lines_ends_with_no_newline(): + document = Document(TEXT) + document.replace_range((0, 1), (0, 1), "Hello,\nworld!") + assert document.lines == [ + "IHello,", + "world! must not fear.", + "Fear is the mind-killer.", + ] + + +def test_insert_multiple_lines_starts_with_newline(): + document = Document(TEXT) + document.replace_range((0, 1), (0, 1), "\nHello,\nworld!\n") + assert document.lines == [ + "I", + "Hello,", + "world!", + " must not fear.", + "Fear is the mind-killer.", + ] + + +def test_insert_range_text_no_newlines(): + """Ensuring we can do a simple replacement of text.""" + document = Document(TEXT) + document.replace_range((0, 2), (0, 6), "MUST") + assert document.lines == [ + "I MUST not fear.", + "Fear is the mind-killer.", + ] + + +TEXT_NEWLINE_EOF = """\ +I must not fear. +Fear is the mind-killer. +""" + + +def test_newline_eof(): + document = Document(TEXT_NEWLINE_EOF) + assert document.lines == [ + "I must not fear.", + "Fear is the mind-killer.", + "", + ] diff --git a/tests/input/test_input_clear.py b/tests/input/test_input_clear.py new file mode 100644 index 0000000000..07abff1a76 --- /dev/null +++ b/tests/input/test_input_clear.py @@ -0,0 +1,16 @@ +from textual.app import App, ComposeResult +from textual.widgets import Input + + +class InputApp(App): + def compose(self) -> ComposeResult: + yield Input("Hello, World!") + + +async def test_input_clear(): + async with InputApp().run_test() as pilot: + input_widget = pilot.app.query_one(Input) + assert input_widget.value == "Hello, World!" + input_widget.clear() + await pilot.pause() + assert input_widget.value == "" diff --git a/tests/input/test_input_mouse.py b/tests/input/test_input_mouse.py index 491f18fda7..a5249e5498 100644 --- a/tests/input/test_input_mouse.py +++ b/tests/input/test_input_mouse.py @@ -34,7 +34,7 @@ def compose(self) -> ComposeResult: (TEXT_SINGLE, 10, 10), (TEXT_SINGLE, len(TEXT_SINGLE) - 1, len(TEXT_SINGLE) - 1), (TEXT_SINGLE, len(TEXT_SINGLE), len(TEXT_SINGLE)), - (TEXT_SINGLE, len(TEXT_SINGLE) * 2, len(TEXT_SINGLE)), + (TEXT_SINGLE, len(TEXT_SINGLE) + 10, len(TEXT_SINGLE)), # Double-width characters (TEXT_DOUBLE, 0, 0), (TEXT_DOUBLE, 1, 0), @@ -55,7 +55,7 @@ def compose(self) -> ComposeResult: (TEXT_MIXED, 13, 9), (TEXT_MIXED, 14, 9), (TEXT_MIXED, 15, 10), - (TEXT_MIXED, 1000, 10), + (TEXT_MIXED, 60, 10), ), ) async def test_mouse_clicks_within(text, click_at, should_land): diff --git a/tests/input/test_input_restrict.py b/tests/input/test_input_restrict.py new file mode 100644 index 0000000000..12100811ac --- /dev/null +++ b/tests/input/test_input_restrict.py @@ -0,0 +1,145 @@ +import re + +import pytest + +from textual.app import App, ComposeResult +from textual.widgets import Input +from textual.widgets._input import _RESTRICT_TYPES + + +def test_input_number_type(): + """Test number type regex.""" + number = _RESTRICT_TYPES["number"] + assert re.fullmatch(number, "0") + assert re.fullmatch(number, "0.") + assert re.fullmatch(number, ".") + assert re.fullmatch(number, ".0") + assert re.fullmatch(number, "1.1") + assert re.fullmatch(number, "1e1") + assert re.fullmatch(number, "1.2e") + assert re.fullmatch(number, "1.2e10") + assert re.fullmatch(number, "1.2E10") + assert re.fullmatch(number, "1.2e-") + assert re.fullmatch(number, "1.2e-10") + assert not re.fullmatch(number, "1.2e10e") + assert not re.fullmatch(number, "1f2") + assert not re.fullmatch(number, "inf") + assert not re.fullmatch(number, "nan") + + +def test_input_integer_type(): + """Test input type regex""" + integer = _RESTRICT_TYPES["integer"] + assert re.fullmatch(integer, "0") + assert re.fullmatch(integer, "1") + assert re.fullmatch(integer, "10") + assert re.fullmatch(integer, "123456789") + assert re.fullmatch(integer, "-") + assert re.fullmatch(integer, "+") + assert re.fullmatch(integer, "-1") + assert re.fullmatch(integer, "+2") + assert not re.fullmatch(integer, "+2e") + assert not re.fullmatch(integer, "foo") + + +async def test_bad_type(): + """Check an invalid type raises a ValueError.""" + + class InputApp(App): + def compose(self) -> ComposeResult: + yield Input(type="foo") # Bad type + + app = InputApp() + + with pytest.raises(ValueError): + async with app.run_test(): + pass + + +async def test_max_length(): + """Check max_length limits characters.""" + + class InputApp(App): + AUTO_FOCUS = "Input" + + def compose(self) -> ComposeResult: + yield Input(max_length=5) + + async with InputApp().run_test() as pilot: + input_widget = pilot.app.query_one(Input) + await pilot.press("1") + assert input_widget.value == "1" + await pilot.press("2", "3", "4", "5") + assert input_widget.value == "12345" + # Value is max length, no more characters are permitted + await pilot.press("6") + assert input_widget.value == "12345" + await pilot.press("7") + assert input_widget.value == "12345" + # Backspace is ok + await pilot.press("backspace") + assert input_widget.value == "1234" + await pilot.press("0") + assert input_widget.value == "12340" + # Back to maximum + await pilot.press("1") + assert input_widget.value == "12340" + + +async def test_restrict(): + """Test restriction by regex.""" + + class InputApp(App): + AUTO_FOCUS = "Input" + + def compose(self) -> ComposeResult: + yield Input(restrict="[abc]*") + + async with InputApp().run_test() as pilot: + input_widget = pilot.app.query_one(Input) + await pilot.press("a") + assert input_widget.value == "a" + await pilot.press("b") + assert input_widget.value == "ab" + await pilot.press("c") + assert input_widget.value == "abc" + # "d" is restricted + await pilot.press("d") + assert input_widget.value == "abc" + # "a" is not + await pilot.press("a") + assert input_widget.value == "abca" + + +async def test_restrict_type(): + class InputApp(App): + def compose(self) -> ComposeResult: + yield Input(type="integer", id="integer") + yield Input(type="number", id="number") + yield Input(type="text", id="text") + + async with InputApp().run_test() as pilot: + integer_input = pilot.app.query_one("#integer", Input) + number_input = pilot.app.query_one("#number", Input) + text_input = pilot.app.query_one("#text", Input) + + integer_input.focus() + await pilot.press("a") + assert integer_input.value == "" + + await pilot.press("-") + assert integer_input.is_valid is False + + await pilot.press("1") + assert integer_input.value == "-1" + assert integer_input.is_valid is True + + number_input.focus() + await pilot.press("x") + assert number_input.value == "" + await pilot.press("-", "3", ".", "1", "4", "y") + assert number_input.value == "-3.14" + + text_input.focus() + await pilot.press("!", "x", "9") + assert text_input.value == "!x9" diff --git a/tests/input/test_input_terminal_cursor.py b/tests/input/test_input_terminal_cursor.py new file mode 100644 index 0000000000..b956a29846 --- /dev/null +++ b/tests/input/test_input_terminal_cursor.py @@ -0,0 +1,28 @@ +from textual.app import App, ComposeResult +from textual.geometry import Offset +from textual.widgets import Input + + +class InputApp(App): + # Apply padding to ensure gutter accounted for. + CSS = "Input { padding: 4 8 }" + + def compose(self) -> ComposeResult: + yield Input("こんにちは!") + + +async def test_initial_terminal_cursor_position(): + app = InputApp() + async with app.run_test(): + # The input is focused so the terminal cursor position should update. + assert app.cursor_position == Offset(21, 5) + + +async def test_terminal_cursor_position_update_on_cursor_move(): + app = InputApp() + async with app.run_test(): + input_widget = app.query_one(Input) + input_widget.action_cursor_left() + input_widget.action_cursor_left() + # We went left over two double-width characters + assert app.cursor_position == Offset(17, 5) diff --git a/tests/input/test_input_validation.py b/tests/input/test_input_validation.py index cfbdf32928..0f70a98df8 100644 --- a/tests/input/test_input_validation.py +++ b/tests/input/test_input_validation.py @@ -2,7 +2,6 @@ from textual import on from textual.app import App, ComposeResult -from textual.events import Blur from textual.validation import Number, ValidationResult from textual.widgets import Input @@ -31,6 +30,7 @@ async def test_input_changed_message_validation_failure(): async with app.run_test() as pilot: input = app.query_one(Input) input.value = "8" + assert not input.is_valid await pilot.pause() assert len(app.messages) == 1 assert app.messages[0].validation_result == ValidationResult.failure( @@ -205,3 +205,19 @@ async def test_none_validate_on_means_all_validations_happen(): app.set_focus(None) await pilot.pause() assert input.has_class("-valid") + + +async def test_valid_empty(): + app = InputApp(None) + async with app.run_test() as pilot: + input = app.query_one(Input) + + await pilot.press("1", "backspace") + + assert not input.has_class("-valid") + assert input.has_class("-invalid") + + input.valid_empty = True + + assert input.has_class("-valid") + assert not input.has_class("-invalid") diff --git a/tests/notifications/test_app_notifications.py b/tests/notifications/test_app_notifications.py index 608fde812f..d5230bd919 100644 --- a/tests/notifications/test_app_notifications.py +++ b/tests/notifications/test_app_notifications.py @@ -1,4 +1,4 @@ -from time import sleep +import asyncio from textual.app import App @@ -34,12 +34,12 @@ async def test_app_with_removing_notifications() -> None: async def test_app_with_notifications_that_expire() -> None: """Notifications should expire from an app.""" async with NotificationApp().run_test() as pilot: - for n in range(100): - pilot.app.notify("test", timeout=(0.5 if bool(n % 2) else 60)) - await pilot.pause() - assert len(pilot.app._notifications) == 100 - sleep(0.6) - assert len(pilot.app._notifications) == 50 + for n in range(10): + pilot.app.notify("test", timeout=(0.01 if bool(n % 2) else 60)) + + # Wait until the 0.01 timeout expires on all notifications (plus some leeway) + await asyncio.sleep(0.25) + assert len(pilot.app._notifications) == 5 async def test_app_clearing_notifications() -> None: diff --git a/tests/option_list/test_option_list_create.py b/tests/option_list/test_option_list_create.py index 7e0bbc49c2..3310579b81 100644 --- a/tests/option_list/test_option_list_create.py +++ b/tests/option_list/test_option_list_create.py @@ -119,5 +119,37 @@ async def test_add_later() -> None: async def test_create_with_duplicate_id() -> None: """Adding an option with a duplicate ID should be an error.""" async with OptionListApp().run_test() as pilot: + option_list = pilot.app.query_one(OptionList) + assert option_list.option_count == 5 + with pytest.raises(DuplicateID): + option_list.add_option(Option("dupe", id="3")) + assert option_list.option_count == 5 + + +async def test_create_with_duplicate_id_and_subsequent_non_dupes() -> None: + """Adding an option with a duplicate ID should be an error.""" + async with OptionListApp().run_test() as pilot: + option_list = pilot.app.query_one(OptionList) + assert option_list.option_count == 5 with pytest.raises(DuplicateID): - pilot.app.query_one(OptionList).add_option(Option("dupe", id="3")) + option_list.add_option(Option("dupe", id="3")) + assert option_list.option_count == 5 + option_list.add_option(Option("Not a dupe", id="6")) + assert option_list.option_count == 6 + option_list.add_option(Option("Not a dupe", id="7")) + assert option_list.option_count == 7 + + +async def test_adding_multiple_duplicates_at_once() -> None: + """Adding duplicates together than aren't existing duplicates should be an error.""" + async with OptionListApp().run_test() as pilot: + option_list = pilot.app.query_one(OptionList) + assert option_list.option_count == 5 + with pytest.raises(DuplicateID): + option_list.add_options( + [ + Option("dupe", id="42"), + Option("dupe", id="42"), + ] + ) + assert option_list.option_count == 5 diff --git a/tests/select/test_blank_and_clear.py b/tests/select/test_blank_and_clear.py new file mode 100644 index 0000000000..5fa24cd2a4 --- /dev/null +++ b/tests/select/test_blank_and_clear.py @@ -0,0 +1,65 @@ +import pytest + +from textual.app import App +from textual.widgets import Select +from textual.widgets.select import InvalidSelectValueError + +SELECT_OPTIONS = [(str(n), n) for n in range(3)] + + +async def test_value_is_blank_by_default(): + class SelectApp(App[None]): + def compose(self): + yield Select(SELECT_OPTIONS) + + app = SelectApp() + async with app.run_test(): + select = app.query_one(Select) + assert select.value == Select.BLANK + assert select.is_blank() + + +async def test_setting_and_checking_blank(): + class SelectApp(App[None]): + def compose(self): + yield Select(SELECT_OPTIONS) + + app = SelectApp() + async with app.run_test(): + select = app.query_one(Select) + assert select.value == Select.BLANK + assert select.is_blank() + + select.value = 0 + assert select.value != Select.BLANK + assert not select.is_blank() + + select.value = Select.BLANK + assert select.value == Select.BLANK + assert select.is_blank() + + +async def test_clear_with_allow_blanks(): + class SelectApp(App[None]): + def compose(self): + yield Select(SELECT_OPTIONS, value=1) + + app = SelectApp() + async with app.run_test(): + select = app.query_one(Select) + assert select.value == 1 # Sanity check. + select.clear() + assert select.is_blank() + + +async def test_clear_fails_if_allow_blank_is_false(): + class SelectApp(App[None]): + def compose(self): + yield Select(SELECT_OPTIONS, allow_blank=False) + + app = SelectApp() + async with app.run_test(): + select = app.query_one(Select) + assert not select.is_blank() + with pytest.raises(InvalidSelectValueError): + select.clear() diff --git a/tests/select/test_changed_message.py b/tests/select/test_changed_message.py new file mode 100644 index 0000000000..7f876ac1ff --- /dev/null +++ b/tests/select/test_changed_message.py @@ -0,0 +1,57 @@ +from textual import on +from textual.app import App +from textual.widgets import Select +from textual.widgets._select import SelectOverlay + + +class SelectApp(App[None]): + def __init__(self): + self.changed_messages = [] + super().__init__() + + def compose(self): + yield Select[int]([(str(n), n) for n in range(3)]) + + @on(Select.Changed) + def add_message(self, event): + self.changed_messages.append(event) + + +async def test_message_control(): + app = SelectApp() + async with app.run_test() as pilot: + await pilot.click(Select) + await pilot.click(SelectOverlay, offset=(2, 3)) + await pilot.pause() + message = app.changed_messages[0] + assert message.control is app.query_one(Select) + + +async def test_selecting_posts_message(): + app = SelectApp() + async with app.run_test() as pilot: + await pilot.click(Select) + # Click on the 1. + await pilot.click(SelectOverlay, offset=(2, 3)) + await pilot.pause() + assert len(app.changed_messages) == 1 + await pilot.click(Select) + # Click on the 2. + await pilot.click(SelectOverlay, offset=(2, 4)) + await pilot.pause() + assert len(app.changed_messages) == 2 + + +async def test_same_selection_does_not_post_message(): + app = SelectApp() + async with app.run_test() as pilot: + await pilot.click(Select) + # Click on the 1. + await pilot.click(SelectOverlay, offset=(2, 3)) + await pilot.pause() + assert len(app.changed_messages) == 1 + await pilot.click(Select) + # Click on the 1 again... + await pilot.click(SelectOverlay, offset=(2, 3)) + await pilot.pause() + assert len(app.changed_messages) == 1 diff --git a/tests/select/test_empty_select.py b/tests/select/test_empty_select.py new file mode 100644 index 0000000000..75bf722eb1 --- /dev/null +++ b/tests/select/test_empty_select.py @@ -0,0 +1,53 @@ +import pytest + +from textual.app import App +from textual.widgets import Select +from textual.widgets.select import EmptySelectError + + +async def test_empty_select_is_ok_with_blanks(): + class SelectApp(App[None]): + def compose(self): + yield Select([]) + + app = SelectApp() + async with app.run_test(): + # Sanity check: + assert app.query_one(Select).is_blank() + + +async def test_empty_set_options_is_ok_with_blanks(): + class SelectApp(App[None]): + def compose(self): + yield Select([(str(n), n) for n in range(3)], value=0) + + app = SelectApp() + async with app.run_test(): + select = app.query_one(Select) + assert not select.is_blank() # Sanity check. + select.set_options([]) + assert select.is_blank() # Sanity check. + + +async def test_empty_select_raises_exception_if_allow_blank_is_false(): + class SelectApp(App[None]): + def compose(self): + yield Select([], allow_blank=False) + + app = SelectApp() + with pytest.raises(EmptySelectError): + async with app.run_test(): + pass + + +async def test_empty_set_options_raises_exception_if_allow_blank_is_false(): + class SelectApp(App[None]): + def compose(self): + yield Select([(str(n), n) for n in range(3)], allow_blank=False) + + app = SelectApp() + async with app.run_test(): + select = app.query_one(Select) + assert not select.is_blank() # Sanity check. + with pytest.raises(EmptySelectError): + select.set_options([]) diff --git a/tests/select/test_initial_value.py b/tests/select/test_initial_value.py deleted file mode 100644 index 49aaceb184..0000000000 --- a/tests/select/test_initial_value.py +++ /dev/null @@ -1,16 +0,0 @@ -"""Initially https://github.com/Textualize/textual/discussions/3037""" - -from textual.app import App, ComposeResult -from textual.widgets import Select - - -class SelectApp(App[None]): - INITIAL_VALUE = 3 - - def compose(self) -> ComposeResult: - yield Select[int]([(str(n), n) for n in range(10)], value=self.INITIAL_VALUE) - - -async def test_select_initial_value(): - async with SelectApp().run_test() as pilot: - assert pilot.app.query_one(Select).value == SelectApp.INITIAL_VALUE diff --git a/tests/select/test_prompt.py b/tests/select/test_prompt.py new file mode 100644 index 0000000000..8c66df1af8 --- /dev/null +++ b/tests/select/test_prompt.py @@ -0,0 +1,54 @@ +from rich.text import Text + +from textual.app import App +from textual.widgets import Select, Static +from textual.widgets._select import SelectCurrent, SelectOverlay + + +async def test_reactive_prompt_change(): + """Regression test for https://github.com/Textualize/textual/issues/2983""" + + class SelectApp(App): + def compose(self): + yield Select[int]( + [(str(n), n) for n in range(3)], + prompt="Old prompt", + ) + + app = SelectApp() + async with app.run_test() as pilot: + select_widget = pilot.app.query_one(Select) + select_current = select_widget.query_one(SelectCurrent) + select_current_label = select_current.query_one("#label", Static) + select_overlay = select_widget.query_one(SelectOverlay) + + assert select_current_label.renderable == Text("Old prompt") + assert select_overlay._options[0].prompt == Text("Old prompt") + + select_widget.prompt = "New prompt" + assert select_current_label.renderable == Text("New prompt") + assert select_overlay._options[0].prompt == Text("New prompt") + + +async def test_reactive_prompt_change_when_allow_blank_is_false(): + class SelectApp(App): + def compose(self): + yield Select[int]( + [(str(n), n) for n in range(3)], + prompt="Old prompt", + allow_blank=False, + ) + + app = SelectApp() + async with app.run_test() as pilot: + select_widget = pilot.app.query_one(Select) + select_current = select_widget.query_one(SelectCurrent) + select_current_label = select_current.query_one("#label", Static) + select_overlay = select_widget.query_one(SelectOverlay) + + assert select_current_label.renderable == Text("0") + assert select_overlay._options[0].prompt == "0" + + select_widget.prompt = "New prompt" + assert select_current_label.renderable == Text("0") + assert select_overlay._options[0].prompt == "0" diff --git a/tests/select/test_value.py b/tests/select/test_value.py new file mode 100644 index 0000000000..c47e285005 --- /dev/null +++ b/tests/select/test_value.py @@ -0,0 +1,127 @@ +import pytest + +from textual.app import App +from textual.widgets import Select +from textual.widgets.select import InvalidSelectValueError + +SELECT_OPTIONS = [(str(n), n) for n in range(3)] +MORE_OPTIONS = [(str(n), n) for n in range(5, 8)] + + +class SelectApp(App[None]): + def __init__(self, initial_value=Select.BLANK): + self.initial_value = initial_value + super().__init__() + + def compose(self): + yield Select[int](SELECT_OPTIONS, value=self.initial_value) + + +async def test_initial_value_is_validated(): + """The initial value should be respected if it is a legal value. + + Regression test for https://github.com/Textualize/textual/discussions/3037. + """ + app = SelectApp(1) + async with app.run_test(): + assert app.query_one(Select).value == 1 + + +async def test_value_unknown_option_raises_error(): + """Setting the value to an unknown value raises an error.""" + app = SelectApp() + async with app.run_test(): + with pytest.raises(InvalidSelectValueError): + app.query_one(Select).value = "french fries" + + +async def test_initial_value_inside_compose_is_validated(): + """Setting the value to an unknown value inside compose should raise an error.""" + + class SelectApp(App[None]): + def compose(self): + s = Select[int](SELECT_OPTIONS) + s.value = 73 + yield s + + app = SelectApp() + with pytest.raises(InvalidSelectValueError): + async with app.run_test(): + pass + + +async def test_value_assign_to_blank(): + """Setting the value to BLANK should work with default `allow_blank` value.""" + app = SelectApp(1) + async with app.run_test(): + select = app.query_one(Select) + assert select.value == 1 + select.value = Select.BLANK + assert select.is_blank() + + +async def test_initial_value_is_picked_if_allow_blank_is_false(): + """The initial value should be picked by default if allow_blank=False.""" + + class SelectApp(App[None]): + def compose(self): + yield Select[int](SELECT_OPTIONS, allow_blank=False) + + app = SelectApp() + async with app.run_test(): + assert app.query_one(Select).value == 0 + + +async def test_initial_value_is_picked_if_allow_blank_is_false(): + """The initial value should be respected even if allow_blank=False.""" + + class SelectApp(App[None]): + def compose(self): + yield Select[int](SELECT_OPTIONS, value=2, allow_blank=False) + + app = SelectApp() + async with app.run_test(): + assert app.query_one(Select).value == 2 + + +async def test_set_value_to_blank_with_allow_blank_false(): + """Setting the value to BLANK with allow_blank=False should raise an error.""" + + class SelectApp(App[None]): + def compose(self): + yield Select[int](SELECT_OPTIONS, allow_blank=False) + + app = SelectApp() + async with app.run_test(): + with pytest.raises(InvalidSelectValueError): + app.query_one(Select).value = Select.BLANK + + +async def test_set_options_resets_value_to_blank(): + """Resetting the options should reset the value to BLANK.""" + + class SelectApp(App[None]): + def compose(self): + yield Select[int](SELECT_OPTIONS, value=2) + + app = SelectApp() + async with app.run_test(): + select = app.query_one(Select) + assert select.value == 2 + select.set_options(MORE_OPTIONS) + assert select.is_blank() + + +async def test_set_options_resets_value_if_allow_blank_is_false(): + """Resetting the options should reset the value if allow_blank=False.""" + + class SelectApp(App[None]): + def compose(self): + yield Select[int](SELECT_OPTIONS, allow_blank=False) + + app = SelectApp() + async with app.run_test(): + select = app.query_one(Select) + assert select.value == 0 + select.set_options(MORE_OPTIONS) + assert select.value > 2 diff --git a/tests/snapshot_tests/__snapshots__/test_snapshots.ambr b/tests/snapshot_tests/__snapshots__/test_snapshots.ambr index e95c8e6a89..c322a2b69f 100644 --- a/tests/snapshot_tests/__snapshots__/test_snapshots.ambr +++ b/tests/snapshot_tests/__snapshots__/test_snapshots.ambr @@ -21,138 +21,139 @@ font-weight: 700; } - .terminal-2137082507-matrix { + .terminal-904862513-matrix { font-family: Fira Code, monospace; font-size: 20px; line-height: 24.4px; font-variant-east-asian: full-width; } - .terminal-2137082507-title { + .terminal-904862513-title { font-size: 18px; font-weight: bold; font-family: arial; } - .terminal-2137082507-r1 { fill: #c5c8c6 } - .terminal-2137082507-r2 { fill: #7ae998 } - .terminal-2137082507-r3 { fill: #4ebf71;font-weight: bold } - .terminal-2137082507-r4 { fill: #008139 } - .terminal-2137082507-r5 { fill: #e3dbce } - .terminal-2137082507-r6 { fill: #e1e1e1 } - .terminal-2137082507-r7 { fill: #e76580 } - .terminal-2137082507-r8 { fill: #f5e5e9;font-weight: bold } - .terminal-2137082507-r9 { fill: #780028 } + .terminal-904862513-r1 { fill: #cad1d6 } + .terminal-904862513-r2 { fill: #7ae998 } + .terminal-904862513-r3 { fill: #c5c8c6 } + .terminal-904862513-r4 { fill: #4ebf71;font-weight: bold } + .terminal-904862513-r5 { fill: #008139 } + .terminal-904862513-r6 { fill: #e3dbce } + .terminal-904862513-r7 { fill: #e1e1e1 } + .terminal-904862513-r8 { fill: #e76580 } + .terminal-904862513-r9 { fill: #f5e5e9;font-weight: bold } + .terminal-904862513-r10 { fill: #780028 } - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - AlignContainersApp + AlignContainersApp - - - - ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ -  center  - ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ - - - - - - - - - - ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ -  middle  - ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ - - - - - - - - + + + + ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ + center + ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ + + + + + + + + + + ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ + middle + ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ + + + + + + + + @@ -183,141 +184,141 @@ font-weight: 700; } - .terminal-3675381774-matrix { + .terminal-3685857257-matrix { font-family: Fira Code, monospace; font-size: 20px; line-height: 24.4px; font-variant-east-asian: full-width; } - .terminal-3675381774-title { + .terminal-3685857257-title { font-size: 18px; font-weight: bold; font-family: arial; } - .terminal-3675381774-r1 { fill: #00ffff } - .terminal-3675381774-r2 { fill: #c5c8c6 } - .terminal-3675381774-r3 { fill: #e1e1e1 } - .terminal-3675381774-r4 { fill: #008000 } - .terminal-3675381774-r5 { fill: #ff0000 } - .terminal-3675381774-r6 { fill: #e1e1e1;font-weight: bold } - .terminal-3675381774-r7 { fill: #dde6ed } + .terminal-3685857257-r1 { fill: #00ffff } + .terminal-3685857257-r2 { fill: #c5c8c6 } + .terminal-3685857257-r3 { fill: #e1e1e1 } + .terminal-3685857257-r4 { fill: #008000 } + .terminal-3685857257-r5 { fill: #ff0000 } + .terminal-3685857257-r6 { fill: #e1e1e1;font-weight: bold } + .terminal-3685857257-r7 { fill: #dde6ed } - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - FRApp + FRApp - + - - ────────────────────────────────────────────────────────────────────────────── - ──────────────────────────── - Hello one line - ────────────────────────── - Widget#child - - - - - - - - - - - - - - ────────────────────────── - - Two - Lines with 1x2 margin - - ──────────────────────────── - ────────────────────────────────────────────────────────────────────────────── + + ────────────────────────────────────────────────────────────────────────────── + ──────────────────────────── + Hello one line + ────────────────────────── + Widget#child + + + + + + + + + + + + + + ────────────────────────── + + Two + Lines with 1x2 margin + + ──────────────────────────── + ────────────────────────────────────────────────────────────────────────────── @@ -347,136 +348,136 @@ font-weight: 700; } - .terminal-2688126662-matrix { + .terminal-2376794324-matrix { font-family: Fira Code, monospace; font-size: 20px; line-height: 24.4px; font-variant-east-asian: full-width; } - .terminal-2688126662-title { + .terminal-2376794324-title { font-size: 18px; font-weight: bold; font-family: arial; } - .terminal-2688126662-r1 { fill: #008000 } - .terminal-2688126662-r2 { fill: #c5c8c6 } - .terminal-2688126662-r3 { fill: #e1e1e1 } - .terminal-2688126662-r4 { fill: #1e1e1e } - .terminal-2688126662-r5 { fill: #121212 } - .terminal-2688126662-r6 { fill: #e2e2e2 } + .terminal-2376794324-r1 { fill: #008000 } + .terminal-2376794324-r2 { fill: #c5c8c6 } + .terminal-2376794324-r3 { fill: #e1e1e1 } + .terminal-2376794324-r4 { fill: #1e1e1e } + .terminal-2376794324-r5 { fill: #121212 } + .terminal-2376794324-r6 { fill: #e2e2e2 } - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - GridApp + GridApp - + - - ────────────────────────────────────────────────────────────────────────────── - foo▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ - - ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ - Longer label▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ - - ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ - ────────────────────────────────────────────────────────────────────────────── - ────────────────────────────────────────────────────────────────────────────── - foo▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ - - ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ - Longer label▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ - - ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ - ────────────────────────────────────────────────────────────────────────────── - ────────────────────────────────────────────────────────────────────────────── - foo bar foo bar foo bar foo ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ - bar foo bar foo bar foo bar  - foo bar foo bar foo bar ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ - Longer label▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ - - ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ - ────────────────────────────────────────────────────────────────────────────── + + ────────────────────────────────────────────────────────────────────────────── + foo▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ + + ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ + Longer label▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ + + ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ + ────────────────────────────────────────────────────────────────────────────── + ────────────────────────────────────────────────────────────────────────────── + foo▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ + + ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ + Longer label▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ + + ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ + ────────────────────────────────────────────────────────────────────────────── + ────────────────────────────────────────────────────────────────────────────── + foo bar foo bar foo bar foo ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ + bar foo bar foo bar foo bar  + foo bar foo bar foo bar ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ + Longer label▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ + + ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ + ────────────────────────────────────────────────────────────────────────────── @@ -506,136 +507,136 @@ font-weight: 700; } - .terminal-3298188965-matrix { + .terminal-3669917786-matrix { font-family: Fira Code, monospace; font-size: 20px; line-height: 24.4px; font-variant-east-asian: full-width; } - .terminal-3298188965-title { + .terminal-3669917786-title { font-size: 18px; font-weight: bold; font-family: arial; } - .terminal-3298188965-r1 { fill: #c5c8c6 } - .terminal-3298188965-r2 { fill: #e3e3e3 } - .terminal-3298188965-r3 { fill: #e1e1e1 } - .terminal-3298188965-r4 { fill: #ff0000 } - .terminal-3298188965-r5 { fill: #dde8f3;font-weight: bold } - .terminal-3298188965-r6 { fill: #ddedf9 } + .terminal-3669917786-r1 { fill: #c5c8c6 } + .terminal-3669917786-r2 { fill: #e3e3e3 } + .terminal-3669917786-r3 { fill: #e1e1e1 } + .terminal-3669917786-r4 { fill: #ff0000 } + .terminal-3669917786-r5 { fill: #dde8f3;font-weight: bold } + .terminal-3669917786-r6 { fill: #ddedf9 } - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - GridHeightAuto + GridHeightAuto - + - - GridHeightAuto - Here is some text before the grid - ────────────────────────────────────────────────────────────────────────────── - Cell #0Cell #1Cell #2 - Cell #3Cell #4Cell #5 - Cell #6Cell #7Cell #8 - ────────────────────────────────────────────────────────────────────────────── - Here is some text after the grid - - - - - - - - - - - - - - - -  G  Grid  V  Vertical  H  Horizontal  C  Container  + + GridHeightAuto + Here is some text before the grid + ────────────────────────────────────────────────────────────────────────────── + Cell #0Cell #1Cell #2 + Cell #3Cell #4Cell #5 + Cell #6Cell #7Cell #8 + ────────────────────────────────────────────────────────────────────────────── + Here is some text after the grid + + + + + + + + + + + + + + + +  G  Grid  V  Vertical  H  Horizontal  C  Container  @@ -665,202 +666,202 @@ font-weight: 700; } - .terminal-1995799848-matrix { + .terminal-3752476664-matrix { font-family: Fira Code, monospace; font-size: 20px; line-height: 24.4px; font-variant-east-asian: full-width; } - .terminal-1995799848-title { + .terminal-3752476664-title { font-size: 18px; font-weight: bold; font-family: arial; } - .terminal-1995799848-r1 { fill: #c5c8c6 } - .terminal-1995799848-r2 { fill: #e3e3e3 } - .terminal-1995799848-r3 { fill: #004578 } - .terminal-1995799848-r4 { fill: #e1e1e1 } - .terminal-1995799848-r5 { fill: #632ca6 } - .terminal-1995799848-r6 { fill: #dde6ed;font-weight: bold } - .terminal-1995799848-r7 { fill: #14191f } - .terminal-1995799848-r8 { fill: #23568b } + .terminal-3752476664-r1 { fill: #c5c8c6 } + .terminal-3752476664-r2 { fill: #e3e3e3 } + .terminal-3752476664-r3 { fill: #004578 } + .terminal-3752476664-r4 { fill: #e1e1e1 } + .terminal-3752476664-r5 { fill: #632ca6 } + .terminal-3752476664-r6 { fill: #dde6ed;font-weight: bold } + .terminal-3752476664-r7 { fill: #14191f } + .terminal-3752476664-r8 { fill: #23568b } - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - MyApp + MyApp - - - - MyApp - ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────── - oktest - ╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍ -  0 ────────────────────────────────────── 1 ────────────────────────────────────── 2 ───── - -  Foo       Bar         Baz               Foo       Bar         Baz               Foo      -  ABCDEFGH  0123456789  IJKLMNOPQRSTUVWXY ABCDEFGH  0123456789  IJKLMNOPQRSTUVWXY ABCDEFGH -  ABCDEFGH  0123456789  IJKLMNOPQRSTUVWXY ABCDEFGH  0123456789  IJKLMNOPQRSTUVWXY ABCDEFGH -  ABCDEFGH  0123456789  IJKLMNOPQRSTUVWXY ABCDEFGH  0123456789  IJKLMNOPQRSTUVWXY ABCDEFGH -  ABCDEFGH  0123456789  IJKLMNOPQRSTUVWXY ABCDEFGH  0123456789  IJKLMNOPQRSTUVWXY ABCDEFGH -  ABCDEFGH  0123456789  IJKLMNOPQRSTUVWXY ABCDEFGH  0123456789  IJKLMNOPQRSTUVWXY ABCDEFGH -  ABCDEFGH  0123456789  IJKLMNOPQRSTUVWXY ABCDEFGH  0123456789  IJKLMNOPQRSTUVWXY ABCDEFGH -  ABCDEFGH  0123456789  IJKLMNOPQRSTUVWXY ABCDEFGH  0123456789  IJKLMNOPQRSTUVWXY ABCDEFGH -  ABCDEFGH  0123456789  IJKLMNOPQRSTUVWXY ABCDEFGH  0123456789  IJKLMNOPQRSTUVWXY ABCDEFGH -  ABCDEFGH  0123456789  IJKLMNOPQRSTUVWXY ABCDEFGH  0123456789  IJKLMNOPQRSTUVWXY ABCDEFGH -  ABCDEFGH  0123456789  IJKLMNOPQRSTUVWXY ABCDEFGH  0123456789  IJKLMNOPQRSTUVWXY ABCDEFGH -  ABCDEFGH  0123456789  IJKLMNOPQRSTUVWXY ABCDEFGH  0123456789  IJKLMNOPQRSTUVWXY ABCDEFGH -  ABCDEFGH  0123456789  IJKLMNOPQRSTUVWXY ABCDEFGH  0123456789  IJKLMNOPQRSTUVWXY ABCDEFGH -  ABCDEFGH  0123456789  IJKLMNOPQRSTUVWXY ABCDEFGH  0123456789  IJKLMNOPQRSTUVWXY ABCDEFGH -  ABCDEFGH  0123456789  IJKLMNOPQRSTUVWXY ABCDEFGH  0123456789  IJKLMNOPQRSTUVWXY ABCDEFGH -  ABCDEFGH  0123456789  IJKLMNOPQRSTUVWXY ABCDEFGH  0123456789  IJKLMNOPQRSTUVWXY ABCDEFGH -  ABCDEFGH  0123456789  IJKLMNOPQRSTUVWXY ABCDEFGH  0123456789  IJKLMNOPQRSTUVWXY ABCDEFGH -  ABCDEFGH  0123456789  IJKLMNOPQRSTUVWXY ABCDEFGH  0123456789  IJKLMNOPQRSTUVWXY ABCDEFGH -  ABCDEFGH  0123456789  IJKLMNOPQRSTUVWXY▁▁ ABCDEFGH  0123456789  IJKLMNOPQRSTUVWXY▁▁ ABCDEFGH -  ABCDEFGH  0123456789  IJKLMNOPQRSTUVWXY ABCDEFGH  0123456789  IJKLMNOPQRSTUVWXY ABCDEFGH -  ABCDEFGH  0123456789  IJKLMNOPQRSTUVWXY ABCDEFGH  0123456789  IJKLMNOPQRSTUVWXY ABCDEFGH -  ABCDEFGH  0123456789  IJKLMNOPQRSTUVWXY ABCDEFGH  0123456789  IJKLMNOPQRSTUVWXY ABCDEFGH -  ABCDEFGH  0123456789  IJKLMNOPQRSTUVWXY ABCDEFGH  0123456789  IJKLMNOPQRSTUVWXY ABCDEFGH -  ABCDEFGH  0123456789  IJKLMNOPQRSTUVWXY ABCDEFGH  0123456789  IJKLMNOPQRSTUVWXY ABCDEFGH -  ABCDEFGH  0123456789  IJKLMNOPQRSTUVWXY ABCDEFGH  0123456789  IJKLMNOPQRSTUVWXY ABCDEFGH -  ABCDEFGH  0123456789  IJKLMNOPQRSTUVWXY ABCDEFGH  0123456789  IJKLMNOPQRSTUVWXY ABCDEFGH -  ABCDEFGH  0123456789  IJKLMNOPQRSTUVWXY ABCDEFGH  0123456789  IJKLMNOPQRSTUVWXY ABCDEFGH -  ABCDEFGH  0123456789  IJKLMNOPQRSTUVWXY ABCDEFGH  0123456789  IJKLMNOPQRSTUVWXY ABCDEFGH -  ABCDEFGH  0123456789  IJKLMNOPQRSTUVWXY ABCDEFGH  0123456789  IJKLMNOPQRSTUVWXY ABCDEFGH -  ABCDEFGH  0123456789  IJKLMNOPQRSTUVWXY ABCDEFGH  0123456789  IJKLMNOPQRSTUVWXY ABCDEFGH -  ABCDEFGH  0123456789  IJKLMNOPQRSTUVWXY ABCDEFGH  0123456789  IJKLMNOPQRSTUVWXY ABCDEFGH - ───────────────────────────────────────────────────────────────────────────────────────────── - - ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────── + + + + MyApp + ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────── + oktest + ╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍ +  0 ────────────────────────────────────── 1 ────────────────────────────────────── 2 ───── + +  Foo       Bar         Baz               Foo       Bar         Baz               Foo      +  ABCDEFGH  0123456789  IJKLMNOPQRSTUVWXY ABCDEFGH  0123456789  IJKLMNOPQRSTUVWXY ABCDEFGH +  ABCDEFGH  0123456789  IJKLMNOPQRSTUVWXY ABCDEFGH  0123456789  IJKLMNOPQRSTUVWXY ABCDEFGH +  ABCDEFGH  0123456789  IJKLMNOPQRSTUVWXY ABCDEFGH  0123456789  IJKLMNOPQRSTUVWXY ABCDEFGH +  ABCDEFGH  0123456789  IJKLMNOPQRSTUVWXY ABCDEFGH  0123456789  IJKLMNOPQRSTUVWXY ABCDEFGH +  ABCDEFGH  0123456789  IJKLMNOPQRSTUVWXY ABCDEFGH  0123456789  IJKLMNOPQRSTUVWXY ABCDEFGH +  ABCDEFGH  0123456789  IJKLMNOPQRSTUVWXY ABCDEFGH  0123456789  IJKLMNOPQRSTUVWXY ABCDEFGH +  ABCDEFGH  0123456789  IJKLMNOPQRSTUVWXY ABCDEFGH  0123456789  IJKLMNOPQRSTUVWXY ABCDEFGH +  ABCDEFGH  0123456789  IJKLMNOPQRSTUVWXY ABCDEFGH  0123456789  IJKLMNOPQRSTUVWXY ABCDEFGH +  ABCDEFGH  0123456789  IJKLMNOPQRSTUVWXY ABCDEFGH  0123456789  IJKLMNOPQRSTUVWXY ABCDEFGH +  ABCDEFGH  0123456789  IJKLMNOPQRSTUVWXY ABCDEFGH  0123456789  IJKLMNOPQRSTUVWXY ABCDEFGH +  ABCDEFGH  0123456789  IJKLMNOPQRSTUVWXY ABCDEFGH  0123456789  IJKLMNOPQRSTUVWXY ABCDEFGH +  ABCDEFGH  0123456789  IJKLMNOPQRSTUVWXY ABCDEFGH  0123456789  IJKLMNOPQRSTUVWXY ABCDEFGH +  ABCDEFGH  0123456789  IJKLMNOPQRSTUVWXY ABCDEFGH  0123456789  IJKLMNOPQRSTUVWXY ABCDEFGH +  ABCDEFGH  0123456789  IJKLMNOPQRSTUVWXY ABCDEFGH  0123456789  IJKLMNOPQRSTUVWXY ABCDEFGH +  ABCDEFGH  0123456789  IJKLMNOPQRSTUVWXY ABCDEFGH  0123456789  IJKLMNOPQRSTUVWXY ABCDEFGH +  ABCDEFGH  0123456789  IJKLMNOPQRSTUVWXY ABCDEFGH  0123456789  IJKLMNOPQRSTUVWXY ABCDEFGH +  ABCDEFGH  0123456789  IJKLMNOPQRSTUVWXY ABCDEFGH  0123456789  IJKLMNOPQRSTUVWXY ABCDEFGH +  ABCDEFGH  0123456789  IJKLMNOPQRSTUVWXY▁▁ ABCDEFGH  0123456789  IJKLMNOPQRSTUVWXY▁▁ ABCDEFGH +  ABCDEFGH  0123456789  IJKLMNOPQRSTUVWXY ABCDEFGH  0123456789  IJKLMNOPQRSTUVWXY ABCDEFGH +  ABCDEFGH  0123456789  IJKLMNOPQRSTUVWXY ABCDEFGH  0123456789  IJKLMNOPQRSTUVWXY ABCDEFGH +  ABCDEFGH  0123456789  IJKLMNOPQRSTUVWXY ABCDEFGH  0123456789  IJKLMNOPQRSTUVWXY ABCDEFGH +  ABCDEFGH  0123456789  IJKLMNOPQRSTUVWXY ABCDEFGH  0123456789  IJKLMNOPQRSTUVWXY ABCDEFGH +  ABCDEFGH  0123456789  IJKLMNOPQRSTUVWXY ABCDEFGH  0123456789  IJKLMNOPQRSTUVWXY ABCDEFGH +  ABCDEFGH  0123456789  IJKLMNOPQRSTUVWXY ABCDEFGH  0123456789  IJKLMNOPQRSTUVWXY ABCDEFGH +  ABCDEFGH  0123456789  IJKLMNOPQRSTUVWXY ABCDEFGH  0123456789  IJKLMNOPQRSTUVWXY ABCDEFGH +  ABCDEFGH  0123456789  IJKLMNOPQRSTUVWXY ABCDEFGH  0123456789  IJKLMNOPQRSTUVWXY ABCDEFGH +  ABCDEFGH  0123456789  IJKLMNOPQRSTUVWXY ABCDEFGH  0123456789  IJKLMNOPQRSTUVWXY ABCDEFGH +  ABCDEFGH  0123456789  IJKLMNOPQRSTUVWXY ABCDEFGH  0123456789  IJKLMNOPQRSTUVWXY ABCDEFGH +  ABCDEFGH  0123456789  IJKLMNOPQRSTUVWXY ABCDEFGH  0123456789  IJKLMNOPQRSTUVWXY ABCDEFGH +  ABCDEFGH  0123456789  IJKLMNOPQRSTUVWXY ABCDEFGH  0123456789  IJKLMNOPQRSTUVWXY ABCDEFGH + ───────────────────────────────────────────────────────────────────────────────────────────── + + ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────── @@ -1027,6 +1028,165 @@ ''' # --- +# name: test_big_buttons + ''' + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ButtonApp + + + + + + + + + + ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ + + + + Hello + + + + ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ + ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ + + + Hello + World !! + + + + ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ + + + + + + + + + + + ''' +# --- # name: test_blur_on_disabled ''' @@ -1208,137 +1368,294 @@ font-weight: 700; } - .terminal-2470781732-matrix { + .terminal-812768284-matrix { font-family: Fira Code, monospace; font-size: 20px; line-height: 24.4px; font-variant-east-asian: full-width; } - .terminal-2470781732-title { + .terminal-812768284-title { font-size: 18px; font-weight: bold; font-family: arial; } - .terminal-2470781732-r1 { fill: #1e1e1e } - .terminal-2470781732-r2 { fill: #c5c8c6 } - .terminal-2470781732-r3 { fill: #e1e1e1 } - .terminal-2470781732-r4 { fill: #183118 } - .terminal-2470781732-r5 { fill: #124512 } - .terminal-2470781732-r6 { fill: #0c580c } - .terminal-2470781732-r7 { fill: #066c06 } - .terminal-2470781732-r8 { fill: #008000 } + .terminal-812768284-r1 { fill: #1e1e1e } + .terminal-812768284-r2 { fill: #c5c8c6 } + .terminal-812768284-r3 { fill: #e1e1e1 } + .terminal-812768284-r4 { fill: #183118 } + .terminal-812768284-r5 { fill: #124512 } + .terminal-812768284-r6 { fill: #0c580c } + .terminal-812768284-r7 { fill: #066c06 } + .terminal-812768284-r8 { fill: #008000 } - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - BorderAlphaApp + BorderAlphaApp - + - - ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ - - ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ - ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ - - ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ - ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ - - ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ - ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ - - ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ - ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ - - ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ - ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ - - ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ - - - - - + + ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + + + + + + + + + + ''' +# --- +# name: test_button_outline + ''' + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ButtonIssue + + + + + + + + + + ────────────── + Test + ────────────── + + + + + + + + + + + + + + + + + + + + @@ -1369,162 +1686,162 @@ font-weight: 700; } - .terminal-3315449210-matrix { + .terminal-3236763676-matrix { font-family: Fira Code, monospace; font-size: 20px; line-height: 24.4px; font-variant-east-asian: full-width; } - .terminal-3315449210-title { + .terminal-3236763676-title { font-size: 18px; font-weight: bold; font-family: arial; } - .terminal-3315449210-r1 { fill: #e1e1e1 } - .terminal-3315449210-r2 { fill: #c5c8c6 } - .terminal-3315449210-r3 { fill: #e1e1e1;font-weight: bold } - .terminal-3315449210-r4 { fill: #454a50 } - .terminal-3315449210-r5 { fill: #303336 } - .terminal-3315449210-r6 { fill: #24292f;font-weight: bold } - .terminal-3315449210-r7 { fill: #a7a7a7;font-weight: bold } - .terminal-3315449210-r8 { fill: #000000 } - .terminal-3315449210-r9 { fill: #0f0f0f } - .terminal-3315449210-r10 { fill: #507bb3 } - .terminal-3315449210-r11 { fill: #364b66 } - .terminal-3315449210-r12 { fill: #dde6ed;font-weight: bold } - .terminal-3315449210-r13 { fill: #a5a9ac;font-weight: bold } - .terminal-3315449210-r14 { fill: #001541 } - .terminal-3315449210-r15 { fill: #0f192e } - .terminal-3315449210-r16 { fill: #7ae998 } - .terminal-3315449210-r17 { fill: #4a8159 } - .terminal-3315449210-r18 { fill: #0a180e;font-weight: bold } - .terminal-3315449210-r19 { fill: #0e1510;font-weight: bold } - .terminal-3315449210-r20 { fill: #008139 } - .terminal-3315449210-r21 { fill: #0f4e2a } - .terminal-3315449210-r22 { fill: #ffcf56 } - .terminal-3315449210-r23 { fill: #8b7439 } - .terminal-3315449210-r24 { fill: #211505;font-weight: bold } - .terminal-3315449210-r25 { fill: #19140c;font-weight: bold } - .terminal-3315449210-r26 { fill: #b86b00 } - .terminal-3315449210-r27 { fill: #68430f } - .terminal-3315449210-r28 { fill: #e76580 } - .terminal-3315449210-r29 { fill: #80404d } - .terminal-3315449210-r30 { fill: #f5e5e9;font-weight: bold } - .terminal-3315449210-r31 { fill: #b0a8aa;font-weight: bold } - .terminal-3315449210-r32 { fill: #780028 } - .terminal-3315449210-r33 { fill: #4a0f22 } + .terminal-3236763676-r1 { fill: #e1e1e1 } + .terminal-3236763676-r2 { fill: #c5c8c6 } + .terminal-3236763676-r3 { fill: #e1e1e1;font-weight: bold } + .terminal-3236763676-r4 { fill: #454a50 } + .terminal-3236763676-r5 { fill: #303336 } + .terminal-3236763676-r6 { fill: #24292f;font-weight: bold } + .terminal-3236763676-r7 { fill: #a7a7a7;font-weight: bold } + .terminal-3236763676-r8 { fill: #000000 } + .terminal-3236763676-r9 { fill: #0f0f0f } + .terminal-3236763676-r10 { fill: #507bb3 } + .terminal-3236763676-r11 { fill: #364b66 } + .terminal-3236763676-r12 { fill: #dde6ed;font-weight: bold } + .terminal-3236763676-r13 { fill: #a5a9ac;font-weight: bold } + .terminal-3236763676-r14 { fill: #001541 } + .terminal-3236763676-r15 { fill: #0f192e } + .terminal-3236763676-r16 { fill: #7ae998 } + .terminal-3236763676-r17 { fill: #4a8159 } + .terminal-3236763676-r18 { fill: #0a180e;font-weight: bold } + .terminal-3236763676-r19 { fill: #0e1510;font-weight: bold } + .terminal-3236763676-r20 { fill: #008139 } + .terminal-3236763676-r21 { fill: #0f4e2a } + .terminal-3236763676-r22 { fill: #ffcf56 } + .terminal-3236763676-r23 { fill: #8b7439 } + .terminal-3236763676-r24 { fill: #211505;font-weight: bold } + .terminal-3236763676-r25 { fill: #19140c;font-weight: bold } + .terminal-3236763676-r26 { fill: #b86b00 } + .terminal-3236763676-r27 { fill: #68430f } + .terminal-3236763676-r28 { fill: #e76580 } + .terminal-3236763676-r29 { fill: #80404d } + .terminal-3236763676-r30 { fill: #f5e5e9;font-weight: bold } + .terminal-3236763676-r31 { fill: #b0a8aa;font-weight: bold } + .terminal-3236763676-r32 { fill: #780028 } + .terminal-3236763676-r33 { fill: #4a0f22 } - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - ButtonsApp + ButtonsApp - - - - - Standard ButtonsDisabled Buttons - - ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ -  Default  Default  - ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ - - ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ -  Primary!  Primary!  - ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ - - ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ -  Success!  Success!  - ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ - - ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ -  Warning!  Warning!  - ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ - - ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ -  Error!  Error!  - ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ - + + + + + Standard ButtonsDisabled Buttons + + ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ + DefaultDefault + ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ + + ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ + Primary!Primary! + ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ + + ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ + Success!Success! + ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ + + ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ + Warning!Warning! + ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ + + ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ + Error!Error! + ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ + @@ -1555,143 +1872,144 @@ font-weight: 700; } - .terminal-3477807222-matrix { + .terminal-1822845634-matrix { font-family: Fira Code, monospace; font-size: 20px; line-height: 24.4px; font-variant-east-asian: full-width; } - .terminal-3477807222-title { + .terminal-1822845634-title { font-size: 18px; font-weight: bold; font-family: arial; } - .terminal-3477807222-r1 { fill: #e1e1e1 } - .terminal-3477807222-r2 { fill: #c5c8c6 } - .terminal-3477807222-r3 { fill: #262626 } - .terminal-3477807222-r4 { fill: #4a4a4a } - .terminal-3477807222-r5 { fill: #2e2e2e;font-weight: bold } - .terminal-3477807222-r6 { fill: #e3e3e3 } - .terminal-3477807222-r7 { fill: #e3e3e3;font-weight: bold } - .terminal-3477807222-r8 { fill: #98729f } - .terminal-3477807222-r9 { fill: #4ebf71;font-weight: bold } - .terminal-3477807222-r10 { fill: #0178d4 } - .terminal-3477807222-r11 { fill: #14191f } - .terminal-3477807222-r12 { fill: #5d5d5d } - .terminal-3477807222-r13 { fill: #e3e3e3;text-decoration: underline; } + .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; } - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - 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 + ▁▁▁▁▁▁▁▁▁▁▁▁▁ + ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ @@ -1721,136 +2039,136 @@ font-weight: 700; } - .terminal-658258504-matrix { + .terminal-4171601622-matrix { font-family: Fira Code, monospace; font-size: 20px; line-height: 24.4px; font-variant-east-asian: full-width; } - .terminal-658258504-title { + .terminal-4171601622-title { font-size: 18px; font-weight: bold; font-family: arial; } - .terminal-658258504-r1 { fill: #121212 } - .terminal-658258504-r2 { fill: #c5c8c6 } - .terminal-658258504-r3 { fill: #ddedf9 } - .terminal-658258504-r4 { fill: #e2e2e2 } - .terminal-658258504-r5 { fill: #e1e1e1 } - .terminal-658258504-r6 { fill: #dde8f3;font-weight: bold } + .terminal-4171601622-r1 { fill: #121212 } + .terminal-4171601622-r2 { fill: #c5c8c6 } + .terminal-4171601622-r3 { fill: #ddedf9 } + .terminal-4171601622-r4 { fill: #e2e2e2 } + .terminal-4171601622-r5 { fill: #e1e1e1 } + .terminal-4171601622-r6 { fill: #dde8f3;font-weight: bold } - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - CollapsibleApp + CollapsibleApp - + - - ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ - ▶ Leto - - ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ - ▶ Jessica - - ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ - ▶ Paul - - - - - - - - - - - - - - - -  C  Collapse All  E  Expand All  + + ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ + ▶ Leto + + ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ + ▶ Jessica + + ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ + ▶ Paul + + + + + + + + + + + + + + + +  C  Collapse All  E  Expand All  @@ -1880,133 +2198,134 @@ font-weight: 700; } - .terminal-3381030266-matrix { + .terminal-3510718873-matrix { font-family: Fira Code, monospace; font-size: 20px; line-height: 24.4px; font-variant-east-asian: full-width; } - .terminal-3381030266-title { + .terminal-3510718873-title { font-size: 18px; font-weight: bold; font-family: arial; } - .terminal-3381030266-r1 { fill: #121212 } - .terminal-3381030266-r2 { fill: #c5c8c6 } - .terminal-3381030266-r3 { fill: #ddedf9 } - .terminal-3381030266-r4 { fill: #e2e2e2 } + .terminal-3510718873-r1 { fill: #121212 } + .terminal-3510718873-r2 { fill: #c5c8c6 } + .terminal-3510718873-r3 { fill: #ddedf9 } + .terminal-3510718873-r4 { fill: #e2e2e2 } + .terminal-3510718873-r5 { fill: #e1e1e1 } - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - CollapsibleApp + CollapsibleApp - + - - ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ - >>> Togglev Toggle - - Hello, world. - - - - - - - - - - - - - - - - - - - + + ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ + >>> Togglev Toggle + + Hello, world. + + + + + + + + + + + + + + + + + + + @@ -2037,137 +2356,137 @@ font-weight: 700; } - .terminal-4076711628-matrix { + .terminal-4078801769-matrix { font-family: Fira Code, monospace; font-size: 20px; line-height: 24.4px; font-variant-east-asian: full-width; } - .terminal-4076711628-title { + .terminal-4078801769-title { font-size: 18px; font-weight: bold; font-family: arial; } - .terminal-4076711628-r1 { fill: #121212 } - .terminal-4076711628-r2 { fill: #e1e1e1 } - .terminal-4076711628-r3 { fill: #c5c8c6 } - .terminal-4076711628-r4 { fill: #ddedf9 } - .terminal-4076711628-r5 { fill: #e2e2e2 } - .terminal-4076711628-r6 { fill: #0053aa } - .terminal-4076711628-r7 { fill: #dde8f3;font-weight: bold } + .terminal-4078801769-r1 { fill: #121212 } + .terminal-4078801769-r2 { fill: #e1e1e1 } + .terminal-4078801769-r3 { fill: #c5c8c6 } + .terminal-4078801769-r4 { fill: #ddedf9 } + .terminal-4078801769-r5 { fill: #e2e2e2 } + .terminal-4078801769-r6 { fill: #0053aa } + .terminal-4078801769-r7 { fill: #dde8f3;font-weight: bold } - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - CollapsibleApp + CollapsibleApp - + - - ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ - ▼ Leto - - # Duke Leto I Atreides - - Head of House Atreides. - - ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ - ▼ Jessica - - ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ - - Lady Jessica - - ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ - Bene Gesserit and concubine of Leto, and mother of Paul and Alia. - - - - ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ - ▼ Paul - - ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ -  Collapse All  E  Expand All  + + ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ + ▼ Leto + + # Duke Leto I Atreides + + Head of House Atreides. + + ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ + ▼ Jessica + + ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ + + Lady Jessica + + ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ + Bene Gesserit and concubine of Leto, and mother of Paul and Alia. + + + + ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ + ▼ Paul + + ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ +  Collapse All  E  Expand All  @@ -2197,134 +2516,135 @@ font-weight: 700; } - .terminal-3855984296-matrix { + .terminal-3576423522-matrix { font-family: Fira Code, monospace; font-size: 20px; line-height: 24.4px; font-variant-east-asian: full-width; } - .terminal-3855984296-title { + .terminal-3576423522-title { font-size: 18px; font-weight: bold; font-family: arial; } - .terminal-3855984296-r1 { fill: #121212 } - .terminal-3855984296-r2 { fill: #c5c8c6 } - .terminal-3855984296-r3 { fill: #ddedf9 } - .terminal-3855984296-r4 { fill: #e3e3e3 } - .terminal-3855984296-r5 { fill: #e1e1e1 } + .terminal-3576423522-r1 { fill: #121212 } + .terminal-3576423522-r2 { fill: #c5c8c6 } + .terminal-3576423522-r3 { fill: #ddedf9 } + .terminal-3576423522-r4 { fill: #e2e2e2 } + .terminal-3576423522-r5 { fill: #e3e3e3 } + .terminal-3576423522-r6 { fill: #e1e1e1 } - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - CollapsibleApp + CollapsibleApp - + - - ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ - ▼ Toggle - - ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ - ▶ Toggle - - - - - - - - - - - - - - - - - - + + ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ + ▼ Toggle + + ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ + ▶ Toggle + + + + + + + + + + + + + + + + + + @@ -2355,137 +2675,137 @@ font-weight: 700; } - .terminal-3602827927-matrix { + .terminal-4121784704-matrix { font-family: Fira Code, monospace; font-size: 20px; line-height: 24.4px; font-variant-east-asian: full-width; } - .terminal-3602827927-title { + .terminal-4121784704-title { font-size: 18px; font-weight: bold; font-family: arial; } - .terminal-3602827927-r1 { fill: #121212 } - .terminal-3602827927-r2 { fill: #c5c8c6 } - .terminal-3602827927-r3 { fill: #ddedf9 } - .terminal-3602827927-r4 { fill: #e2e2e2 } - .terminal-3602827927-r5 { fill: #0053aa } - .terminal-3602827927-r6 { fill: #dde8f3;font-weight: bold } - .terminal-3602827927-r7 { fill: #e1e1e1 } + .terminal-4121784704-r1 { fill: #121212 } + .terminal-4121784704-r2 { fill: #c5c8c6 } + .terminal-4121784704-r3 { fill: #ddedf9 } + .terminal-4121784704-r4 { fill: #e2e2e2 } + .terminal-4121784704-r5 { fill: #0053aa } + .terminal-4121784704-r6 { fill: #dde8f3;font-weight: bold } + .terminal-4121784704-r7 { fill: #e1e1e1 } - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - CollapsibleApp + CollapsibleApp - + - - ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ - ▼ Leto - - # Duke Leto I Atreides - - Head of House Atreides. - - ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ - ▼ Jessica - - ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ - - Lady Jessica - - ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ - Bene Gesserit and concubine of Leto, and mother of Paul and Alia. - - - - ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ - ▶ Paul - - -  C  Collapse All  E  Expand All  + + ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ + ▼ Leto + + # Duke Leto I Atreides + + Head of House Atreides. + + ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ + ▼ Jessica + + ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ + + Lady Jessica + + ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ + Bene Gesserit and concubine of Leto, and mother of Paul and Alia. + + + + ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ + ▶ Paul + + +  C  Collapse All  E  Expand All  @@ -2515,133 +2835,133 @@ font-weight: 700; } - .terminal-363813734-matrix { + .terminal-2207022363-matrix { font-family: Fira Code, monospace; font-size: 20px; line-height: 24.4px; font-variant-east-asian: full-width; } - .terminal-363813734-title { + .terminal-2207022363-title { font-size: 18px; font-weight: bold; font-family: arial; } - .terminal-363813734-r1 { fill: #ff0000 } - .terminal-363813734-r2 { fill: #c5c8c6 } - .terminal-363813734-r3 { fill: #008000 } - .terminal-363813734-r4 { fill: #e1e1e1 } + .terminal-2207022363-r1 { fill: #ff0000 } + .terminal-2207022363-r2 { fill: #c5c8c6 } + .terminal-2207022363-r3 { fill: #008000 } + .terminal-2207022363-r4 { fill: #e1e1e1 } - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - HeightApp + HeightApp - + - - ────────────────────────────────────────────────────────────────────────────── - ────────────────────────────────────────────────────────── - As tall as containerThis has defaultI have a static height - height - but a - few lines - ──────────────── - - - - - - - - - - ────────────────────────────────────────── - ────────────────────────────────────────────────────────────────────────────── - - - - - + + ────────────────────────────────────────────────────────────────────────────── + ────────────────────────────────────────────────────────── + As tall as containerThis has defaultI have a static height + height + but a + few lines + ──────────────── + + + + + + + + + + ────────────────────────────────────────── + ────────────────────────────────────────────────────────────────────────────── + + + + + @@ -2672,136 +2992,137 @@ font-weight: 700; } - .terminal-3973201778-matrix { + .terminal-174430999-matrix { font-family: Fira Code, monospace; font-size: 20px; line-height: 24.4px; font-variant-east-asian: full-width; } - .terminal-3973201778-title { + .terminal-174430999-title { font-size: 18px; font-weight: bold; font-family: arial; } - .terminal-3973201778-r1 { fill: #a2a2a2 } - .terminal-3973201778-r2 { fill: #c5c8c6 } - .terminal-3973201778-r3 { fill: #004578 } - .terminal-3973201778-r4 { fill: #00ff00 } - .terminal-3973201778-r5 { fill: #e2e3e3 } - .terminal-3973201778-r6 { fill: #1e1e1e } - .terminal-3973201778-r7 { fill: #fea62b;font-weight: bold } + .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 } - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - 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 + ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ + + + + @@ -2832,140 +3153,140 @@ font-weight: 700; } - .terminal-4120014803-matrix { + .terminal-3026307999-matrix { font-family: Fira Code, monospace; font-size: 20px; line-height: 24.4px; font-variant-east-asian: full-width; } - .terminal-4120014803-title { + .terminal-3026307999-title { font-size: 18px; font-weight: bold; font-family: arial; } - .terminal-4120014803-r1 { fill: #c5c8c6 } - .terminal-4120014803-r2 { fill: #e1e1e1 } - .terminal-4120014803-r3 { fill: #454a50 } - .terminal-4120014803-r4 { fill: #24292f;font-weight: bold } - .terminal-4120014803-r5 { fill: #e2e3e3;font-weight: bold } - .terminal-4120014803-r6 { fill: #000000 } - .terminal-4120014803-r7 { fill: #004578 } - .terminal-4120014803-r8 { fill: #dde6ed;font-weight: bold } - .terminal-4120014803-r9 { fill: #dde6ed } - .terminal-4120014803-r10 { fill: #211505 } - .terminal-4120014803-r11 { fill: #e2e3e3 } + .terminal-3026307999-r1 { fill: #c5c8c6 } + .terminal-3026307999-r2 { fill: #e1e1e1 } + .terminal-3026307999-r3 { fill: #454a50 } + .terminal-3026307999-r4 { fill: #24292f;font-weight: bold } + .terminal-3026307999-r5 { fill: #e2e3e3;font-weight: bold } + .terminal-3026307999-r6 { fill: #000000 } + .terminal-3026307999-r7 { fill: #004578 } + .terminal-3026307999-r8 { fill: #dde6ed;font-weight: bold } + .terminal-3026307999-r9 { fill: #dde6ed } + .terminal-3026307999-r10 { fill: #211505 } + .terminal-3026307999-r11 { fill: #e2e3e3 } - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - ContentSwitcherApp + ContentSwitcherApp - - - - - ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ -  DataTable  Markdown  - ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ - ──────────────────────────────────────────────────────────────────── -  Book                                 Year  -  Dune                                 1965  -  Dune Messiah                         1969  -  Children of Dune                     1976  -  God Emperor of Dune                  1981  -  Heretics of Dune                     1984  -  Chapterhouse: Dune                   1985  - - - - - - - - - - - ──────────────────────────────────────────────────────────────────── + + + + + ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ + DataTableMarkdown + ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ + ──────────────────────────────────────────────────────────────────── +  Book                                 Year  +  Dune                                 1965  +  Dune Messiah                         1969  +  Children of Dune                     1976  +  God Emperor of Dune                  1981  +  Heretics of Dune                     1984  +  Chapterhouse: Dune                   1985  + + + + + + + + + + + ──────────────────────────────────────────────────────────────────── @@ -2996,246 +3317,246 @@ font-weight: 700; } - .terminal-4016107209-matrix { + .terminal-2579705430-matrix { font-family: Fira Code, monospace; font-size: 20px; line-height: 24.4px; font-variant-east-asian: full-width; } - .terminal-4016107209-title { + .terminal-2579705430-title { font-size: 18px; font-weight: bold; font-family: arial; } - .terminal-4016107209-r1 { fill: #c5c8c6 } - .terminal-4016107209-r2 { fill: #e1e1e1 } - .terminal-4016107209-r3 { fill: #454a50 } - .terminal-4016107209-r4 { fill: #e2e3e3;font-weight: bold } - .terminal-4016107209-r5 { fill: #24292f;font-weight: bold } - .terminal-4016107209-r6 { fill: #000000 } - .terminal-4016107209-r7 { fill: #004578 } - .terminal-4016107209-r8 { fill: #121212 } - .terminal-4016107209-r9 { fill: #e2e3e3 } - .terminal-4016107209-r10 { fill: #0053aa } - .terminal-4016107209-r11 { fill: #dde8f3;font-weight: bold } - .terminal-4016107209-r12 { fill: #ffff00;font-weight: bold } - .terminal-4016107209-r13 { fill: #24292f } + .terminal-2579705430-r1 { fill: #c5c8c6 } + .terminal-2579705430-r2 { fill: #e1e1e1 } + .terminal-2579705430-r3 { fill: #454a50 } + .terminal-2579705430-r4 { fill: #e2e3e3;font-weight: bold } + .terminal-2579705430-r5 { fill: #24292f;font-weight: bold } + .terminal-2579705430-r6 { fill: #000000 } + .terminal-2579705430-r7 { fill: #004578 } + .terminal-2579705430-r8 { fill: #e2e3e3 } + .terminal-2579705430-r9 { fill: #121212 } + .terminal-2579705430-r10 { fill: #0053aa } + .terminal-2579705430-r11 { fill: #dde8f3;font-weight: bold } + .terminal-2579705430-r12 { fill: #ffff00;font-weight: bold } + .terminal-2579705430-r13 { fill: #24292f } - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - ContentSwitcherApp + ContentSwitcherApp - - - - - ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ -  DataTable  Markdown  - ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ - ───────────────────────────────────────── - ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ - - Three Flavours Cornetto - - ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ - The Three Flavours Cornetto  - trilogy is an anthology series  - of British comedic genre films  - directed by Edgar Wright. - -        Shaun of the Dead        - - ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ - - UK       - Release  - Flavour Date    Director  -  ━━━━━━━━━━━━━━━━━━━━━━━━━━━  - Strawbe…2004-04…Edgar     - Wright    - - ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ - -            Hot Fuzz             - - ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ - - UK       - Release  - Flavour Date    Director  -  ━━━━━━━━━━━━━━━━━━━━━━━━━━━  - Classico2007-02…Edgar     - Wright    - - ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ - -         The World's End         - - ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ - - UK        - Release   - FlavourDate     Director  - ───────────────────────────────────────── + + + + + ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ + DataTableMarkdown + ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ + ───────────────────────────────────────── + ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ + + Three Flavours Cornetto + + ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ + The Three Flavours Cornetto  + trilogy is an anthology series  + of British comedic genre films  + directed by Edgar Wright. + +        Shaun of the Dead        + + ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ + + UK       + Release  + Flavour Date    Director  +  ━━━━━━━━━━━━━━━━━━━━━━━━━━━  + Strawbe…2004-04…Edgar     + Wright    + + ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ + +            Hot Fuzz             + + ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ + + UK       + Release  + Flavour Date    Director  +  ━━━━━━━━━━━━━━━━━━━━━━━━━━━  + Classico2007-02…Edgar     + Wright    + + ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ + +         The World's End         + + ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ + + UK        + Release   + FlavourDate     Director  + ───────────────────────────────────────── @@ -3266,131 +3587,131 @@ font-weight: 700; } - .terminal-3709538693-matrix { + .terminal-1055651203-matrix { font-family: Fira Code, monospace; font-size: 20px; line-height: 24.4px; font-variant-east-asian: full-width; } - .terminal-3709538693-title { + .terminal-1055651203-title { font-size: 18px; font-weight: bold; font-family: arial; } - .terminal-3709538693-r1 { fill: #e1e1e1 } - .terminal-3709538693-r2 { fill: #c5c8c6 } + .terminal-1055651203-r1 { fill: #e1e1e1 } + .terminal-1055651203-r2 { fill: #c5c8c6 } - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - HotReloadingApp + HotReloadingApp - + - - Hello, world! - - - - - - - - - - - - - - - - - - - - - - + + Hello, world! + + + + + + + + + + + + + + + + + + + + + + @@ -3398,7 +3719,7 @@ ''' # --- -# name: test_css_property[align.py] +# name: test_css_hot_reloading_on_screen ''' @@ -3421,133 +3742,288 @@ font-weight: 700; } - .terminal-1567237307-matrix { + .terminal-1055651203-matrix { font-family: Fira Code, monospace; font-size: 20px; line-height: 24.4px; font-variant-east-asian: full-width; } - .terminal-1567237307-title { + .terminal-1055651203-title { font-size: 18px; font-weight: bold; font-family: arial; } - .terminal-1567237307-r1 { fill: #e1e1e1 } - .terminal-1567237307-r2 { fill: #c5c8c6 } - .terminal-1567237307-r3 { fill: #ffffff } - .terminal-1567237307-r4 { fill: #e5f2e5 } - .terminal-1567237307-r5 { fill: #e5f2e5;font-weight: bold } + .terminal-1055651203-r1 { fill: #e1e1e1 } + .terminal-1055651203-r2 { fill: #c5c8c6 } - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - AlignApp + HotReloadingApp - - - - - - - - - - ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ - - Vertical alignment with Textual - - ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ - - ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ - - Take note, browsers. - - ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ - - - - - + + + + Hello, world! + + + + + + + + + + + + + + + + + + + + + + + + + + + + ''' +# --- +# name: test_css_property[align.py] + ''' + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + AlignApp + + + + + + + + + + + + + + + + ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + Vertical alignment with Textual + + ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + Take note, browsers. + + ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + + + + @@ -3579,135 +4055,135 @@ font-weight: 700; } - .terminal-1331556511-matrix { + .terminal-1945469710-matrix { font-family: Fira Code, monospace; font-size: 20px; line-height: 24.4px; font-variant-east-asian: full-width; } - .terminal-1331556511-title { + .terminal-1945469710-title { font-size: 18px; font-weight: bold; font-family: arial; } - .terminal-1331556511-r1 { fill: #808080 } - .terminal-1331556511-r2 { fill: #e1e1e1 } - .terminal-1331556511-r3 { fill: #c5c8c6 } - .terminal-1331556511-r4 { fill: #ddedf9 } - .terminal-1331556511-r5 { fill: #e2e2e2 } + .terminal-1945469710-r1 { fill: #808080 } + .terminal-1945469710-r2 { fill: #e1e1e1 } + .terminal-1945469710-r3 { fill: #c5c8c6 } + .terminal-1945469710-r4 { fill: #ddedf9 } + .terminal-1945469710-r5 { fill: #e2e2e2 } - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - AlignAllApp + AlignAllApp - - - - ──────────────────────────────────────────────────────────────────────── - left topcenter topright top - - - - - ──────────────────────────────────────────────────────────────────────── - - ──────────────────────────────────────────────────────────────────────── - - - left middlecenter middleright middle - - - ──────────────────────────────────────────────────────────────────────── - - ──────────────────────────────────────────────────────────────────────── - - - - - - left bottomcenter bottomright bottom - ──────────────────────────────────────────────────────────────────────── + + + + ──────────────────────────────────────────────────────────────────────── + left topcenter topright top + + + + + ──────────────────────────────────────────────────────────────────────── + + ──────────────────────────────────────────────────────────────────────── + + + left middlecenter middleright middle + + + ──────────────────────────────────────────────────────────────────────── + + ──────────────────────────────────────────────────────────────────────── + + + + + + left bottomcenter bottomright bottom + ──────────────────────────────────────────────────────────────────────── @@ -4214,133 +4690,133 @@ font-weight: 700; } - .terminal-1717278065-matrix { + .terminal-2436490509-matrix { font-family: Fira Code, monospace; font-size: 20px; line-height: 24.4px; font-variant-east-asian: full-width; } - .terminal-1717278065-title { + .terminal-2436490509-title { font-size: 18px; font-weight: bold; font-family: arial; } - .terminal-1717278065-r1 { fill: #c5c8c6 } - .terminal-1717278065-r2 { fill: #0178d4 } - .terminal-1717278065-r3 { fill: #e1e1e1 } - .terminal-1717278065-r4 { fill: #1e1e1e } + .terminal-2436490509-r1 { fill: #e1e1e1 } + .terminal-2436490509-r2 { fill: #0178d4 } + .terminal-2436490509-r3 { fill: #c5c8c6 } + .terminal-2436490509-r4 { fill: #1e1e1e } - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - AllBordersApp + AllBordersApp - + - - +------------------+╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍ - |ascii|blankdashed - +------------------+╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍ - - - ══════════════════━━━━━━━━━━━━━━━━━━ - doubleheavyhidden/none - ══════════════════━━━━━━━━━━━━━━━━━━ - - - ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ - hkeyinnerouter - ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄ - - - ────────────────────────────────────▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ - roundsolidtall - ────────────────────────────────────▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ - - - ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ - thickvkeywide - ▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ + + +------------------+╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍ + |ascii|blankdashed + +------------------+╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍ + + + ══════════════════━━━━━━━━━━━━━━━━━━ + doubleheavyhidden/none + ══════════════════━━━━━━━━━━━━━━━━━━ + + + ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ + hkeyinnerouter + ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄ + + + ────────────────────────────────────▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ + roundsolidtall + ────────────────────────────────────▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ + + + ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ + thickvkeywide + ▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ @@ -4371,141 +4847,141 @@ font-weight: 700; } - .terminal-1997861159-matrix { + .terminal-2893669067-matrix { font-family: Fira Code, monospace; font-size: 20px; line-height: 24.4px; font-variant-east-asian: full-width; } - .terminal-1997861159-title { + .terminal-2893669067-title { font-size: 18px; font-weight: bold; font-family: arial; } - .terminal-1997861159-r1 { fill: #e1e1e1 } - .terminal-1997861159-r2 { fill: #c5c8c6 } - .terminal-1997861159-r3 { fill: #fea62b } - .terminal-1997861159-r4 { fill: #fea62b;font-weight: bold } - .terminal-1997861159-r5 { fill: #fea62b;font-weight: bold;font-style: italic; } - .terminal-1997861159-r6 { fill: #cc555a;font-weight: bold } - .terminal-1997861159-r7 { fill: #1e1e1e } - .terminal-1997861159-r8 { fill: #1e1e1e;text-decoration: underline; } - .terminal-1997861159-r9 { fill: #fea62b;text-decoration: underline; } - .terminal-1997861159-r10 { fill: #4b4e55;text-decoration: underline; } - .terminal-1997861159-r11 { fill: #4ebf71 } - .terminal-1997861159-r12 { fill: #b93c5b } + .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 } - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - 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… + @@ -5482,137 +5958,138 @@ font-weight: 700; } - .terminal-3959176494-matrix { + .terminal-272467720-matrix { font-family: Fira Code, monospace; font-size: 20px; line-height: 24.4px; font-variant-east-asian: full-width; } - .terminal-3959176494-title { + .terminal-272467720-title { font-size: 18px; font-weight: bold; font-family: arial; } - .terminal-3959176494-r1 { fill: #c5c8c6 } - .terminal-3959176494-r2 { fill: #e8e0e7 } - .terminal-3959176494-r3 { fill: #eae3e5 } - .terminal-3959176494-r4 { fill: #ede6e6 } - .terminal-3959176494-r5 { fill: #efe9e4 } - .terminal-3959176494-r6 { fill: #efeedf } - .terminal-3959176494-r7 { fill: #e9eee5 } - .terminal-3959176494-r8 { fill: #e4eee8 } + .terminal-272467720-r1 { fill: #c5c8c6 } + .terminal-272467720-r2 { fill: #e8e0e7 } + .terminal-272467720-r3 { fill: #e1e1e1 } + .terminal-272467720-r4 { fill: #eae3e5 } + .terminal-272467720-r5 { fill: #ede6e6 } + .terminal-272467720-r6 { fill: #efe9e4 } + .terminal-272467720-r7 { fill: #efeedf } + .terminal-272467720-r8 { fill: #e9eee5 } + .terminal-272467720-r9 { fill: #e4eee8 } - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - MyApp + MyApp - + - - - - #p1 - - - - - - #p2#p3 - - - - - - #p4#p5 - - - - - - #p6#p7 - - + + + + #p1 + + + + + + #p2#p3 + + + + + + #p4#p5 + + + + + + #p6#p7 + + @@ -6112,132 +6589,132 @@ font-weight: 700; } - .terminal-1840966081-matrix { + .terminal-1440050744-matrix { font-family: Fira Code, monospace; font-size: 20px; line-height: 24.4px; font-variant-east-asian: full-width; } - .terminal-1840966081-title { + .terminal-1440050744-title { font-size: 18px; font-weight: bold; font-family: arial; } - .terminal-1840966081-r1 { fill: #e1e1e1 } - .terminal-1840966081-r2 { fill: #c5c8c6 } - .terminal-1840966081-r3 { fill: #ffffff } + .terminal-1440050744-r1 { fill: #e1e1e1 } + .terminal-1440050744-r2 { fill: #c5c8c6 } + .terminal-1440050744-r3 { fill: #ffffff } - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - DockAllApp + DockAllApp - - - - - - - ────────────────────────────────────────────────────────── - top - - - - - - - leftright - - - - - - - - bottom - ────────────────────────────────────────────────────────── - - + + + + + + + ────────────────────────────────────────────────────────── + top + + + + + + + leftright + + + + + + + + bottom + ────────────────────────────────────────────────────────── + + @@ -6581,133 +7058,133 @@ font-weight: 700; } - .terminal-2490521691-matrix { + .terminal-1194212456-matrix { font-family: Fira Code, monospace; font-size: 20px; line-height: 24.4px; font-variant-east-asian: full-width; } - .terminal-2490521691-title { + .terminal-1194212456-title { font-size: 18px; font-weight: bold; font-family: arial; } - .terminal-2490521691-r1 { fill: #ffffff } - .terminal-2490521691-r2 { fill: #c5c8c6 } - .terminal-2490521691-r3 { fill: #e1e1e1 } + .terminal-1194212456-r1 { fill: #ffffff } + .terminal-1194212456-r2 { fill: #e1e1e1 } + .terminal-1194212456-r3 { fill: #c5c8c6 } - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - MyApp + MyApp - + - - ────────────────────────────────────────────────────────────────────────── - - 12 - - ────────────────────────────────────────────────────────────────────────── - - ────────────────────────────────────────────────────────────────────────── - - 34 - - ────────────────────────────────────────────────────────────────────────── - - ────────────────────────────────────────────────────────────────────────── - - 56 - - ────────────────────────────────────────────────────────────────────────── - - ────────────────────────────────────────────────────────────────────────── - - 78 - - - ────────────────────────────────────────────────────────────────────────── + + ────────────────────────────────────────────────────────────────────────── + + 12 + + ────────────────────────────────────────────────────────────────────────── + + ────────────────────────────────────────────────────────────────────────── + + 34 + + ────────────────────────────────────────────────────────────────────────── + + ────────────────────────────────────────────────────────────────────────── + + 56 + + ────────────────────────────────────────────────────────────────────────── + + ────────────────────────────────────────────────────────────────────────── + + 78 + + + ────────────────────────────────────────────────────────────────────────── @@ -6893,132 +7370,132 @@ font-weight: 700; } - .terminal-1098633890-matrix { + .terminal-3270198448-matrix { font-family: Fira Code, monospace; font-size: 20px; line-height: 24.4px; font-variant-east-asian: full-width; } - .terminal-1098633890-title { + .terminal-3270198448-title { font-size: 18px; font-weight: bold; font-family: arial; } - .terminal-1098633890-r1 { fill: #ffffff } - .terminal-1098633890-r2 { fill: #c5c8c6 } - .terminal-1098633890-r3 { fill: #e1e1e1 } + .terminal-3270198448-r1 { fill: #ffffff } + .terminal-3270198448-r2 { fill: #c5c8c6 } + .terminal-3270198448-r3 { fill: #e1e1e1 } - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - MyApp + MyApp - + - - ──────────────────────────────────────────────────────────────────────────── - - 12 - - - ──────────────────────────────────────────────────────────────────────────── - ──────────────────────────────────────────────────────────────────────────── - - 34 - - - ──────────────────────────────────────────────────────────────────────────── - ────────────────────────────────────── - - 5 - - - ────────────────────────────────────── - - - - - + + ──────────────────────────────────────────────────────────────────────────── + + 12 + + + ──────────────────────────────────────────────────────────────────────────── + ──────────────────────────────────────────────────────────────────────────── + + 34 + + + ──────────────────────────────────────────────────────────────────────────── + ────────────────────────────────────── + + 5 + + + ────────────────────────────────────── + + + + + @@ -7049,133 +7526,133 @@ font-weight: 700; } - .terminal-42281693-matrix { + .terminal-4208206220-matrix { font-family: Fira Code, monospace; font-size: 20px; line-height: 24.4px; font-variant-east-asian: full-width; } - .terminal-42281693-title { + .terminal-4208206220-title { font-size: 18px; font-weight: bold; font-family: arial; } - .terminal-42281693-r1 { fill: #ffffff } - .terminal-42281693-r2 { fill: #c5c8c6 } - .terminal-42281693-r3 { fill: #e1e1e1 } + .terminal-4208206220-r1 { fill: #ffffff } + .terminal-4208206220-r2 { fill: #c5c8c6 } + .terminal-4208206220-r3 { fill: #e1e1e1 } - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - MyApp + MyApp - + - - ──────────────────────────────────────────────────────────────────────────── - - - 12 - - - - ──────────────────────────────────────────────────────────────────────────── - ──────────────────────────────────────────────────────────────────────────── - - - 34 - - - - ──────────────────────────────────────────────────────────────────────────── - ────────────────────────────────────── - - - 5 - - - - ────────────────────────────────────── + + ──────────────────────────────────────────────────────────────────────────── + + + 12 + + + + ──────────────────────────────────────────────────────────────────────────── + ──────────────────────────────────────────────────────────────────────────── + + + 34 + + + + ──────────────────────────────────────────────────────────────────────────── + ────────────────────────────────────── + + + 5 + + + + ────────────────────────────────────── @@ -7502,7 +7979,7 @@ ''' # --- -# name: test_css_property[layout.py] +# name: test_css_property[keyline.py] ''' @@ -7525,134 +8002,136 @@ font-weight: 700; } - .terminal-2838975926-matrix { + .terminal-3762053931-matrix { font-family: Fira Code, monospace; font-size: 20px; line-height: 24.4px; font-variant-east-asian: full-width; } - .terminal-2838975926-title { + .terminal-3762053931-title { font-size: 18px; font-weight: bold; font-family: arial; } - .terminal-2838975926-r1 { fill: #efddef } - .terminal-2838975926-r2 { fill: #c5c8c6 } - .terminal-2838975926-r3 { fill: #000000 } - .terminal-2838975926-r4 { fill: #ddefef } - .terminal-2838975926-r5 { fill: #e1e1e1 } + .terminal-3762053931-r1 { fill: #c5c8c6 } + .terminal-3762053931-r2 { fill: #008000 } + .terminal-3762053931-r3 { fill: #e8e0e7 } + .terminal-3762053931-r4 { fill: #eae3e5 } + .terminal-3762053931-r5 { fill: #1e1e1e } + .terminal-3762053931-r6 { fill: #ede6e6 } + .terminal-3762053931-r7 { fill: #efeedf } - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - LayoutApp + KeylineApp - - - - - Layout - - Is - - Vertical - - - LayoutIsHorizontal - - - - - - - - - - - - - - + + + + + + ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + + #foo + + + ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━#bar + + + Placeholder + + + ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + + #baz + + + + ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + @@ -7660,7 +8139,7 @@ ''' # --- -# name: test_css_property[link_background.py] +# name: test_css_property[keyline_horizontal.py] ''' @@ -7683,142 +8162,142 @@ font-weight: 700; } - .terminal-687058265-matrix { + .terminal-1481425640-matrix { font-family: Fira Code, monospace; font-size: 20px; line-height: 24.4px; font-variant-east-asian: full-width; } - .terminal-687058265-title { + .terminal-1481425640-title { font-size: 18px; font-weight: bold; font-family: arial; } - .terminal-687058265-r1 { fill: #e1e1e1 } - .terminal-687058265-r2 { fill: #c5c8c6 } - .terminal-687058265-r3 { fill: #ffdddd;text-decoration: underline; } - .terminal-687058265-r4 { fill: #121201;text-decoration: underline; } - .terminal-687058265-r5 { fill: #ddedf9;text-decoration: underline; } + .terminal-1481425640-r1 { fill: #fea62b } + .terminal-1481425640-r2 { fill: #c5c8c6 } + .terminal-1481425640-r3 { fill: #e8e0e7 } + .terminal-1481425640-r4 { fill: #eae3e5 } + .terminal-1481425640-r5 { fill: #ede6e6 } - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - LinkBackgroundApp + KeylineApp - - - - Visit the Textualize website. - Click here for the bell sound. - You can also click here for the bell sound. - Exit this application. - - - - - - - - - - - - - - - - - - - - + + + + ──────────────────────────────────────────────────────────────────────────── + + + + + + + + + + + PlaceholderPlaceholderPlaceholder + + + + + + + + + + + + ──────────────────────────────────────────────────────────────────────────── ''' # --- -# name: test_css_property[link_color.py] +# name: test_css_property[layout.py] ''' @@ -7841,134 +8320,292 @@ font-weight: 700; } - .terminal-3021056461-matrix { + .terminal-17876085-matrix { font-family: Fira Code, monospace; font-size: 20px; line-height: 24.4px; font-variant-east-asian: full-width; } - .terminal-3021056461-title { + .terminal-17876085-title { font-size: 18px; font-weight: bold; font-family: arial; } - .terminal-3021056461-r1 { fill: #e1e1e1 } - .terminal-3021056461-r2 { fill: #c5c8c6 } - .terminal-3021056461-r3 { fill: #ff0000;text-decoration: underline; } - .terminal-3021056461-r4 { fill: #8e8e0f;text-decoration: underline; } - .terminal-3021056461-r5 { fill: #0178d4;text-decoration: underline; } + .terminal-17876085-r1 { fill: #efddef } + .terminal-17876085-r2 { fill: #c5c8c6 } + .terminal-17876085-r3 { fill: #000000 } + .terminal-17876085-r4 { fill: #ddefef } + .terminal-17876085-r5 { fill: #e1e1e1 } - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - LinkColorApp + LayoutApp - - - - Visit the Textualize website. - Click here for the bell sound. - You can also click here for the bell sound. - Exit this application. - - - - - - - - - - - - - - - - - - - + + + + + Layout + + Is + + Vertical + + + LayoutIsHorizontal + + + + + + + + + + + + + + + + + + + + ''' +# --- +# name: test_css_property[link_background.py] + ''' + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + LinkBackgroundApp + + + + + + + + + + Visit the Textualize website. + Click here for the bell sound. + You can also click here for the bell sound. + Exit this application. + + + + + + + + + + + + + + + + + + + @@ -7976,7 +8613,7 @@ ''' # --- -# name: test_css_property[link_hover_background.py] +# name: test_css_property[link_background_hover.py] ''' @@ -8132,7 +8769,7 @@ ''' # --- -# name: test_css_property[link_hover_color.py] +# name: test_css_property[link_color.py] ''' @@ -8155,132 +8792,134 @@ font-weight: 700; } - .terminal-3576933835-matrix { + .terminal-3021056461-matrix { font-family: Fira Code, monospace; font-size: 20px; line-height: 24.4px; font-variant-east-asian: full-width; } - .terminal-3576933835-title { + .terminal-3021056461-title { font-size: 18px; font-weight: bold; font-family: arial; } - .terminal-3576933835-r1 { fill: #e1e1e1 } - .terminal-3576933835-r2 { fill: #c5c8c6 } - .terminal-3576933835-r3 { fill: #e1e1e1;text-decoration: underline; } + .terminal-3021056461-r1 { fill: #e1e1e1 } + .terminal-3021056461-r2 { fill: #c5c8c6 } + .terminal-3021056461-r3 { fill: #ff0000;text-decoration: underline; } + .terminal-3021056461-r4 { fill: #8e8e0f;text-decoration: underline; } + .terminal-3021056461-r5 { fill: #0178d4;text-decoration: underline; } - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - LinkHoverColorApp + LinkColorApp - + - - Visit the Textualize website. - Click here for the bell sound. - You can also click here for the bell sound. - Exit this application. - - - - - - - - - - - - - - - - - - - + + Visit the Textualize website. + Click here for the bell sound. + You can also click here for the bell sound. + Exit this application. + + + + + + + + + + + + + + + + + + + @@ -8288,7 +8927,7 @@ ''' # --- -# name: test_css_property[link_hover_style.py] +# name: test_css_property[link_color_hover.py] ''' @@ -8311,132 +8950,132 @@ font-weight: 700; } - .terminal-3588337117-matrix { + .terminal-3576933835-matrix { font-family: Fira Code, monospace; font-size: 20px; line-height: 24.4px; font-variant-east-asian: full-width; } - .terminal-3588337117-title { + .terminal-3576933835-title { font-size: 18px; font-weight: bold; font-family: arial; } - .terminal-3588337117-r1 { fill: #e1e1e1 } - .terminal-3588337117-r2 { fill: #c5c8c6 } - .terminal-3588337117-r3 { fill: #e1e1e1;text-decoration: underline; } + .terminal-3576933835-r1 { fill: #e1e1e1 } + .terminal-3576933835-r2 { fill: #c5c8c6 } + .terminal-3576933835-r3 { fill: #e1e1e1;text-decoration: underline; } - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - LinkHoverStyleApp + LinkHoverColorApp - + - - Visit the Textualize website. - Click here for the bell sound. - You can also click here for the bell sound. - Exit this application. - - - - - - - - - - - - - - - - - - - + + Visit the Textualize website. + Click here for the bell sound. + You can also click here for the bell sound. + Exit this application. + + + + + + + + + + + + + + + + + + + @@ -8602,7 +9241,7 @@ ''' # --- -# name: test_css_property[links.py] +# name: test_css_property[link_style_hover.py] ''' @@ -8625,153 +9264,309 @@ font-weight: 700; } - .terminal-3461676208-matrix { + .terminal-3588337117-matrix { font-family: Fira Code, monospace; font-size: 20px; line-height: 24.4px; font-variant-east-asian: full-width; } - .terminal-3461676208-title { + .terminal-3588337117-title { font-size: 18px; font-weight: bold; font-family: arial; } - .terminal-3461676208-r1 { fill: #e1e1e1 } - .terminal-3461676208-r2 { fill: #e1e1e1;text-decoration: underline; } - .terminal-3461676208-r3 { fill: #c5c8c6 } - .terminal-3461676208-r4 { fill: #030e19;font-weight: bold;font-style: italic;;text-decoration: underline; } + .terminal-3588337117-r1 { fill: #e1e1e1 } + .terminal-3588337117-r2 { fill: #c5c8c6 } + .terminal-3588337117-r3 { fill: #e1e1e1;text-decoration: underline; } - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - LinksApp + LinkHoverStyleApp - - - - Here is a link which you can click! - - Here is a link which you can click! - - - - - - - - - - - - - - - - - - - - - - - - - - ''' -# --- -# name: test_css_property[margin.py] - ''' - - - + + ''' +# --- +# name: test_css_property[links.py] + ''' + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + LinksApp + + + + + + + + + + Here is a link which you can click! + + Here is a link which you can click! + + + + + + + + + + + + + + + + + + + + + + + + + + ''' +# --- +# name: test_css_property[margin.py] + ''' + + + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - MarginAllApp + MarginAllApp - - - - ────────────────────────────────────────────────────────────────── - - - - marginmargin: 1  - no marginmargin: 1: 1 51 2 6 - - - - - ────────────────────────────────────────────────────────────────── - - ────────────────────────────────────────────────────────────────── - - - margin-bottom: 4 - - margin-right: margin-left: 3 - 3 - margin-top: 4 - - - - ────────────────────────────────────────────────────────────────── + + + + ────────────────────────────────────────────────────────────────── + + + + marginmargin: 1  + no marginmargin: 1: 1 51 2 6 + + + + + ────────────────────────────────────────────────────────────────── + + ────────────────────────────────────────────────────────────────── + + + margin-bottom: 4 + + margin-right: margin-left: 3 + 3 + margin-top: 4 + + + + ────────────────────────────────────────────────────────────────── @@ -9103,134 +9898,135 @@ font-weight: 700; } - .terminal-3102345871-matrix { + .terminal-871212948-matrix { font-family: Fira Code, monospace; font-size: 20px; line-height: 24.4px; font-variant-east-asian: full-width; } - .terminal-3102345871-title { + .terminal-871212948-title { font-size: 18px; font-weight: bold; font-family: arial; } - .terminal-3102345871-r1 { fill: #c5c8c6 } - .terminal-3102345871-r2 { fill: #e8e0e7 } - .terminal-3102345871-r3 { fill: #efe9e4 } - .terminal-3102345871-r4 { fill: #ede6e6 } - .terminal-3102345871-r5 { fill: #eae3e5 } + .terminal-871212948-r1 { fill: #c5c8c6 } + .terminal-871212948-r2 { fill: #e8e0e7 } + .terminal-871212948-r3 { fill: #efe9e4 } + .terminal-871212948-r4 { fill: #ede6e6 } + .terminal-871212948-r5 { fill: #e1e1e1 } + .terminal-871212948-r6 { fill: #eae3e5 } - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - MaxHeightApp + MaxHeightApp - + - - - - - max-height: 10w - max-height: 10 - max-height: 50% - - - - - - max-height: 999 - - - - - - - - - - - + + + + + max-height: 10w + max-height: 10 + max-height: 50% + + + + + + max-height: 999 + + + + + + + + + + + @@ -9261,134 +10057,135 @@ font-weight: 700; } - .terminal-1398959741-matrix { + .terminal-2391822459-matrix { font-family: Fira Code, monospace; font-size: 20px; line-height: 24.4px; font-variant-east-asian: full-width; } - .terminal-1398959741-title { + .terminal-2391822459-title { font-size: 18px; font-weight: bold; font-family: arial; } - .terminal-1398959741-r1 { fill: #c5c8c6 } - .terminal-1398959741-r2 { fill: #e8e0e7 } - .terminal-1398959741-r3 { fill: #eae3e5 } - .terminal-1398959741-r4 { fill: #ede6e6 } - .terminal-1398959741-r5 { fill: #efe9e4 } + .terminal-2391822459-r1 { fill: #c5c8c6 } + .terminal-2391822459-r2 { fill: #e1e1e1 } + .terminal-2391822459-r3 { fill: #e8e0e7 } + .terminal-2391822459-r4 { fill: #eae3e5 } + .terminal-2391822459-r5 { fill: #ede6e6 } + .terminal-2391822459-r6 { fill: #efe9e4 } - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - MaxWidthApp + MaxWidthApp - - - - - - max-width:  - 50h - - - - - max-width: 999 - - - - - - max-width: 50% - - - - - - max-width: 30 - - + + + + + + max-width:  + 50h + + + + + max-width: 999 + + + + + + max-width: 50% + + + + + + max-width: 30 + + @@ -9419,136 +10216,136 @@ font-weight: 700; } - .terminal-3637655391-matrix { + .terminal-3387599198-matrix { font-family: Fira Code, monospace; font-size: 20px; line-height: 24.4px; font-variant-east-asian: full-width; } - .terminal-3637655391-title { + .terminal-3387599198-title { font-size: 18px; font-weight: bold; font-family: arial; } - .terminal-3637655391-r1 { fill: #c5c8c6 } - .terminal-3637655391-r2 { fill: #e1e1e1 } - .terminal-3637655391-r3 { fill: #e8e0e7 } - .terminal-3637655391-r4 { fill: #eae3e5 } - .terminal-3637655391-r5 { fill: #ede6e6 } - .terminal-3637655391-r6 { fill: #efe9e4 } - .terminal-3637655391-r7 { fill: #14191f } + .terminal-3387599198-r1 { fill: #c5c8c6 } + .terminal-3387599198-r2 { fill: #e1e1e1 } + .terminal-3387599198-r3 { fill: #e8e0e7 } + .terminal-3387599198-r4 { fill: #eae3e5 } + .terminal-3387599198-r5 { fill: #ede6e6 } + .terminal-3387599198-r6 { fill: #efe9e4 } + .terminal-3387599198-r7 { fill: #14191f } - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - MinHeightApp + MinHeightApp - + - - - - - - - min-height: 25% - - - min-height: 75% - - - - - - min-height: 30 - min-height: 40w - - - ▃▃ - - - - + + + + + + + min-height: 25% + + + min-height: 75% + + + + + + min-height: 30 + min-height: 40w + + + ▃▃ + + + + @@ -9579,135 +10376,135 @@ font-weight: 700; } - .terminal-3351527922-matrix { + .terminal-1031378205-matrix { font-family: Fira Code, monospace; font-size: 20px; line-height: 24.4px; font-variant-east-asian: full-width; } - .terminal-3351527922-title { + .terminal-1031378205-title { font-size: 18px; font-weight: bold; font-family: arial; } - .terminal-3351527922-r1 { fill: #c5c8c6 } - .terminal-3351527922-r2 { fill: #e8e0e7 } - .terminal-3351527922-r3 { fill: #eae3e5 } - .terminal-3351527922-r4 { fill: #ede6e6 } - .terminal-3351527922-r5 { fill: #efe9e4 } - .terminal-3351527922-r6 { fill: #e1e1e1 } + .terminal-1031378205-r1 { fill: #c5c8c6 } + .terminal-1031378205-r2 { fill: #e1e1e1 } + .terminal-1031378205-r3 { fill: #e8e0e7 } + .terminal-1031378205-r4 { fill: #eae3e5 } + .terminal-1031378205-r5 { fill: #ede6e6 } + .terminal-1031378205-r6 { fill: #efe9e4 } - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - MinWidthApp + MinWidthApp - + - - - - min-width: 25% - - - - - min-width: 75% - - - - - - min-width: 100 - - - - - - min-width: 400h - - - + + + + min-width: 25% + + + + + min-width: 75% + + + + + + min-width: 100 + + + + + + min-width: 400h + + + @@ -9738,134 +10535,134 @@ font-weight: 700; } - .terminal-292160688-matrix { + .terminal-3520697079-matrix { font-family: Fira Code, monospace; font-size: 20px; line-height: 24.4px; font-variant-east-asian: full-width; } - .terminal-292160688-title { + .terminal-3520697079-title { font-size: 18px; font-weight: bold; font-family: arial; } - .terminal-292160688-r1 { fill: #000000 } - .terminal-292160688-r2 { fill: #0000ff } - .terminal-292160688-r3 { fill: #c5c8c6 } - .terminal-292160688-r4 { fill: #ff0000 } - .terminal-292160688-r5 { fill: #008000 } + .terminal-3520697079-r1 { fill: #000000 } + .terminal-3520697079-r2 { fill: #0000ff } + .terminal-3520697079-r3 { fill: #c5c8c6 } + .terminal-3520697079-r4 { fill: #ff0000 } + .terminal-3520697079-r5 { fill: #008000 } - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - OffsetApp + OffsetApp - - - - - Chani (offset 0  - ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀-3) - - - - Paul (offset 8 2)▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄ - - - - ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ - ▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄ - - - Duncan (offset 4  - 10) - - - - ▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄ - - - + + + + + Chani (offset 0  + ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀-3) + + + + Paul (offset 8 2)▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄ + + + + ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ + ▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄ + + + Duncan (offset 4  + 10) + + + + ▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄ + + + @@ -10217,133 +11014,133 @@ font-weight: 700; } - .terminal-3019471504-matrix { + .terminal-2040973868-matrix { font-family: Fira Code, monospace; font-size: 20px; line-height: 24.4px; font-variant-east-asian: full-width; } - .terminal-3019471504-title { + .terminal-2040973868-title { font-size: 18px; font-weight: bold; font-family: arial; } - .terminal-3019471504-r1 { fill: #c5c8c6 } - .terminal-3019471504-r2 { fill: #0178d4 } - .terminal-3019471504-r3 { fill: #e1e1e1 } - .terminal-3019471504-r4 { fill: #1e1e1e } + .terminal-2040973868-r1 { fill: #e1e1e1 } + .terminal-2040973868-r2 { fill: #0178d4 } + .terminal-2040973868-r3 { fill: #c5c8c6 } + .terminal-2040973868-r4 { fill: #1e1e1e } - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - AllOutlinesApp + AllOutlinesApp - + - - +------------------+╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍ - |ascii|blankdashed - +------------------+╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍ - - - ══════════════════━━━━━━━━━━━━━━━━━━ - doubleheavyhidden/none - ══════════════════━━━━━━━━━━━━━━━━━━ - - - ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄ - hkeyinnernone - ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ - - - ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀──────────────────────────────────── - outerroundsolid - ▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄──────────────────────────────────── - - - ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ - tallvkeywide - ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ + + +------------------+╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍ + |ascii|blankdashed + +------------------+╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍ + + + ══════════════════━━━━━━━━━━━━━━━━━━ + doubleheavyhidden/none + ══════════════════━━━━━━━━━━━━━━━━━━ + + + ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄ + hkeyinnernone + ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ + + + ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀──────────────────────────────────── + outerroundsolid + ▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄──────────────────────────────────── + + + ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ + tallvkeywide + ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ @@ -10531,136 +11328,136 @@ font-weight: 700; } - .terminal-2114496641-matrix { + .terminal-1076088988-matrix { font-family: Fira Code, monospace; font-size: 20px; line-height: 24.4px; font-variant-east-asian: full-width; } - .terminal-2114496641-title { + .terminal-1076088988-title { font-size: 18px; font-weight: bold; font-family: arial; } - .terminal-2114496641-r1 { fill: #c5c8c6 } - .terminal-2114496641-r2 { fill: #000000 } - .terminal-2114496641-r3 { fill: #008000 } - .terminal-2114496641-r4 { fill: #e5f0e5 } - .terminal-2114496641-r5 { fill: #036a03 } - .terminal-2114496641-r6 { fill: #14191f } + .terminal-1076088988-r1 { fill: #000000 } + .terminal-1076088988-r2 { fill: #c5c8c6 } + .terminal-1076088988-r3 { fill: #008000 } + .terminal-1076088988-r4 { fill: #e5f0e5 } + .terminal-1076088988-r5 { fill: #036a03 } + .terminal-1076088988-r6 { fill: #14191f } - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - OverflowApp + OverflowApp - - - - - ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ - I must not fear.I must not fear. - Fear is the mind-killer.Fear is the mind-killer. - Fear is the little-death that Fear is the little-death that  - brings total obliteration.brings total obliteration. - I will face my fear.I will face my fear. - I will permit it to pass over meI will permit it to pass over me  - and through me.and through me. - And when it has gone past, I And when it has gone past, I will  - will turn the inner eye to see turn the inner eye to see its  - its path.▁▁path. - Where the fear has gone there Where the fear has gone there will - will be nothing. Only I will be nothing. Only I will remain. - remain.▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ - ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ - ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ - ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁I must not fear. - I must not fear.Fear is the mind-killer. - Fear is the mind-killer.Fear is the little-death that  - Fear is the little-death that brings total obliteration. - brings total obliteration.I will face my fear. - I will face my fear.I will permit it to pass over me  - I will permit it to pass over meand through me. + + + + + ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ + I must not fear.I must not fear. + Fear is the mind-killer.Fear is the mind-killer. + Fear is the little-death that Fear is the little-death that  + brings total obliteration.brings total obliteration. + I will face my fear.I will face my fear. + I will permit it to pass over meI will permit it to pass over me  + and through me.and through me. + And when it has gone past, I And when it has gone past, I will  + will turn the inner eye to see turn the inner eye to see its  + its path.▁▁path. + Where the fear has gone there Where the fear has gone there will + will be nothing. Only I will be nothing. Only I will remain. + remain.▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ + ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ + ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ + ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁I must not fear. + I must not fear.Fear is the mind-killer. + Fear is the mind-killer.Fear is the little-death that  + Fear is the little-death that brings total obliteration. + brings total obliteration.I will face my fear. + I will face my fear.I will permit it to pass over me  + I will permit it to pass over meand through me. @@ -10845,138 +11642,139 @@ font-weight: 700; } - .terminal-1642992271-matrix { + .terminal-1905931680-matrix { font-family: Fira Code, monospace; font-size: 20px; line-height: 24.4px; font-variant-east-asian: full-width; } - .terminal-1642992271-title { + .terminal-1905931680-title { font-size: 18px; font-weight: bold; font-family: arial; } - .terminal-1642992271-r1 { fill: #c5c8c6 } - .terminal-1642992271-r2 { fill: #e7e0e6 } - .terminal-1642992271-r3 { fill: #eae2e4 } - .terminal-1642992271-r4 { fill: #ece5e5 } - .terminal-1642992271-r5 { fill: #eee8e3 } - .terminal-1642992271-r6 { fill: #e8ede4 } - .terminal-1642992271-r7 { fill: #e3ede7 } - .terminal-1642992271-r8 { fill: #e1eceb } - .terminal-1642992271-r9 { fill: #eeeddf } + .terminal-1905931680-r1 { fill: #e7e0e6 } + .terminal-1905931680-r2 { fill: #e0e0e0 } + .terminal-1905931680-r3 { fill: #c5c8c6 } + .terminal-1905931680-r4 { fill: #eae2e4 } + .terminal-1905931680-r5 { fill: #ece5e5 } + .terminal-1905931680-r6 { fill: #eee8e3 } + .terminal-1905931680-r7 { fill: #e8ede4 } + .terminal-1905931680-r8 { fill: #e3ede7 } + .terminal-1905931680-r9 { fill: #e1eceb } + .terminal-1905931680-r10 { fill: #eeeddf } - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - PaddingAllApp + PaddingAllApp - - - - no padding - padding: 1padding:padding: 1 1 - 1 52 6 - - - - - - - - - - padding-right: 3padding-bottom: 4padding-left: 3 - - - - padding-top: 4 - - - - - - + + + + no padding + padding: 1padding:padding: 1 1 + 1 52 6 + + + + + + + + + + padding-right: 3padding-bottom: 4padding-left: 3 + + + + padding-top: 4 + + + + + + @@ -11007,137 +11805,138 @@ font-weight: 700; } - .terminal-3799423623-matrix { + .terminal-3617589510-matrix { font-family: Fira Code, monospace; font-size: 20px; line-height: 24.4px; font-variant-east-asian: full-width; } - .terminal-3799423623-title { + .terminal-3617589510-title { font-size: 18px; font-weight: bold; font-family: arial; } - .terminal-3799423623-r1 { fill: #c5c8c6 } - .terminal-3799423623-r2 { fill: #efe9e4 } - .terminal-3799423623-r3 { fill: #ede6e6 } - .terminal-3799423623-r4 { fill: #eae3e5 } - .terminal-3799423623-r5 { fill: #e8e0e7 } - .terminal-3799423623-r6 { fill: #efeedf } - .terminal-3799423623-r7 { fill: #e9eee5 } - .terminal-3799423623-r8 { fill: #e4eee8 } + .terminal-3617589510-r1 { fill: #c5c8c6 } + .terminal-3617589510-r2 { fill: #e1e1e1 } + .terminal-3617589510-r3 { fill: #efe9e4 } + .terminal-3617589510-r4 { fill: #ede6e6 } + .terminal-3617589510-r5 { fill: #eae3e5 } + .terminal-3617589510-r6 { fill: #e8e0e7 } + .terminal-3617589510-r7 { fill: #efeedf } + .terminal-3617589510-r8 { fill: #e9eee5 } + .terminal-3617589510-r9 { fill: #e4eee8 } - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - MyApp + MyApp - + - - - - #p4 - - - #p3 - - - #p2 - - - #p1 - - - #p5 - - - #p6 - - - #p7 - - + + + + #p4 + + + #p3 + + + #p2 + + + #p1 + + + #p5 + + + #p6 + + + #p7 + + @@ -12588,138 +13387,138 @@ font-weight: 700; } - .terminal-1979075264-matrix { + .terminal-3534356536-matrix { font-family: Fira Code, monospace; font-size: 20px; line-height: 24.4px; font-variant-east-asian: full-width; } - .terminal-1979075264-title { + .terminal-3534356536-title { font-size: 18px; font-weight: bold; font-family: arial; } - .terminal-1979075264-r1 { fill: #e1e1e1 } - .terminal-1979075264-r2 { fill: #c5c8c6 } - .terminal-1979075264-r3 { fill: #e1e1e1;font-weight: bold } - .terminal-1979075264-r4 { fill: #e1e1e1;font-style: italic; } - .terminal-1979075264-r5 { fill: #1e1e1e } - .terminal-1979075264-r6 { fill: #e1e1e1;text-decoration: line-through; } - .terminal-1979075264-r7 { fill: #e1e1e1;text-decoration: underline; } - .terminal-1979075264-r8 { fill: #e1e1e1;font-weight: bold;font-style: italic; } - .terminal-1979075264-r9 { fill: #1e1e1e;text-decoration: line-through; } + .terminal-3534356536-r1 { fill: #e1e1e1 } + .terminal-3534356536-r2 { fill: #c5c8c6 } + .terminal-3534356536-r3 { fill: #e1e1e1;font-weight: bold } + .terminal-3534356536-r4 { fill: #e1e1e1;font-style: italic; } + .terminal-3534356536-r5 { fill: #1e1e1e } + .terminal-3534356536-r6 { fill: #e1e1e1;text-decoration: line-through; } + .terminal-3534356536-r7 { fill: #e1e1e1;text-decoration: underline; } + .terminal-3534356536-r8 { fill: #e1e1e1;font-weight: bold;font-style: italic; } + .terminal-3534356536-r9 { fill: #1e1e1e;text-decoration: line-through; } - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - AllTextStyleApp + AllTextStyleApp - + - - - nonebolditalicreverse - I must not fear.I must not fear.I must not fear.I must not fear. - Fear is the Fear is the Fear is the Fear is the  - mind-killer.mind-killer.mind-killer.mind-killer. - Fear is the Fear is the Fear is the Fear is the  - little-death thatlittle-death that little-death thatlittle-death that  - brings total brings total brings total brings total  - obliteration.obliteration.obliteration.obliteration. - I will face my I will face my I will face my I will face my  - fear.fear.fear.fear. - - strikeunderlinebold italicreverse strike - I must not fear.I must not fear.I must not fear.I must not fear. - Fear is the Fear is the Fear is the Fear is the  - mind-killer.mind-killer.mind-killer.mind-killer. - Fear is the Fear is the Fear is the Fear is the  - little-death thatlittle-death that little-death thatlittle-death that  - brings total brings total brings total brings total  - obliteration.obliteration.obliteration.obliteration. - I will face my I will face my I will face my I will face my  - fear.fear.fear.fear. - I will permit it I will permit it I will permit it I will permit it  + + + nonebolditalicreverse + I must not fear.I must not fear.I must not fear.I must not fear. + Fear is the Fear is the Fear is the Fear is the  + mind-killer.mind-killer.mind-killer.mind-killer. + Fear is the Fear is the Fear is the Fear is the  + little-death thatlittle-death that little-death thatlittle-death that  + brings total brings total brings total brings total  + obliteration.obliteration.obliteration.obliteration. + I will face my I will face my I will face my I will face my  + fear.fear.fear.fear. + + strikeunderlinebold italicreverse strike + I must not fear.I must not fear.I must not fear.I must not fear. + Fear is the Fear is the Fear is the Fear is the  + mind-killer.mind-killer.mind-killer.mind-killer. + Fear is the Fear is the Fear is the Fear is the  + little-death thatlittle-death that little-death thatlittle-death that  + brings total brings total brings total brings total  + obliteration.obliteration.obliteration.obliteration. + I will face my I will face my I will face my I will face my  + fear.fear.fear.fear. + I will permit it I will permit it I will permit it I will permit it  @@ -13070,136 +13869,137 @@ font-weight: 700; } - .terminal-3647302390-matrix { + .terminal-1982402907-matrix { font-family: Fira Code, monospace; font-size: 20px; line-height: 24.4px; font-variant-east-asian: full-width; } - .terminal-3647302390-title { + .terminal-1982402907-title { font-size: 18px; font-weight: bold; font-family: arial; } - .terminal-3647302390-r1 { fill: #c5c8c6 } - .terminal-3647302390-r2 { fill: #191118 } - .terminal-3647302390-r3 { fill: #1b1316 } - .terminal-3647302390-r4 { fill: #1d1717 } - .terminal-3647302390-r5 { fill: #141e19 } - .terminal-3647302390-r6 { fill: #121d1c } - .terminal-3647302390-r7 { fill: #101c1d } + .terminal-1982402907-r1 { fill: #c5c8c6 } + .terminal-1982402907-r2 { fill: #191118 } + .terminal-1982402907-r3 { fill: #1b1316 } + .terminal-1982402907-r4 { fill: #1d1717 } + .terminal-1982402907-r5 { fill: #e1e1e1 } + .terminal-1982402907-r6 { fill: #141e19 } + .terminal-1982402907-r7 { fill: #121d1c } + .terminal-1982402907-r8 { fill: #101c1d } - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - VisibilityContainersApp + VisibilityContainersApp - + - - - - - PlaceholderPlaceholderPlaceholder - - - - - - - - - - - - - - - - PlaceholderPlaceholderPlaceholder - - - + + + + + PlaceholderPlaceholderPlaceholder + + + + + + + + + + + + + + + + PlaceholderPlaceholderPlaceholder + + + @@ -13386,141 +14186,141 @@ font-weight: 700; } - .terminal-4051010257-matrix { + .terminal-3780599318-matrix { font-family: Fira Code, monospace; font-size: 20px; line-height: 24.4px; font-variant-east-asian: full-width; } - .terminal-4051010257-title { + .terminal-3780599318-title { font-size: 18px; font-weight: bold; font-family: arial; } - .terminal-4051010257-r1 { fill: #c5c8c6 } - .terminal-4051010257-r2 { fill: #e8e0e7 } - .terminal-4051010257-r3 { fill: #eae3e5 } - .terminal-4051010257-r4 { fill: #ede6e6 } - .terminal-4051010257-r5 { fill: #efe9e4 } - .terminal-4051010257-r6 { fill: #efeedf } - .terminal-4051010257-r7 { fill: #e9eee5 } - .terminal-4051010257-r8 { fill: #e4eee8 } - .terminal-4051010257-r9 { fill: #e2edeb } - .terminal-4051010257-r10 { fill: #dfebed } - .terminal-4051010257-r11 { fill: #ddedf9 } + .terminal-3780599318-r1 { fill: #c5c8c6 } + .terminal-3780599318-r2 { fill: #e8e0e7 } + .terminal-3780599318-r3 { fill: #eae3e5 } + .terminal-3780599318-r4 { fill: #ede6e6 } + .terminal-3780599318-r5 { fill: #efe9e4 } + .terminal-3780599318-r6 { fill: #efeedf } + .terminal-3780599318-r7 { fill: #e9eee5 } + .terminal-3780599318-r8 { fill: #e4eee8 } + .terminal-3780599318-r9 { fill: #e2edeb } + .terminal-3780599318-r10 { fill: #dfebed } + .terminal-3780599318-r11 { fill: #ddedf9 } - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - WidthComparisonApp + WidthComparisonApp - - - - - - - - - - - - - - - #cells#percent#w#h#vw#vh#auto#fr1#fr3 - - - - - - - - - - - - ····•····•····•····•····•····•····•····•····•····•····•····•····•····•····•····• + + + + + + + + + + + + + + + #cells#percent#w#h#vw#vh#auto#fr1#fr3 + + + + + + + + + + + + ····•····•····•····•····•····•····•····•····•····•····•····•····•····•····•····• @@ -13685,7 +14485,7 @@ ''' # --- -# name: test_datatable_column_cursor_render +# name: test_datatable_add_row_auto_height ''' @@ -13708,137 +14508,134 @@ font-weight: 700; } - .terminal-1071832686-matrix { + .terminal-3912008695-matrix { font-family: Fira Code, monospace; font-size: 20px; line-height: 24.4px; font-variant-east-asian: full-width; } - .terminal-1071832686-title { + .terminal-3912008695-title { font-size: 18px; font-weight: bold; font-family: arial; } - .terminal-1071832686-r1 { fill: #dde6ed;font-weight: bold } - .terminal-1071832686-r2 { fill: #1e1201;font-weight: bold } - .terminal-1071832686-r3 { fill: #dde6ed } - .terminal-1071832686-r4 { fill: #c5c8c6 } - .terminal-1071832686-r5 { fill: #dfe4e7 } - .terminal-1071832686-r6 { fill: #1e1405 } - .terminal-1071832686-r7 { fill: #e1e1e1 } - .terminal-1071832686-r8 { fill: #211505 } + .terminal-3912008695-r1 { fill: #dde6ed;font-weight: bold } + .terminal-3912008695-r2 { fill: #dde6ed } + .terminal-3912008695-r3 { fill: #c5c8c6 } + .terminal-3912008695-r4 { fill: #211505 } + .terminal-3912008695-r5 { fill: #e1e1e1 } - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - TableApp + AutoHeightRowsApp - - - -  lane  swimmer               country        time   -  4     Joseph Schooling      Singapore      50.39  -  2     Michael Phelps        United States  51.14  -  5     Chad le Clos          South Africa   51.14  -  6     László Cseh           Hungary        51.14  -  3     Li Zhuhao             China          51.26  -  8     Mehdy Metella         France         51.58  -  7     Tom Shields           United States  51.73  -  1     Aleksandr Sadovnikov  Russia         51.84  - - - - - - - - - - - - - - + + + +  N  Column      +  3  hey there   +  1  hey there   +  5  long        +  string      +  2  ╭───────╮   +  │ Hello │   +  │ world │   +  ╰───────╯   +  4  1           +  2           +  3           +  4           +  5           +  6           +  7           + + + + + + + @@ -13846,7 +14643,7 @@ ''' # --- -# name: test_datatable_labels_and_fixed_data +# name: test_datatable_add_row_auto_height_sorted ''' @@ -13869,135 +14666,134 @@ font-weight: 700; } - .terminal-1710966859-matrix { + .terminal-210732003-matrix { font-family: Fira Code, monospace; font-size: 20px; line-height: 24.4px; font-variant-east-asian: full-width; } - .terminal-1710966859-title { + .terminal-210732003-title { font-size: 18px; font-weight: bold; font-family: arial; } - .terminal-1710966859-r1 { fill: #dde6ed;font-weight: bold } - .terminal-1710966859-r2 { fill: #dde6ed } - .terminal-1710966859-r3 { fill: #c5c8c6 } - .terminal-1710966859-r4 { fill: #1e1405 } - .terminal-1710966859-r5 { fill: #dfe4e7 } - .terminal-1710966859-r6 { fill: #e1e1e1 } + .terminal-210732003-r1 { fill: #dde6ed;font-weight: bold } + .terminal-210732003-r2 { fill: #dde6ed } + .terminal-210732003-r3 { fill: #c5c8c6 } + .terminal-210732003-r4 { fill: #211505 } + .terminal-210732003-r5 { fill: #e1e1e1 } - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - TableApp + AutoHeightRowsApp - - - -  lane  swimmer               country        time   -  0  5     Chad le Clos          South Africa   51.14  -  1  4     Joseph Schooling      Singapore      50.39  -  2  2     Michael Phelps        United States  51.14  -  3  6     László Cseh           Hungary        51.14  -  4  3     Li Zhuhao             China          51.26  -  5  8     Mehdy Metella         France         51.58  -  6  7     Tom Shields           United States  51.73  -  7  10    Darren Burns          Scotland       51.84  -  8  1     Aleksandr Sadovnikov  Russia         51.84  - - - - - - - - - - - - - + + + +  N  Column      +  1  hey there   +  2  ╭───────╮   +  │ Hello │   +  │ world │   +  ╰───────╯   +  3  hey there   +  4  1           +  2           +  3           +  4           +  5           +  6           +  7           +  5  long        +  string      + + + + + + + @@ -14005,7 +14801,7 @@ ''' # --- -# name: test_datatable_remove_row +# name: test_datatable_cell_padding ''' @@ -14028,134 +14824,134 @@ font-weight: 700; } - .terminal-2304919999-matrix { + .terminal-1699433504-matrix { font-family: Fira Code, monospace; font-size: 20px; line-height: 24.4px; font-variant-east-asian: full-width; } - .terminal-2304919999-title { + .terminal-1699433504-title { font-size: 18px; font-weight: bold; font-family: arial; } - .terminal-2304919999-r1 { fill: #dde6ed;font-weight: bold } - .terminal-2304919999-r2 { fill: #dde6ed } - .terminal-2304919999-r3 { fill: #c5c8c6 } - .terminal-2304919999-r4 { fill: #211505 } - .terminal-2304919999-r5 { fill: #e1e1e1 } + .terminal-1699433504-r1 { fill: #e1e1e1 } + .terminal-1699433504-r2 { fill: #c5c8c6 } + .terminal-1699433504-r3 { fill: #dde6ed;font-weight: bold } + .terminal-1699433504-r4 { fill: #dde6ed } + .terminal-1699433504-r5 { fill: #211505 } - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - TableApp + TableApp - - - -  lane  swimmer               country        time   -  5     Chad le Clos          South Africa   51.14  -  4     Joseph Schooling      Singapore      50.39  -  6     László Cseh           Hungary        51.14  -  3     Li Zhuhao             China          51.26  -  7     Tom Shields           United States  51.73  -  10    Darren Burns          Scotland       51.84  - - - - - - - - - - - - - - - - + + + + + one  two  three + valuevalueval   + +  one    two    three  +  value  value  val    + +   one      two      three   +   value    value    val     + +    one        two        three    +    value      value      val      + +     one          two          three     +     value        value        val       + + + + + + + + @@ -14163,7 +14959,7 @@ ''' # --- -# name: test_datatable_render +# name: test_datatable_change_cell_padding ''' @@ -14186,134 +14982,134 @@ font-weight: 700; } - .terminal-2311386745-matrix { + .terminal-236473376-matrix { font-family: Fira Code, monospace; font-size: 20px; line-height: 24.4px; font-variant-east-asian: full-width; } - .terminal-2311386745-title { + .terminal-236473376-title { font-size: 18px; font-weight: bold; font-family: arial; } - .terminal-2311386745-r1 { fill: #dde6ed;font-weight: bold } - .terminal-2311386745-r2 { fill: #dde6ed } - .terminal-2311386745-r3 { fill: #c5c8c6 } - .terminal-2311386745-r4 { fill: #e1e1e1 } - .terminal-2311386745-r5 { fill: #211505 } + .terminal-236473376-r1 { fill: #e1e1e1 } + .terminal-236473376-r2 { fill: #c5c8c6 } + .terminal-236473376-r3 { fill: #dde6ed;font-weight: bold } + .terminal-236473376-r4 { fill: #dde6ed } + .terminal-236473376-r5 { fill: #211505 } - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - TableApp + TableApp - - - -  lane  swimmer               country        time   -  4     Joseph Schooling      Singapore      50.39  -  2     Michael Phelps        United States  51.14  -  5     Chad le Clos          South Africa   51.14  -  6     László Cseh           Hungary        51.14  -  3     Li Zhuhao             China          51.26  -  8     Mehdy Metella         France         51.58  -  7     Tom Shields           United States  51.73  -  1     Aleksandr Sadovnikov  Russia         51.84  -  10    Darren Burns          Scotland       51.84  - - - - - - - - - - - - - + + + + + one  two  three + valuevalueval   + +  one    two    three  +  value  value  val    + +   one      two      three   +   value    value    val     + +    one        two        three    +    value      value      val      + +           one                      two                      three           +           value                    value                    val             + + + + + + + + @@ -14321,7 +15117,7 @@ ''' # --- -# name: test_datatable_row_cursor_render +# name: test_datatable_column_cursor_render ''' @@ -14344,136 +15140,137 @@ font-weight: 700; } - .terminal-3008422431-matrix { + .terminal-1071832686-matrix { font-family: Fira Code, monospace; font-size: 20px; line-height: 24.4px; font-variant-east-asian: full-width; } - .terminal-3008422431-title { + .terminal-1071832686-title { font-size: 18px; font-weight: bold; font-family: arial; } - .terminal-3008422431-r1 { fill: #dde6ed;font-weight: bold } - .terminal-3008422431-r2 { fill: #dde6ed } - .terminal-3008422431-r3 { fill: #c5c8c6 } - .terminal-3008422431-r4 { fill: #dfe4e7 } - .terminal-3008422431-r5 { fill: #e1e1e1 } - .terminal-3008422431-r6 { fill: #1e1405 } - .terminal-3008422431-r7 { fill: #211505 } + .terminal-1071832686-r1 { fill: #dde6ed;font-weight: bold } + .terminal-1071832686-r2 { fill: #1e1201;font-weight: bold } + .terminal-1071832686-r3 { fill: #dde6ed } + .terminal-1071832686-r4 { fill: #c5c8c6 } + .terminal-1071832686-r5 { fill: #dfe4e7 } + .terminal-1071832686-r6 { fill: #1e1405 } + .terminal-1071832686-r7 { fill: #e1e1e1 } + .terminal-1071832686-r8 { fill: #211505 } - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - TableApp + TableApp - - - -  lane  swimmer               country        time   -  4     Joseph Schooling      Singapore      50.39  -  2     Michael Phelps        United States  51.14  -  5     Chad le Clos          South Africa   51.14  -  6     László Cseh           Hungary        51.14  -  3     Li Zhuhao             China          51.26  -  8     Mehdy Metella         France         51.58  -  7     Tom Shields           United States  51.73  -  1     Aleksandr Sadovnikov  Russia         51.84  - - - - - - - - - - - - - - + + + +  lane  swimmer               country        time   +  4     Joseph Schooling      Singapore      50.39  +  2     Michael Phelps        United States  51.14  +  5     Chad le Clos          South Africa   51.14  +  6     László Cseh           Hungary        51.14  +  3     Li Zhuhao             China          51.26  +  8     Mehdy Metella         France         51.58  +  7     Tom Shields           United States  51.73  +  1     Aleksandr Sadovnikov  Russia         51.84  + + + + + + + + + + + + + + @@ -14481,7 +15278,7 @@ ''' # --- -# name: test_datatable_sort_multikey +# name: test_datatable_hot_reloading ''' @@ -14504,134 +15301,137 @@ font-weight: 700; } - .terminal-2683041401-matrix { + .terminal-1782919787-matrix { font-family: Fira Code, monospace; font-size: 20px; line-height: 24.4px; font-variant-east-asian: full-width; } - .terminal-2683041401-title { + .terminal-1782919787-title { font-size: 18px; font-weight: bold; font-family: arial; } - .terminal-2683041401-r1 { fill: #dde6ed;font-weight: bold } - .terminal-2683041401-r2 { fill: #dde6ed } - .terminal-2683041401-r3 { fill: #c5c8c6 } - .terminal-2683041401-r4 { fill: #e1e1e1 } - .terminal-2683041401-r5 { fill: #211505 } + .terminal-1782919787-r1 { fill: #dde6ed;font-weight: bold } + .terminal-1782919787-r2 { fill: #dde6ed } + .terminal-1782919787-r3 { fill: #c5c8c6 } + .terminal-1782919787-r4 { fill: #1e1405 } + .terminal-1782919787-r5 { fill: #211505 } + .terminal-1782919787-r6 { fill: #e1e2e3 } + .terminal-1782919787-r7 { fill: #dfe4e7 } + .terminal-1782919787-r8 { fill: #e1e1e1 } - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - TableApp + DataTableHotReloadingApp - - - -  lane  swimmer               country        time   -  4     Joseph Schooling      Singapore      50.39  -  2     Michael Phelps        United States  51.14  -  5     Chad le Clos          South Africa   51.14  -  6     László Cseh           Hungary        51.14  -  3     Li Zhuhao             China          51.26  -  8     Mehdy Metella         France         51.58  -  7     Tom Shields           United States  51.73  -  1     Aleksandr Sadovnikov  Russia         51.84  -  10    Darren Burns          Scotland       51.84  - - - - - - - - - - - - - + + + +  A           B     +  one         two   +  three       four  +  five        six   + + + + + + + + + + + + + + + + + + + @@ -14639,7 +15439,7 @@ ''' # --- -# name: test_datatable_style_ordering +# name: test_datatable_labels_and_fixed_data ''' @@ -14662,137 +15462,135 @@ font-weight: 700; } - .terminal-1146140386-matrix { + .terminal-1710966859-matrix { font-family: Fira Code, monospace; font-size: 20px; line-height: 24.4px; font-variant-east-asian: full-width; } - .terminal-1146140386-title { + .terminal-1710966859-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-1710966859-r1 { fill: #dde6ed;font-weight: bold } + .terminal-1710966859-r2 { fill: #dde6ed } + .terminal-1710966859-r3 { fill: #c5c8c6 } + .terminal-1710966859-r4 { fill: #1e1405 } + .terminal-1710966859-r5 { fill: #dfe4e7 } + .terminal-1710966859-r6 { fill: #e1e1e1 } - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - DataTableCursorStyles + TableApp - - - - 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 + + + +  lane  swimmer               country        time   +  0  5     Chad le Clos          South Africa   51.14  +  1  4     Joseph Schooling      Singapore      50.39  +  2  2     Michael Phelps        United States  51.14  +  3  6     László Cseh           Hungary        51.14  +  4  3     Li Zhuhao             China          51.26  +  5  8     Mehdy Metella         France         51.58  +  6  7     Tom Shields           United States  51.73  +  7  10    Darren Burns          Scotland       51.84  +  8  1     Aleksandr Sadovnikov  Russia         51.84  + + + + + + + + + + + + + @@ -14800,9 +15598,9 @@ ''' # --- -# name: test_demo +# name: test_datatable_remove_row ''' - + - - + + - - + + - - + + - - - - - - - - - - - - - - - - - - - - + + - - + + - - + + - - + + - - + + - - + + - - + + - - + + - - + + - - + + - - + + - - + + - - + + - - + + - - + + - - + + - - + + - - + + - - + + - - + + - - + + - Textual Demo + TableApp - - - - Textual Demo - - - TOP - - ▆▆ - - Widgets - ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ - - - Rich contentTextual Demo - - Welcome! Textual is a framework for creating sophisticated - applications with the terminal.                            - CSS - ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ -  Start  - ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ - - ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ - - - - - - - - -  CTRL+B  Sidebar  CTRL+T  Toggle Dark mode  CTRL+S  Screenshot  F1  Notes  CTRL+Q  Quit  + + + +  lane  swimmer               country        time   +  5     Chad le Clos          South Africa   51.14  +  4     Joseph Schooling      Singapore      50.39  +  6     László Cseh           Hungary        51.14  +  3     Li Zhuhao             China          51.26  +  7     Tom Shields           United States  51.73  +  10    Darren Burns          Scotland       51.84  + + + + + + + + + + + + + + + + + ''' # --- -# name: test_digits +# name: test_datatable_render ''' @@ -15014,132 +15779,134 @@ font-weight: 700; } - .terminal-993607346-matrix { + .terminal-2311386745-matrix { font-family: Fira Code, monospace; font-size: 20px; line-height: 24.4px; font-variant-east-asian: full-width; } - .terminal-993607346-title { + .terminal-2311386745-title { font-size: 18px; font-weight: bold; font-family: arial; } - .terminal-993607346-r1 { fill: #e1e1e1;font-weight: bold } - .terminal-993607346-r2 { fill: #c5c8c6 } - .terminal-993607346-r3 { fill: #e1e1e1 } + .terminal-2311386745-r1 { fill: #dde6ed;font-weight: bold } + .terminal-2311386745-r2 { fill: #dde6ed } + .terminal-2311386745-r3 { fill: #c5c8c6 } + .terminal-2311386745-r4 { fill: #e1e1e1 } + .terminal-2311386745-r5 { fill: #211505 } - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - DigitApp + TableApp - - - - ╺━┓  ┓ ╻ ╻╺━┓╺━┓ -  ━┫  ┃ ┗━┫┏━┛  ┃ - ╺━┛.╺┻╸  ╹┗━╸  ╹ -    ┏━┓ ┓ ╺━┓╺━┓╻ ╻┏━╸┏━╸╺━┓┏━┓┏━┓         -    ┃ ┃ ┃ ┏━┛ ━┫┗━┫┗━┓┣━┓  ┃┣━┫┗━┫╺╋╸╺━╸   -    ┗━┛╺┻╸┗━╸╺━┛  ╹╺━┛┗━┛  ╹┗━┛╺━┛      ., - ╺━┓    ┓ ┏━┓ ^ ╻ ╻ -  ━┫ ×  ┃ ┃ ┃   ┗━┫ - ╺━┛   ╺┻╸┗━┛     ╹ - - - - - - - - - - - - - - + + + +  lane  swimmer               country        time   +  4     Joseph Schooling      Singapore      50.39  +  2     Michael Phelps        United States  51.14  +  5     Chad le Clos          South Africa   51.14  +  6     László Cseh           Hungary        51.14  +  3     Li Zhuhao             China          51.26  +  8     Mehdy Metella         France         51.58  +  7     Tom Shields           United States  51.73  +  1     Aleksandr Sadovnikov  Russia         51.84  +  10    Darren Burns          Scotland       51.84  + + + + + + + + + + + + + @@ -15147,7 +15914,7 @@ ''' # --- -# name: test_disabled_widgets +# name: test_datatable_row_cursor_render ''' @@ -15170,169 +15937,144 @@ font-weight: 700; } - .terminal-3864303289-matrix { + .terminal-3008422431-matrix { font-family: Fira Code, monospace; font-size: 20px; line-height: 24.4px; font-variant-east-asian: full-width; } - .terminal-3864303289-title { + .terminal-3008422431-title { font-size: 18px; font-weight: bold; font-family: arial; } - .terminal-3864303289-r1 { fill: #454a50 } - .terminal-3864303289-r2 { fill: #507bb3 } - .terminal-3864303289-r3 { fill: #7ae998 } - .terminal-3864303289-r4 { fill: #ffcf56 } - .terminal-3864303289-r5 { fill: #e76580 } - .terminal-3864303289-r6 { fill: #c5c8c6 } - .terminal-3864303289-r7 { fill: #24292f;font-weight: bold } - .terminal-3864303289-r8 { fill: #dde6ed;font-weight: bold } - .terminal-3864303289-r9 { fill: #0a180e;font-weight: bold } - .terminal-3864303289-r10 { fill: #211505;font-weight: bold } - .terminal-3864303289-r11 { fill: #f5e5e9;font-weight: bold } - .terminal-3864303289-r12 { fill: #000000 } - .terminal-3864303289-r13 { fill: #001541 } - .terminal-3864303289-r14 { fill: #008139 } - .terminal-3864303289-r15 { fill: #b86b00 } - .terminal-3864303289-r16 { fill: #780028 } - .terminal-3864303289-r17 { fill: #303336 } - .terminal-3864303289-r18 { fill: #364b66 } - .terminal-3864303289-r19 { fill: #4a8159 } - .terminal-3864303289-r20 { fill: #8b7439 } - .terminal-3864303289-r21 { fill: #80404d } - .terminal-3864303289-r22 { fill: #a7a7a7;font-weight: bold } - .terminal-3864303289-r23 { fill: #a5a9ac;font-weight: bold } - .terminal-3864303289-r24 { fill: #0e1510;font-weight: bold } - .terminal-3864303289-r25 { fill: #19140c;font-weight: bold } - .terminal-3864303289-r26 { fill: #b0a8aa;font-weight: bold } - .terminal-3864303289-r27 { fill: #0f0f0f } - .terminal-3864303289-r28 { fill: #0f192e } - .terminal-3864303289-r29 { fill: #0f4e2a } - .terminal-3864303289-r30 { fill: #68430f } - .terminal-3864303289-r31 { fill: #4a0f22 } - .terminal-3864303289-r32 { fill: #e2e3e3;font-weight: bold } + .terminal-3008422431-r1 { fill: #dde6ed;font-weight: bold } + .terminal-3008422431-r2 { fill: #dde6ed } + .terminal-3008422431-r3 { fill: #c5c8c6 } + .terminal-3008422431-r4 { fill: #dfe4e7 } + .terminal-3008422431-r5 { fill: #e1e1e1 } + .terminal-3008422431-r6 { fill: #1e1405 } + .terminal-3008422431-r7 { fill: #211505 } - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - WidgetDisableTestApp + TableApp - - - - ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ -  Button  Button  Button  Button  Button  - ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ - ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ -  Button  Button  Button  Button  Button  - ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ - ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ -  Button  Button  Button  Button  Button  - ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ - ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ -  Button  Button  Button  Button  Button  - ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ - ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ -  Button  Button  Button  Button  Button  - ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ - ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ -  Button  Button  Button  Button  Button  - ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ - ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ -  Button  Button  Button  Button  Button  - ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ - ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ -  Button  Button  Button  Button  Button  - ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ - - + + + +  lane  swimmer               country        time   +  4     Joseph Schooling      Singapore      50.39  +  2     Michael Phelps        United States  51.14  +  5     Chad le Clos          South Africa   51.14  +  6     László Cseh           Hungary        51.14  +  3     Li Zhuhao             China          51.26  +  8     Mehdy Metella         France         51.58  +  7     Tom Shields           United States  51.73  +  1     Aleksandr Sadovnikov  Russia         51.84  + + + + + + + + + + + + + + + + + ''' # --- -# name: test_dock_layout_sidebar +# name: test_datatable_sort_multikey ''' @@ -15355,133 +16097,134 @@ font-weight: 700; } - .terminal-3539432312-matrix { + .terminal-2683041401-matrix { font-family: Fira Code, monospace; font-size: 20px; line-height: 24.4px; font-variant-east-asian: full-width; } - .terminal-3539432312-title { + .terminal-2683041401-title { font-size: 18px; font-weight: bold; font-family: arial; } - .terminal-3539432312-r1 { fill: #0f2b41 } - .terminal-3539432312-r2 { fill: #c5c8c6 } - .terminal-3539432312-r3 { fill: #e1e1e1 } - .terminal-3539432312-r4 { fill: #14191f } + .terminal-2683041401-r1 { fill: #dde6ed;font-weight: bold } + .terminal-2683041401-r2 { fill: #dde6ed } + .terminal-2683041401-r3 { fill: #c5c8c6 } + .terminal-2683041401-r4 { fill: #e1e1e1 } + .terminal-2683041401-r5 { fill: #211505 } - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - DockLayoutExample + TableApp - - - - Sidebar1Docking a widget removes it from the layout and  - fixes its position, aligned to either the top,  - right, bottom, or left edges of a container. - - Docked widgets will not scroll out of view,  - making them ideal for sticky headers, footers,  - and sidebars. - ▇▇ - Docking a widget removes it from the layout and  - fixes its position, aligned to either the top,  - right, bottom, or left edges of a container. - - Docked widgets will not scroll out of view,  - making them ideal for sticky headers, footers,  - and sidebars. - - Docking a widget removes it from the layout and  - fixes its position, aligned to either the top,  - right, bottom, or left edges of a container. - - Docked widgets will not scroll out of view,  - making them ideal for sticky headers, footers,  - and sidebars. + + + +  lane  swimmer               country        time   +  4     Joseph Schooling      Singapore      50.39  +  2     Michael Phelps        United States  51.14  +  5     Chad le Clos          South Africa   51.14  +  6     László Cseh           Hungary        51.14  +  3     Li Zhuhao             China          51.26  +  8     Mehdy Metella         France         51.58  +  7     Tom Shields           United States  51.73  +  1     Aleksandr Sadovnikov  Russia         51.84  +  10    Darren Burns          Scotland       51.84  + + + + + + + + + + + + + @@ -15489,9 +16232,9 @@ ''' # --- -# name: test_dock_scroll +# name: test_datatable_style_ordering ''' - + - - + + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - - - - TestApp + DataTableCursorStyles - - - - TestApp - ───────── - this - is - a - sample - sentence - and - here - are - some - wordsthis - is - a - sample - sentence - and - here - are - some - words -  CTRL+Q  Quit  - - - ▇▇ - - - - + + + + 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 + + + + + ''' # --- -# name: test_dock_scroll2 +# name: test_demo ''' - + - - + + - - + + - - + + - - + + - - + + - - + + - - + + - - + + - - + + - - + + - - + + - - + + - - + + - - + + - - + + - - + + - - + + - - + + - - + + - - + + - - + + - - + + - - + + - - + + - - + + + + + + + + + + + + + + + + + - TestApp + Textual Demo - - - - TestApp - ───────── - this - is - a - sample - sentence - and - here - are - some - wordsthis - is - a▅▅ - sample - sentence - and - here - are - some - words -  CTRL+Q  Quit  - - - + + + + Textual Demo + + + TOP + + ▆▆ + + Widgets + ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ + + + Rich contentTextual Demo + + Welcome! Textual is a framework for creating sophisticated + applications with the terminal.                            + CSS + ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ + Start + ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ + + ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ + + + + + + + + +  CTRL+B  Sidebar  CTRL+T  Toggle Dark mode  CTRL+S  Screenshot  F1  Notes  CTRL+Q  Quit  ''' # --- -# name: test_dock_scroll_off_by_one +# name: test_digits ''' - + - - + + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - - - - ScrollOffByOne + DigitApp - - - - ▔▔▔▔▔▔▔▔ - X 92 - ▁▁▁▁▁▁▁▁ - ▔▔▔▔▔▔▔▔ - X 93 - ▁▁▁▁▁▁▁▁ - ▔▔▔▔▔▔▔▔ - X 94 - ▁▁▁▁▁▁▁▁ - ▔▔▔▔▔▔▔▔ - X 95 - ▁▁▁▁▁▁▁▁ - ▔▔▔▔▔▔▔▔ - X 96 - ▁▁▁▁▁▁▁▁ - ▔▔▔▔▔▔▔▔ - X 97 - ▁▁▁▁▁▁▁▁ - ▔▔▔▔▔▔▔▔ - X 98 - ▁▁▁▁▁▁▁▁ - ▔▔▔▔▔▔▔▔ - X 99▁▁ - ▁▁▁▁▁▁▁▁ + + + + ╺━┓  ┓ ╻ ╻╺━┓╺━┓ +  ━┫  ┃ ┗━┫┏━┛  ┃ + ╺━┛.╺┻╸  ╹┗━╸  ╹ +    ┏━┓ ┓ ╺━┓╺━┓╻ ╻┏━╸┏━╸╺━┓┏━┓┏━┓         +    ┃ ┃ ┃ ┏━┛ ━┫┗━┫┗━┓┣━┓  ┃┣━┫┗━┫╺╋╸╺━╸   +    ┗━┛╺┻╸┗━╸╺━┛  ╹╺━┛┗━┛  ╹┗━┛╺━┛      ., + ╺━┓    ┓ ┏━┓ ^ ╻ ╻ +  ━┫ ×  ┃ ┃ ┃   ┗━┫ + ╺━┛   ╺┻╸┗━┛     ╹ + + + + + + + + + + + + + + @@ -15982,7 +16740,7 @@ ''' # --- -# name: test_focus_component_class +# name: test_disabled_widgets ''' @@ -16005,143 +16763,169 @@ font-weight: 700; } - .terminal-3936062011-matrix { + .terminal-3209943725-matrix { font-family: Fira Code, monospace; font-size: 20px; line-height: 24.4px; font-variant-east-asian: full-width; } - .terminal-3936062011-title { + .terminal-3209943725-title { font-size: 18px; font-weight: bold; font-family: arial; } - .terminal-3936062011-r1 { fill: #c5c8c6 } - .terminal-3936062011-r2 { fill: #e3e3e3 } - .terminal-3936062011-r3 { fill: #ffdddd } - .terminal-3936062011-r4 { fill: #e1e1e1 } - .terminal-3936062011-r5 { fill: #14191f } - .terminal-3936062011-r6 { fill: #ddedf9 } + .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 } - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - StyleBugApp + WidgetDisableTestApp - - - - StyleBugApp - test widget 0 - test widget 1 - test widget 2 - test widget 3 - test widget 4 - test widget 5 - test widget 6 - test widget 7 - test widget 8 - test widget 9 - test widget 10 - test widget 11 - test widget 12▇▇ - test widget 13 - test widget 14 - test widget 15 - test widget 16 - test widget 17 - test widget 18 - test widget 19 - test widget 20 - test widget 21 - + + + + ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ + ButtonButtonButtonButtonButton + ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ + ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ + ButtonButtonButtonButtonButton + ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ + ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ + ButtonButtonButtonButtonButton + ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ + ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ + ButtonButtonButtonButtonButton + ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ + ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ + ButtonButtonButtonButtonButton + ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ + ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ + ButtonButtonButtonButtonButton + ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ + ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ + ButtonButtonButtonButtonButton + ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ + ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ + ButtonButtonButtonButtonButton + ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ ''' # --- -# name: test_footer_render +# name: test_dock_layout_sidebar ''' @@ -16164,141 +16948,141 @@ font-weight: 700; } - .terminal-1971839132-matrix { + .terminal-3539432312-matrix { font-family: Fira Code, monospace; font-size: 20px; line-height: 24.4px; font-variant-east-asian: full-width; } - .terminal-1971839132-title { + .terminal-3539432312-title { font-size: 18px; font-weight: bold; font-family: arial; } - .terminal-1971839132-r1 { fill: #e1e1e1 } - .terminal-1971839132-r2 { fill: #c5c8c6 } - .terminal-1971839132-r3 { fill: #dde8f3;font-weight: bold } - .terminal-1971839132-r4 { fill: #ddedf9 } + .terminal-3539432312-r1 { fill: #0f2b41 } + .terminal-3539432312-r2 { fill: #c5c8c6 } + .terminal-3539432312-r3 { fill: #e1e1e1 } + .terminal-3539432312-r4 { fill: #14191f } - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - FooterApp + DockLayoutExample - - - - - - - - - - - - - - - - - - - - - - - - - - -  Q  Quit the app  ?  Show help screen  DELETE  Delete the thing  + + + + Sidebar1Docking a widget removes it from the layout and  + fixes its position, aligned to either the top,  + right, bottom, or left edges of a container. + + Docked widgets will not scroll out of view,  + making them ideal for sticky headers, footers,  + and sidebars. + ▇▇ + Docking a widget removes it from the layout and  + fixes its position, aligned to either the top,  + right, bottom, or left edges of a container. + + Docked widgets will not scroll out of view,  + making them ideal for sticky headers, footers,  + and sidebars. + + Docking a widget removes it from the layout and  + fixes its position, aligned to either the top,  + right, bottom, or left edges of a container. + + Docked widgets will not scroll out of view,  + making them ideal for sticky headers, footers,  + and sidebars. + ''' # --- -# name: test_fr_margins +# name: test_dock_scroll ''' @@ -16321,148 +17105,151 @@ font-weight: 700; } - .terminal-1665781252-matrix { + .terminal-3671710802-matrix { font-family: Fira Code, monospace; font-size: 20px; line-height: 24.4px; font-variant-east-asian: full-width; } - .terminal-1665781252-title { + .terminal-3671710802-title { font-size: 18px; font-weight: bold; font-family: arial; } - .terminal-1665781252-r1 { fill: #008000 } - .terminal-1665781252-r2 { fill: #c5c8c6 } - .terminal-1665781252-r3 { fill: #e0e4e0 } - .terminal-1665781252-r4 { fill: #e0e6e0 } + .terminal-3671710802-r1 { fill: #c5c8c6 } + .terminal-3671710802-r2 { fill: #1e1e1e } + .terminal-3671710802-r3 { fill: #1f1f1f } + .terminal-3671710802-r4 { fill: #ff0000 } + .terminal-3671710802-r5 { fill: #dde8f3;font-weight: bold } + .terminal-3671710802-r6 { fill: #ddedf9 } + .terminal-3671710802-r7 { fill: #c7cdd2 } - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - TestApp + TestApp - - - - ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ - - - Hello - - - - - - - World - - - - - - - !! - - - - - - - ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ - - - - - ''' -# --- -# name: test_fr_unit_with_min - ''' - - + + + + TestApp + ───────── + this + is + a + sample + sentence + and + here + are + some + wordsthis + is + a + sample + sentence + and + here + are + some + words +  CTRL+Q  Quit  + + + ▇▇ + + + + + ''' +# --- +# name: test_dock_scroll2 + ''' + + - - + + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + + + + - ScreenSplitApp + TestApp - - - - ScreenSplitApp - This is content This is content number 0 - number 0This is content number 1 - This is content ▄▄This is content number 2 - number 1This is content number 3 - This is content This is content number 4▁▁ - number 2This is content number 5 - This is content This is content number 6 - number 3This is content number 7 - This is content This is content number 8 - number 4This is content number 9 - This is content This is content number 10 - number 5This is content number 11 - This is content This is content number 12 - number 6This is content number 13 - This is content This is content number 14 - number 7This is content number 15 - This is content This is content number 16 - number 8This is content number 17 - This is content This is content number 18 - number 9This is content number 19 - This is content This is content number 20 - number 10This is content number 21 + + + + TestApp + ───────── + this + is + a + sample + sentence + and + here + are + some + wordsthis + is + a▅▅ + sample + sentence + and + here + are + some + words +  CTRL+Q  Quit  + + @@ -16619,9 +17410,9 @@ ''' # --- -# name: test_fr_units +# name: test_dock_scroll_off_by_one ''' - + - - + + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + + + + - FRApp + ScrollOffByOne - - - - ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ - HEADER - - - - ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ - ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ - foobarbaz - - - - - - - - - - - - ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ - ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ - FOOTER - - ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + + + ▔▔▔▔▔▔▔▔ + X 92 + ▁▁▁▁▁▁▁▁ + ▔▔▔▔▔▔▔▔ + X 93 + ▁▁▁▁▁▁▁▁ + ▔▔▔▔▔▔▔▔ + X 94 + ▁▁▁▁▁▁▁▁ + ▔▔▔▔▔▔▔▔ + X 95 + ▁▁▁▁▁▁▁▁ + ▔▔▔▔▔▔▔▔ + X 96 + ▁▁▁▁▁▁▁▁ + ▔▔▔▔▔▔▔▔ + X 97 + ▁▁▁▁▁▁▁▁ + ▔▔▔▔▔▔▔▔ + X 98 + ▁▁▁▁▁▁▁▁ + ▔▔▔▔▔▔▔▔ + X 99▁▁ + ▁▁▁▁▁▁▁▁ + ''' # --- -# name: test_grid_layout_basic +# name: test_focus_component_class ''' @@ -16798,140 +17598,143 @@ font-weight: 700; } - .terminal-3077119198-matrix { + .terminal-3936062011-matrix { font-family: Fira Code, monospace; font-size: 20px; line-height: 24.4px; font-variant-east-asian: full-width; } - .terminal-3077119198-title { + .terminal-3936062011-title { font-size: 18px; font-weight: bold; font-family: arial; } - .terminal-3077119198-r1 { fill: #008000 } - .terminal-3077119198-r2 { fill: #c5c8c6 } - .terminal-3077119198-r3 { fill: #e1e1e1 } + .terminal-3936062011-r1 { fill: #c5c8c6 } + .terminal-3936062011-r2 { fill: #e3e3e3 } + .terminal-3936062011-r3 { fill: #ffdddd } + .terminal-3936062011-r4 { fill: #e1e1e1 } + .terminal-3936062011-r5 { fill: #14191f } + .terminal-3936062011-r6 { fill: #ddedf9 } - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - GridLayoutExample + StyleBugApp - - - - ────────────────────────────────────────────────────────────────────────── - OneTwoThree - - - - - - - - - - ────────────────────────────────────────────────────────────────────────── - ────────────────────────────────────────────────────────────────────────── - FourFiveSix - - - - - - - - - - ────────────────────────────────────────────────────────────────────────── + + + + StyleBugApp + test widget 0 + test widget 1 + test widget 2 + test widget 3 + test widget 4 + test widget 5 + test widget 6 + test widget 7 + test widget 8 + test widget 9 + test widget 10 + test widget 11 + test widget 12▇▇ + test widget 13 + test widget 14 + test widget 15 + test widget 16 + test widget 17 + test widget 18 + test widget 19 + test widget 20 + test widget 21 + ''' # --- -# name: test_grid_layout_basic_overflow +# name: test_footer_render ''' @@ -16954,142 +17757,143 @@ font-weight: 700; } - .terminal-1958232742-matrix { + .terminal-1971839132-matrix { font-family: Fira Code, monospace; font-size: 20px; line-height: 24.4px; font-variant-east-asian: full-width; } - .terminal-1958232742-title { + .terminal-1971839132-title { font-size: 18px; font-weight: bold; font-family: arial; } - .terminal-1958232742-r1 { fill: #008000 } - .terminal-1958232742-r2 { fill: #c5c8c6 } - .terminal-1958232742-r3 { fill: #e1e1e1 } + .terminal-1971839132-r1 { fill: #e1e1e1 } + .terminal-1971839132-r2 { fill: #c5c8c6 } + .terminal-1971839132-r3 { fill: #dde8f3;font-weight: bold } + .terminal-1971839132-r4 { fill: #ddedf9 } - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - GridLayoutExample + FooterApp - - - - ────────────────────────────────────────────────────────────────────────── - OneTwoThree - - - - - - ────────────────────────────────────────────────────────────────────────── - ────────────────────────────────────────────────────────────────────────── - FourFiveSix - - - - - - ────────────────────────────────────────────────────────────────────────── - ──────────────────────── - Seven - - - - - - ──────────────────────── + + + + + + + + + + + + + + + + + + + + + + + + + + +  Q  Quit the app  ?  Show help screen  DELETE  Delete the thing  ''' # --- -# name: test_grid_layout_gutter +# name: test_fr_margins ''' - + - - + + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + + + + - GridLayoutExample + TestApp - - - - OneTwoThree - - - - - - - - - - - - FourFiveSix - - - - - - - - - - - + + + + ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + + Hello + + + + + + + World + + + + + + + !! + + + + + + + ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ ''' # --- -# name: test_header_render +# name: test_fr_unit_with_min ''' @@ -17266,132 +18075,136 @@ font-weight: 700; } - .terminal-4077214022-matrix { + .terminal-2211506176-matrix { font-family: Fira Code, monospace; font-size: 20px; line-height: 24.4px; font-variant-east-asian: full-width; } - .terminal-4077214022-title { + .terminal-2211506176-title { font-size: 18px; font-weight: bold; font-family: arial; } - .terminal-4077214022-r1 { fill: #c5c8c6 } - .terminal-4077214022-r2 { fill: #e3e3e3 } - .terminal-4077214022-r3 { fill: #e1e1e1 } + .terminal-2211506176-r1 { fill: #c5c8c6 } + .terminal-2211506176-r2 { fill: #e3e3e3 } + .terminal-2211506176-r3 { fill: #ddddff } + .terminal-2211506176-r4 { fill: #e3e4e5 } + .terminal-2211506176-r5 { fill: #e2e3e3 } + .terminal-2211506176-r6 { fill: #14191f } + .terminal-2211506176-r7 { fill: #ddedf9 } - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - HeaderApp + ScreenSplitApp - - - - HeaderApp - - - - - - - - - - - - - - - - - - - - - - + + + + ScreenSplitApp + This is content This is content number 0 + number 0This is content number 1 + This is content ▄▄This is content number 2 + number 1This is content number 3 + This is content This is content number 4▁▁ + number 2This is content number 5 + This is content This is content number 6 + number 3This is content number 7 + This is content This is content number 8 + number 4This is content number 9 + This is content This is content number 10 + number 5This is content number 11 + This is content This is content number 12 + number 6This is content number 13 + This is content This is content number 14 + number 7This is content number 15 + This is content This is content number 16 + number 8This is content number 17 + This is content This is content number 18 + number 9This is content number 19 + This is content This is content number 20 + number 10This is content number 21 @@ -17399,7 +18212,7 @@ ''' # --- -# name: test_horizontal_layout +# name: test_fr_units ''' @@ -17422,140 +18235,140 @@ font-weight: 700; } - .terminal-1769115774-matrix { + .terminal-230484307-matrix { font-family: Fira Code, monospace; font-size: 20px; line-height: 24.4px; font-variant-east-asian: full-width; } - .terminal-1769115774-title { + .terminal-230484307-title { font-size: 18px; font-weight: bold; font-family: arial; } - .terminal-1769115774-r1 { fill: #008000 } - .terminal-1769115774-r2 { fill: #c5c8c6 } - .terminal-1769115774-r3 { fill: #e1e1e1 } + .terminal-230484307-r1 { fill: #ffffff } + .terminal-230484307-r2 { fill: #c5c8c6 } + .terminal-230484307-r3 { fill: #e2e2e2 } - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - HorizontalLayoutExample + FRApp - - - - ────────────────────────────────────────────────────────────────────────── - OneTwoThree - - - - - - - - - - - - - - - - - - - - - - ────────────────────────────────────────────────────────────────────────── + + + + ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + HEADER + + + + ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + foobarbaz + + + + + + + + + + + + ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + FOOTER + + ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ ''' # --- -# name: test_horizontal_layout_width_auto_dock +# name: test_grid_layout_basic ''' @@ -17578,301 +18391,296 @@ font-weight: 700; } - .terminal-3689181897-matrix { + .terminal-3077119198-matrix { font-family: Fira Code, monospace; font-size: 20px; line-height: 24.4px; font-variant-east-asian: full-width; } - .terminal-3689181897-title { + .terminal-3077119198-title { font-size: 18px; font-weight: bold; font-family: arial; } - .terminal-3689181897-r1 { fill: #e1f0ff } - .terminal-3689181897-r2 { fill: #c5c8c6 } - .terminal-3689181897-r3 { fill: #e1e1e1 } - .terminal-3689181897-r4 { fill: #ebf0e2 } - .terminal-3689181897-r5 { fill: #f7e0ef } + .terminal-3077119198-r1 { fill: #008000 } + .terminal-3077119198-r2 { fill: #c5c8c6 } + .terminal-3077119198-r3 { fill: #e1e1e1 } - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - HorizontalAutoWidth + GridLayoutExample - - - - Docke - Widget 1Widget 2 - left  - 1Docked left 2 - - - - - - - - - - - - - - - - - - - - - - - - - ''' -# --- -# name: test_input_and_focus - ''' - - - + + ''' +# --- +# name: test_grid_layout_basic_overflow + ''' + + + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - InputApp + GridLayoutExample - - - - ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ - Darren - ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ - ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ - Burns - ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ - - - - - - - - - - - - - - - - - - + + + + ────────────────────────────────────────────────────────────────────────── + OneTwoThree + + + + + + ────────────────────────────────────────────────────────────────────────── + ────────────────────────────────────────────────────────────────────────── + FourFiveSix + + + + + + ────────────────────────────────────────────────────────────────────────── + ──────────────────────── + Seven + + + + + + ──────────────────────── ''' # --- -# name: test_input_suggestions +# name: test_grid_layout_gutter ''' @@ -17895,136 +18703,132 @@ font-weight: 700; } - .terminal-1319604136-matrix { + .terminal-721777988-matrix { font-family: Fira Code, monospace; font-size: 20px; line-height: 24.4px; font-variant-east-asian: full-width; } - .terminal-1319604136-title { + .terminal-721777988-title { font-size: 18px; font-weight: bold; font-family: arial; } - .terminal-1319604136-r1 { fill: #1e1e1e } - .terminal-1319604136-r2 { fill: #0178d4 } - .terminal-1319604136-r3 { fill: #c5c8c6 } - .terminal-1319604136-r4 { fill: #e2e2e2 } - .terminal-1319604136-r5 { fill: #1e1e1e;font-style: italic; } - .terminal-1319604136-r6 { fill: #ff0000;font-style: italic; } - .terminal-1319604136-r7 { fill: #e1e1e1 } + .terminal-721777988-r1 { fill: #efddef } + .terminal-721777988-r2 { fill: #c5c8c6 } + .terminal-721777988-r3 { fill: #f0fcf0 } - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - FruitsApp + GridLayoutExample - - - - ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ - strawberry - ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ - - - - - - - - - - - - - - - - - - - - + + + + OneTwoThree + + + + + + + + + + + + FourFiveSix + + + + + + + + + + @@ -18032,7 +18836,7 @@ ''' # --- -# name: test_input_validation +# name: test_header_render ''' @@ -18055,138 +18859,132 @@ font-weight: 700; } - .terminal-922438230-matrix { + .terminal-4077214022-matrix { font-family: Fira Code, monospace; font-size: 20px; line-height: 24.4px; font-variant-east-asian: full-width; } - .terminal-922438230-title { + .terminal-4077214022-title { font-size: 18px; font-weight: bold; font-family: arial; } - .terminal-922438230-r1 { fill: #e1e1e1 } - .terminal-922438230-r2 { fill: #c5c8c6 } - .terminal-922438230-r3 { fill: #1e1e1e } - .terminal-922438230-r4 { fill: #7b3042 } - .terminal-922438230-r5 { fill: #e2e2e2 } - .terminal-922438230-r6 { fill: #3a7e4f } - .terminal-922438230-r7 { fill: #b93c5b } - .terminal-922438230-r8 { fill: #121212 } - .terminal-922438230-r9 { fill: #787878 } + .terminal-4077214022-r1 { fill: #c5c8c6 } + .terminal-4077214022-r2 { fill: #e3e3e3 } + .terminal-4077214022-r3 { fill: #e1e1e1 } - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - InputApp + HeaderApp - - - - - ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ - -2 - ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ - - ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ - 3 - ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ - - ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ - -2 - ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ - - ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ - Enter a number between 1 and 5 - ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ - - - - - - - + + + + HeaderApp + + + + + + + + + + + + + + + + + + + + + + @@ -18194,7 +18992,7 @@ ''' # --- -# name: test_key_display +# name: test_horizontal_layout ''' @@ -18217,141 +19015,140 @@ font-weight: 700; } - .terminal-1765381587-matrix { + .terminal-1769115774-matrix { font-family: Fira Code, monospace; font-size: 20px; line-height: 24.4px; font-variant-east-asian: full-width; } - .terminal-1765381587-title { + .terminal-1769115774-title { font-size: 18px; font-weight: bold; font-family: arial; } - .terminal-1765381587-r1 { fill: #e1e1e1 } - .terminal-1765381587-r2 { fill: #c5c8c6 } - .terminal-1765381587-r3 { fill: #dde8f3;font-weight: bold } - .terminal-1765381587-r4 { fill: #ddedf9 } + .terminal-1769115774-r1 { fill: #008000 } + .terminal-1769115774-r2 { fill: #c5c8c6 } + .terminal-1769115774-r3 { fill: #e1e1e1 } - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - KeyDisplayApp + HorizontalLayoutExample - - - - - - - - - - - - - - - - - - - - - - - - - - -  ?  Question  ^q  Quit app  Escape!  Escape  A  Letter A  + + + + ────────────────────────────────────────────────────────────────────────── + OneTwoThree + + + + + + + + + + + + + + + + + + + + + + ────────────────────────────────────────────────────────────────────────── ''' # --- -# name: test_label_widths +# name: test_horizontal_layout_width_auto_dock ''' @@ -18374,134 +19171,135 @@ font-weight: 700; } - .terminal-248448564-matrix { + .terminal-1672359278-matrix { font-family: Fira Code, monospace; font-size: 20px; line-height: 24.4px; font-variant-east-asian: full-width; } - .terminal-248448564-title { + .terminal-1672359278-title { font-size: 18px; font-weight: bold; font-family: arial; } - .terminal-248448564-r1 { fill: #1f1f1f } - .terminal-248448564-r2 { fill: #c5c8c6 } - .terminal-248448564-r3 { fill: #00ff00 } - .terminal-248448564-r4 { fill: #1b1b1b } - .terminal-248448564-r5 { fill: #121e12 } + .terminal-1672359278-r1 { fill: #e1f0ff } + .terminal-1672359278-r2 { fill: #e7e5ef } + .terminal-1672359278-r3 { fill: #e1e1e1 } + .terminal-1672359278-r4 { fill: #c5c8c6 } + .terminal-1672359278-r5 { fill: #ebf0e2 } + .terminal-1672359278-r6 { fill: #f7e0ef } - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - LabelWrap + HorizontalAutoWidth - - - - - - - - - - Apple Banana Cherry Mango Fig Guava Pineapple:Dragon Unicorn Centaur Phoenix Ch - - - Apple Banana Cherry Mango Fig Guava Pineapple:Dragon Unicorn Centaur Phoenix  - Chimera Castle - - - ╭────────────────────────────────────────────────────────────────────────────╮ - Apple Banana Cherry Mango Fig Guava Pineapple:Dragon Unicorn Centaur  - Phoenix Chimera Castle - ╰────────────────────────────────────────────────────────────────────────────╯ - - - - - - + + + + Docke + Widget 1Widget 2 + left  + 1Docked left 2 + + + + + + + + + + + + + + + + + + + @@ -18509,7 +19307,7 @@ ''' # --- -# name: test_layer_fix +# name: test_input_and_focus ''' @@ -18532,143 +19330,143 @@ font-weight: 700; } - .terminal-1675990519-matrix { + .terminal-596216952-matrix { font-family: Fira Code, monospace; font-size: 20px; line-height: 24.4px; font-variant-east-asian: full-width; } - .terminal-1675990519-title { + .terminal-596216952-title { font-size: 18px; font-weight: bold; font-family: arial; } - .terminal-1675990519-r1 { fill: #c5c8c6 } - .terminal-1675990519-r2 { fill: #e3e3e3 } - .terminal-1675990519-r3 { fill: #e1e1e1 } - .terminal-1675990519-r4 { fill: #ff0000 } - .terminal-1675990519-r5 { fill: #dde8f3;font-weight: bold } - .terminal-1675990519-r6 { fill: #ddedf9 } + .terminal-596216952-r1 { fill: #1e1e1e } + .terminal-596216952-r2 { fill: #121212 } + .terminal-596216952-r3 { fill: #c5c8c6 } + .terminal-596216952-r4 { fill: #e2e2e2 } + .terminal-596216952-r5 { fill: #0178d4 } + .terminal-596216952-r6 { fill: #e1e1e1 } - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - DialogIssueApp + InputApp - - - - DialogIssueApp - - - - - - ─────────────────────────────────────── - - - - - - This should not cause a scrollbar to ap - - - - - - ─────────────────────────────────────── - - - - -  D  Toggle the dialog  + + + + ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ + Darren + ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ + ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ + Burns + ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ + + + + + + + + + + + + + + + + + + ''' # --- -# name: test_layers +# name: test_input_suggestions ''' @@ -18691,133 +19489,137 @@ font-weight: 700; } - .terminal-3301495769-matrix { + .terminal-2073605363-matrix { font-family: Fira Code, monospace; font-size: 20px; line-height: 24.4px; font-variant-east-asian: full-width; } - .terminal-3301495769-title { + .terminal-2073605363-title { font-size: 18px; font-weight: bold; font-family: arial; } - .terminal-3301495769-r1 { fill: #e1e1e1 } - .terminal-3301495769-r2 { fill: #c5c8c6 } - .terminal-3301495769-r3 { fill: #ddefef } - .terminal-3301495769-r4 { fill: #211500 } + .terminal-2073605363-r1 { fill: #1e1e1e } + .terminal-2073605363-r2 { fill: #0178d4 } + .terminal-2073605363-r3 { fill: #c5c8c6 } + .terminal-2073605363-r4 { fill: #e2e2e2 } + .terminal-2073605363-r5 { fill: #1e1e1e;font-style: italic; } + .terminal-2073605363-r6 { fill: #ff0000;font-style: italic; } + .terminal-2073605363-r7 { fill: #121212 } + .terminal-2073605363-r8 { fill: #e1e1e1 } - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - LayersExample + FruitsApp - - - - - - - - - - - - - - - box1 (layer = above) - - - - - - box2 (layer = below) - - - - - + + + + ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ + strawberry + ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ + ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ + straw + ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ + ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ + p + ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ + ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ + b + ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ + ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ + a + ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ + + + + + + + + @@ -18825,7 +19627,7 @@ ''' # --- -# name: test_layout_containers +# name: test_input_validation ''' @@ -18848,151 +19650,146 @@ font-weight: 700; } - .terminal-2420307368-matrix { + .terminal-922438230-matrix { font-family: Fira Code, monospace; font-size: 20px; line-height: 24.4px; font-variant-east-asian: full-width; } - .terminal-2420307368-title { + .terminal-922438230-title { font-size: 18px; font-weight: bold; font-family: arial; } - .terminal-2420307368-r1 { fill: #7ae998 } - .terminal-2420307368-r2 { fill: #e76580 } - .terminal-2420307368-r3 { fill: #1e1e1e } - .terminal-2420307368-r4 { fill: #121212 } - .terminal-2420307368-r5 { fill: #c5c8c6 } - .terminal-2420307368-r6 { fill: #4ebf71;font-weight: bold } - .terminal-2420307368-r7 { fill: #f5e5e9;font-weight: bold } - .terminal-2420307368-r8 { fill: #e2e2e2 } - .terminal-2420307368-r9 { fill: #0a180e;font-weight: bold } - .terminal-2420307368-r10 { fill: #008139 } - .terminal-2420307368-r11 { fill: #780028 } - .terminal-2420307368-r12 { fill: #e1e1e1 } - .terminal-2420307368-r13 { fill: #23568b } - .terminal-2420307368-r14 { fill: #14191f } + .terminal-922438230-r1 { fill: #e1e1e1 } + .terminal-922438230-r2 { fill: #c5c8c6 } + .terminal-922438230-r3 { fill: #1e1e1e } + .terminal-922438230-r4 { fill: #7b3042 } + .terminal-922438230-r5 { fill: #e2e2e2 } + .terminal-922438230-r6 { fill: #3a7e4f } + .terminal-922438230-r7 { fill: #b93c5b } + .terminal-922438230-r8 { fill: #121212 } + .terminal-922438230-r9 { fill: #787878 } - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - MyApp + InputApp - - - - ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ -  Accept  Decline  Accept  Decline  - ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ - - - - - - - - - - ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ -  Accept  Accept  - ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ - ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ -  Decline  Decline  - ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▆▆ - ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ - - ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ - 00 - - 10000001000000 + + + + + ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ + -2 + ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ + + ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ + 3 + ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ + + ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ + -2 + ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ + + ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ + Enter a number between 1 and 5 + ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ + + + + + + + + ''' # --- -# name: test_line_api_scrollbars +# name: test_key_display ''' @@ -19015,140 +19812,141 @@ font-weight: 700; } - .terminal-3512435366-matrix { + .terminal-1765381587-matrix { font-family: Fira Code, monospace; font-size: 20px; line-height: 24.4px; font-variant-east-asian: full-width; } - .terminal-3512435366-title { + .terminal-1765381587-title { font-size: 18px; font-weight: bold; font-family: arial; } - .terminal-3512435366-r1 { fill: #e1e1e1 } - .terminal-3512435366-r2 { fill: #c5c8c6 } - .terminal-3512435366-r3 { fill: #23568b } + .terminal-1765381587-r1 { fill: #e1e1e1 } + .terminal-1765381587-r2 { fill: #c5c8c6 } + .terminal-1765381587-r3 { fill: #dde8f3;font-weight: bold } + .terminal-1765381587-r4 { fill: #ddedf9 } - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - ScrollViewApp + KeyDisplayApp - - - - - - 11 01234567 - 12 01234567 - 13 01234567 - 14 01234567 - 15 01234567▁▁ - 16 01234567 - 17 01234567 - 18 01234567 - 19 01234567 - - 11 01234567 - 12 01234567 - 13 01234567 - 14 01234567 - 15 01234567▁▁ - 16 01234567 - 17 01234567 - 18 01234567 - 19 01234567 - - - + + + + + + + + + + + + + + + + + + + + + + + + + + +  ?  Question  ^q  Quit app  Escape!  Escape  A  Letter A  ''' # --- -# name: test_list_view +# name: test_keyline ''' @@ -19171,141 +19969,143 @@ font-weight: 700; } - .terminal-3746094688-matrix { + .terminal-1393283672-matrix { font-family: Fira Code, monospace; font-size: 20px; line-height: 24.4px; font-variant-east-asian: full-width; } - .terminal-3746094688-title { + .terminal-1393283672-title { font-size: 18px; font-weight: bold; font-family: arial; } - .terminal-3746094688-r1 { fill: #e1e1e1 } - .terminal-3746094688-r2 { fill: #c5c8c6 } - .terminal-3746094688-r3 { fill: #e4e5e6 } - .terminal-3746094688-r4 { fill: #ddedf9 } + .terminal-1393283672-r1 { fill: #ff0000 } + .terminal-1393283672-r2 { fill: #c5c8c6 } + .terminal-1393283672-r3 { fill: #e1e1e1 } + .terminal-1393283672-r4 { fill: #008000 } + .terminal-1393283672-r5 { fill: #ff00ff } + .terminal-1393283672-r6 { fill: #1e1e1e } - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - ListViewExample + KeylineApp - - - - - - - - - - - - One - - - Two - - - Three - - - - - - - - - + + + + ────────────────────────────────────────────────────────────────────────────── + 1 + ────────────────────────────────────────────────────────────────────────────── + 2 + ────────────────────────────────────────────────────────────────────────────── + 3 + + ────────────────────────────────────────────────────────────────────────────── + ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + 456 + + + + + + ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + ═════════════════════════════════════════════════════════════════════════════ + 78 + + ═════════════════════════════════════════════════════════════════════════════ + 9 + + + ══════════════════════════════════════ ''' # --- -# name: test_log_write +# name: test_label_widths ''' @@ -19328,131 +20128,134 @@ font-weight: 700; } - .terminal-383823119-matrix { + .terminal-248448564-matrix { font-family: Fira Code, monospace; font-size: 20px; line-height: 24.4px; font-variant-east-asian: full-width; } - .terminal-383823119-title { + .terminal-248448564-title { font-size: 18px; font-weight: bold; font-family: arial; } - .terminal-383823119-r1 { fill: #e1e1e1 } - .terminal-383823119-r2 { fill: #c5c8c6 } + .terminal-248448564-r1 { fill: #1f1f1f } + .terminal-248448564-r2 { fill: #c5c8c6 } + .terminal-248448564-r3 { fill: #00ff00 } + .terminal-248448564-r4 { fill: #1b1b1b } + .terminal-248448564-r5 { fill: #121e12 } - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - LogApp + LabelWrap - - - - Hello, World! - What's up? - FOO - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + Apple Banana Cherry Mango Fig Guava Pineapple:Dragon Unicorn Centaur Phoenix Ch + + + Apple Banana Cherry Mango Fig Guava Pineapple:Dragon Unicorn Centaur Phoenix  + Chimera Castle + + + ╭────────────────────────────────────────────────────────────────────────────╮ + Apple Banana Cherry Mango Fig Guava Pineapple:Dragon Unicorn Centaur  + Phoenix Chimera Castle + ╰────────────────────────────────────────────────────────────────────────────╯ + + + + + + @@ -19460,7 +20263,7 @@ ''' # --- -# name: test_log_write_lines +# name: test_layer_fix ''' @@ -19483,141 +20286,143 @@ font-weight: 700; } - .terminal-1103060635-matrix { + .terminal-2540665408-matrix { font-family: Fira Code, monospace; font-size: 20px; line-height: 24.4px; font-variant-east-asian: full-width; } - .terminal-1103060635-title { + .terminal-2540665408-title { font-size: 18px; font-weight: bold; font-family: arial; } - .terminal-1103060635-r1 { fill: #e1e1e1 } - .terminal-1103060635-r2 { fill: #c5c8c6 } - .terminal-1103060635-r3 { fill: #14191f } - .terminal-1103060635-r4 { fill: #23568b } + .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 } - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - LogApp + DialogIssueApp - - - - I must not fear.And when it has goHello, WorldFear is the mind-k - Fear is the mind-kWhere the fear hasFear is the little - Fear is the littleI must not fear.I will face my fea - I will face my fea▁▁Fear is the mind-kI will permit it t - I will permit it tFear is the littleAnd when it has go - And when it has goI will face my feaWhere the fear has - Where the fear hasI will permit it t - I must not fear.And when it has go - Fear is the mind-kWhere the fear has - Fear is the littleI must not fear. - I will face my feaFear is the mind-k - I will permit it tFear is the little - And when it has goI will face my fea - Where the fear hasI will permit it t - I must not fear.And when it has go - Fear is the mind-kWhere the fear has - Fear is the littleI must not fear. - I will face my feaFear is the mind-k - I will permit it tFear is the little - And when it has goI will face my fea▇▇ - Where the fear hasI will permit it t - I must not fear.And when it has go - Fear is the mind-kWhere the fear has - + + + + DialogIssueApp + + + + + + ─────────────────────────────────────── + + + + + + This should not cause a scrollbar to ap + + + + + + ─────────────────────────────────────── + + + + +  D  Toggle the dialog  ''' # --- -# name: test_markdown_example +# name: test_layers ''' @@ -19640,140 +20445,133 @@ font-weight: 700; } - .terminal-1909492357-matrix { + .terminal-3301495769-matrix { font-family: Fira Code, monospace; font-size: 20px; line-height: 24.4px; font-variant-east-asian: full-width; } - .terminal-1909492357-title { + .terminal-3301495769-title { font-size: 18px; font-weight: bold; font-family: arial; } - .terminal-1909492357-r1 { fill: #e1e1e1 } - .terminal-1909492357-r2 { fill: #121212 } - .terminal-1909492357-r3 { fill: #c5c8c6 } - .terminal-1909492357-r4 { fill: #0053aa } - .terminal-1909492357-r5 { fill: #dde8f3;font-weight: bold } - .terminal-1909492357-r6 { fill: #939393;font-weight: bold } - .terminal-1909492357-r7 { fill: #24292f } - .terminal-1909492357-r8 { fill: #e2e3e3;font-weight: bold } - .terminal-1909492357-r9 { fill: #4ebf71;font-weight: bold } - .terminal-1909492357-r10 { fill: #e1e1e1;font-style: italic; } - .terminal-1909492357-r11 { fill: #e1e1e1;font-weight: bold } + .terminal-3301495769-r1 { fill: #e1e1e1 } + .terminal-3301495769-r2 { fill: #c5c8c6 } + .terminal-3301495769-r3 { fill: #ddefef } + .terminal-3301495769-r4 { fill: #211500 } - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - MarkdownExampleApp + LayersExample - - - - ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ - - Markdown Document - - ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ - This is an example of Textual's Markdown widget. - - ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ - -                               Features                               - - ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ - Markdown syntax and extensions are supported. - - ● Typography emphasisstronginline code etc. - ● Headers - ● Lists (bullet and ordered) - ● Syntax highlighted code blocks - ● Tables! - - - - + + + + + + + + + + + + + + + box1 (layer = above) + + + + + + box2 (layer = below) + + + + + @@ -19781,7 +20579,7 @@ ''' # --- -# name: test_markdown_viewer_example +# name: test_layout_containers ''' @@ -19804,152 +20602,151 @@ font-weight: 700; } - .terminal-1944007215-matrix { + .terminal-3525357221-matrix { font-family: Fira Code, monospace; font-size: 20px; line-height: 24.4px; font-variant-east-asian: full-width; } - .terminal-1944007215-title { + .terminal-3525357221-title { font-size: 18px; font-weight: bold; font-family: arial; } - .terminal-1944007215-r1 { fill: #c5c8c6 } - .terminal-1944007215-r2 { fill: #24292f } - .terminal-1944007215-r3 { fill: #121212 } - .terminal-1944007215-r4 { fill: #e1e1e1 } - .terminal-1944007215-r5 { fill: #e2e3e3 } - .terminal-1944007215-r6 { fill: #96989b } - .terminal-1944007215-r7 { fill: #0053aa } - .terminal-1944007215-r8 { fill: #008139 } - .terminal-1944007215-r9 { fill: #dde8f3;font-weight: bold } - .terminal-1944007215-r10 { fill: #939393;font-weight: bold } - .terminal-1944007215-r11 { fill: #14191f } - .terminal-1944007215-r12 { fill: #e2e3e3;font-weight: bold } - .terminal-1944007215-r13 { fill: #4ebf71;font-weight: bold } - .terminal-1944007215-r14 { fill: #e1e1e1;font-style: italic; } - .terminal-1944007215-r15 { fill: #e1e1e1;font-weight: bold } + .terminal-3525357221-r1 { fill: #7ae998 } + .terminal-3525357221-r2 { fill: #e76580 } + .terminal-3525357221-r3 { fill: #1e1e1e } + .terminal-3525357221-r4 { fill: #121212 } + .terminal-3525357221-r5 { fill: #c5c8c6 } + .terminal-3525357221-r6 { fill: #4ebf71;font-weight: bold } + .terminal-3525357221-r7 { fill: #f5e5e9;font-weight: bold } + .terminal-3525357221-r8 { fill: #e2e2e2 } + .terminal-3525357221-r9 { fill: #0a180e;font-weight: bold } + .terminal-3525357221-r10 { fill: #008139 } + .terminal-3525357221-r11 { fill: #780028 } + .terminal-3525357221-r12 { fill: #e1e1e1 } + .terminal-3525357221-r13 { fill: #23568b } + .terminal-3525357221-r14 { fill: #14191f } - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - MarkdownExampleApp + MyApp - - - - ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ - ▼  Markdown Viewer - ├──  FeaturesMarkdown Viewer - ├──  Tables - └──  Code Blocks▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ - This is an example of Textual's MarkdownViewer - widget. - - ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▅▅ - -                  Features                  - - ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ - Markdown syntax and extensions are supported. - - ● Typography emphasisstronginline code - etc. - ● Headers - ● Lists (bullet and ordered) - ● Syntax highlighted code blocks - ● Tables! - - ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ - + + + + ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ + AcceptDeclineAcceptDecline + ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ + + + + + + + + + + ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ + AcceptAccept + ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ + ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ + DeclineDecline + ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▆▆ + ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ + + ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ + 00 + + 10000001000000 ''' # --- -# name: test_modal_dialog_bindings +# name: test_line_api_scrollbars ''' @@ -19972,142 +20769,140 @@ font-weight: 700; } - .terminal-543315859-matrix { + .terminal-3512435366-matrix { font-family: Fira Code, monospace; font-size: 20px; line-height: 24.4px; font-variant-east-asian: full-width; } - .terminal-543315859-title { + .terminal-3512435366-title { font-size: 18px; font-weight: bold; font-family: arial; } - .terminal-543315859-r1 { fill: #c5c8c6 } - .terminal-543315859-r2 { fill: #e3e3e3 } - .terminal-543315859-r3 { fill: #e1e1e1 } - .terminal-543315859-r4 { fill: #dde8f3;font-weight: bold } - .terminal-543315859-r5 { fill: #ddedf9 } + .terminal-3512435366-r1 { fill: #e1e1e1 } + .terminal-3512435366-r2 { fill: #c5c8c6 } + .terminal-3512435366-r3 { fill: #23568b } - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - ModalApp + ScrollViewApp - - - - ModalApp - Hello - - - - - - - - - - - - - - - - - - - - - -  ⏎  Open Dialog  + + + + + + 11 01234567 + 12 01234567 + 13 01234567 + 14 01234567 + 15 01234567▁▁ + 16 01234567 + 17 01234567 + 18 01234567 + 19 01234567 + + 11 01234567 + 12 01234567 + 13 01234567 + 14 01234567 + 15 01234567▁▁ + 16 01234567 + 17 01234567 + 18 01234567 + 19 01234567 + + + ''' # --- -# name: test_modal_dialog_bindings_input +# name: test_list_view ''' @@ -20130,148 +20925,141 @@ font-weight: 700; } - .terminal-764470079-matrix { + .terminal-552007062-matrix { font-family: Fira Code, monospace; font-size: 20px; line-height: 24.4px; font-variant-east-asian: full-width; } - .terminal-764470079-title { + .terminal-552007062-title { font-size: 18px; font-weight: bold; font-family: arial; } - .terminal-764470079-r1 { fill: #e0e0e0 } - .terminal-764470079-r2 { fill: #656565 } - .terminal-764470079-r3 { fill: #c5c8c6 } - .terminal-764470079-r4 { fill: #121212 } - .terminal-764470079-r5 { fill: #e1e1e1 } - .terminal-764470079-r6 { fill: #454a50 } - .terminal-764470079-r7 { fill: #646464 } - .terminal-764470079-r8 { fill: #24292f;font-weight: bold } - .terminal-764470079-r9 { fill: #000000 } - .terminal-764470079-r10 { fill: #63676c;font-weight: bold } - .terminal-764470079-r11 { fill: #63696e } + .terminal-552007062-r1 { fill: #e1e1e1 } + .terminal-552007062-r2 { fill: #c5c8c6 } + .terminal-552007062-r3 { fill: #e4e5e6 } + .terminal-552007062-r4 { fill: #ddedf9 } - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - ModalApp + ListViewExample - - - - DialogModalApp - ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ - hi! - ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ - ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ -  OK  - ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ - - - - - - - - - - - - - - - - -  ⏎  Open Dialog  + + + + + + + + + + + + One + + + Two + + + Three + + + + + + + + + ''' # --- -# name: test_multiple_css +# name: test_loading_indicator ''' @@ -20294,141 +21082,143 @@ font-weight: 700; } - .terminal-1292433193-matrix { + .terminal-1550026580-matrix { font-family: Fira Code, monospace; font-size: 20px; line-height: 24.4px; font-variant-east-asian: full-width; } - .terminal-1292433193-title { + .terminal-1550026580-title { font-size: 18px; font-weight: bold; font-family: arial; } - .terminal-1292433193-r1 { fill: #8b0000 } - .terminal-1292433193-r2 { fill: #c5c8c6 } - .terminal-1292433193-r3 { fill: #ff0000 } - .terminal-1292433193-r4 { fill: #e1e1e1 } + .terminal-1550026580-r1 { fill: #1e1e1e } + .terminal-1550026580-r2 { fill: #0178d4 } + .terminal-1550026580-r3 { fill: #c5c8c6 } + .terminal-1550026580-r4 { fill: #e2e2e2;font-weight: bold } + .terminal-1550026580-r5 { fill: #e2e2e2 } + .terminal-1550026580-r6 { fill: #14191f } - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - MultipleCSSApp + LoadingOverlayRedux - - - - #one - #two - - - - - - - - - - - - - - - - - - - - - - + + + + ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ + foo barfoo barfoo barfoo barfoo  + bar + foo barfoo barfoo barfoo barfoo ▄▄ + bar + foo barfoo barfoo barfoo barfoo  + bar + foo barfoo barfoo barfoo barfoo  + bar + foo barfoo barfoo barfoo barfoo  + bar + Loading!foo barfoo barfoo barfoo barfoo  + bar + foo barfoo barfoo barfoo barfoo  + bar + foo barfoo barfoo barfoo barfoo  + bar + foo barfoo barfoo barfoo barfoo  + bar + foo barfoo barfoo barfoo barfoo  + bar + foo barfoo barfoo barfoo barfoo  + bar + ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ ''' # --- -# name: test_nested_auto_heights +# name: test_loading_indicator_disables_widget ''' @@ -20451,143 +21241,144 @@ font-weight: 700; } - .terminal-3700945997-matrix { + .terminal-3594715516-matrix { font-family: Fira Code, monospace; font-size: 20px; line-height: 24.4px; font-variant-east-asian: full-width; } - .terminal-3700945997-title { + .terminal-3594715516-title { font-size: 18px; font-weight: bold; font-family: arial; } - .terminal-3700945997-r1 { fill: #90ee90 } - .terminal-3700945997-r2 { fill: #c5c8c6 } - .terminal-3700945997-r3 { fill: #add8e6 } - .terminal-3700945997-r4 { fill: #808080 } - .terminal-3700945997-r5 { fill: #dddddd } - .terminal-3700945997-r6 { fill: #ffdddd } + .terminal-3594715516-r1 { fill: #1e1e1e } + .terminal-3594715516-r2 { fill: #0178d4 } + .terminal-3594715516-r3 { fill: #c5c8c6 } + .terminal-3594715516-r4 { fill: #ddedf9;font-weight: bold } + .terminal-3594715516-r5 { fill: #e2e2e2 } + .terminal-3594715516-r6 { fill: #e2e2e2;font-weight: bold } + .terminal-3594715516-r7 { fill: #14191f } - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - NestedAutoApp + LoadingOverlayRedux - - - - ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ - ━━━━━━━━━━━━━━━ - ━━━━━━━━━━━━━ - JUST ONE LINE - ━━━━━━━━━━━━━ - ━━━━━━━━━━━━━━━ - ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ - - - - - - - - - - - - - - - - - + + + + ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ + hello world hello world hello foo barfoo barfoo barfoo barfoo  + world hello world hello world bar + hello world hello world hello ▄▄foo barfoo barfoo barfoo barfoo ▄▄ + world hello world hello world bar + hello world hello world hello foo barfoo barfoo barfoo barfoo  + world hello world hello world bar + hello world hello world hello foo barfoo barfoo barfoo barfoo  + world hello world hello world bar + hello world hello world hello foo barfoo barfoo barfoo barfoo  + world hello world hello world bar + hello world hello world hello foo barfoo barfoo barfoo barfoo  + world hello world hello world bar + hello world hello world hello foo barfoo barfoo barfoo barfoo  + world hello world hello world bar + hello world hello world hello foo barfoo barfoo barfoo barfoo  + world hello world hello world bar + hello world hello world hello foo barfoo barfoo barfoo barfoo  + world hello world hello world bar + hello world hello world hello foo barfoo barfoo barfoo barfoo  + world hello world hello world bar + hello world hello world hello foo barfoo barfoo barfoo barfoo  + world hello world hello world bar + ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ ''' # --- -# name: test_nested_fr +# name: test_log_write ''' @@ -20610,141 +21401,139 @@ font-weight: 700; } - .terminal-4113050056-matrix { + .terminal-383823119-matrix { font-family: Fira Code, monospace; font-size: 20px; line-height: 24.4px; font-variant-east-asian: full-width; } - .terminal-4113050056-title { + .terminal-383823119-title { font-size: 18px; font-weight: bold; font-family: arial; } - .terminal-4113050056-r1 { fill: #ffffff } - .terminal-4113050056-r2 { fill: #c5c8c6 } - .terminal-4113050056-r3 { fill: #ffff00 } - .terminal-4113050056-r4 { fill: #002121 } + .terminal-383823119-r1 { fill: #e1e1e1 } + .terminal-383823119-r2 { fill: #c5c8c6 } - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - AutoApp + LogApp - - - - ────────────────────────────────────────────────────────────────────────────── - ──────────────────────────────────────────────────────────────────────────── - Hello - World! - foo - - - - - - - - - - - - - - - - - - ──────────────────────────────────────────────────────────────────────────── - ────────────────────────────────────────────────────────────────────────────── + + + + Hello, World! + What's up? + FOO + + + + + + + + + + + + + + + + + + + + + ''' # --- -# name: test_notifications_example +# name: test_log_write_lines ''' @@ -20767,147 +21556,141 @@ font-weight: 700; } - .terminal-3970684023-matrix { + .terminal-1103060635-matrix { font-family: Fira Code, monospace; font-size: 20px; line-height: 24.4px; font-variant-east-asian: full-width; } - .terminal-3970684023-title { + .terminal-1103060635-title { font-size: 18px; font-weight: bold; font-family: arial; } - .terminal-3970684023-r1 { fill: #e1e1e1 } - .terminal-3970684023-r2 { fill: #c5c8c6 } - .terminal-3970684023-r3 { fill: #56c278 } - .terminal-3970684023-r4 { fill: #2e3339 } - .terminal-3970684023-r5 { fill: #e3e4e4 } - .terminal-3970684023-r6 { fill: #feaa35 } - .terminal-3970684023-r7 { fill: #e89719;font-weight: bold } - .terminal-3970684023-r8 { fill: #e3e4e4;font-weight: bold } - .terminal-3970684023-r9 { fill: #e3e4e4;font-weight: bold;font-style: italic; } - .terminal-3970684023-r10 { fill: #bc4563 } + .terminal-1103060635-r1 { fill: #e1e1e1 } + .terminal-1103060635-r2 { fill: #c5c8c6 } + .terminal-1103060635-r3 { fill: #14191f } + .terminal-1103060635-r4 { fill: #23568b } - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - ToastApp + LogApp - - - - - - - - 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. - - + + + + I must not fear.And when it has goHello, WorldFear is the mind-k + Fear is the mind-kWhere the fear hasFear is the little + Fear is the littleI must not fear.I will face my fea + I will face my fea▁▁Fear is the mind-kI will permit it t + I will permit it tFear is the littleAnd when it has go + And when it has goI will face my feaWhere the fear has + Where the fear hasI will permit it t + I must not fear.And when it has go + Fear is the mind-kWhere the fear has + Fear is the littleI must not fear. + I will face my feaFear is the mind-k + I will permit it tFear is the little + And when it has goI will face my fea + Where the fear hasI will permit it t + I must not fear.And when it has go + Fear is the mind-kWhere the fear has + Fear is the littleI must not fear. + I will face my feaFear is the mind-k + I will permit it tFear is the little + And when it has goI will face my fea▇▇ + Where the fear hasI will permit it t + I must not fear.And when it has go + Fear is the mind-kWhere the fear has + ''' # --- -# name: test_notifications_through_modes +# name: test_markdown_example ''' @@ -20930,134 +21713,140 @@ font-weight: 700; } - .terminal-2782348326-matrix { + .terminal-454878977-matrix { font-family: Fira Code, monospace; font-size: 20px; line-height: 24.4px; font-variant-east-asian: full-width; } - .terminal-2782348326-title { + .terminal-454878977-title { font-size: 18px; font-weight: bold; font-family: arial; } - .terminal-2782348326-r1 { fill: #e1e1e1 } - .terminal-2782348326-r2 { fill: #56c278 } - .terminal-2782348326-r3 { fill: #c5c8c6 } - .terminal-2782348326-r4 { fill: #2e3339 } - .terminal-2782348326-r5 { fill: #e3e4e4 } + .terminal-454878977-r1 { fill: #e1e1e1 } + .terminal-454878977-r2 { fill: #121212 } + .terminal-454878977-r3 { fill: #c5c8c6 } + .terminal-454878977-r4 { fill: #0053aa } + .terminal-454878977-r5 { fill: #dde8f3;font-weight: bold } + .terminal-454878977-r6 { fill: #939393;font-weight: bold } + .terminal-454878977-r7 { fill: #24292f } + .terminal-454878977-r8 { fill: #e2e3e3;font-weight: bold } + .terminal-454878977-r9 { fill: #4ebf71;font-weight: bold } + .terminal-454878977-r10 { fill: #e1e1e1;font-style: italic; } + .terminal-454878977-r11 { fill: #e1e1e1;font-weight: bold } - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - NotifyThroughModesApp + MarkdownExampleApp - - - - This is a mode screen - 4 - - - - 5 - - - - 6 - - - - 7 - - - - 8 - - - - 9 - + + + + ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ + + Markdown Document + + ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ + This is an example of Textual's Markdown widget. + + ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ + +                               Features                               + + ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ + Markdown syntax and extensions are supported. + + ● Typography emphasisstronginline code etc. + ● Headers + ● Lists (bullet and ordered) + ● Syntax highlighted code blocks + ● Tables! + + + + @@ -21065,7 +21854,7 @@ ''' # --- -# name: test_notifications_through_screens +# name: test_markdown_viewer_example ''' @@ -21088,142 +21877,152 @@ font-weight: 700; } - .terminal-180633759-matrix { + .terminal-1185109701-matrix { font-family: Fira Code, monospace; font-size: 20px; line-height: 24.4px; font-variant-east-asian: full-width; } - .terminal-180633759-title { + .terminal-1185109701-title { font-size: 18px; font-weight: bold; font-family: arial; } - .terminal-180633759-r1 { fill: #e1e1e1 } - .terminal-180633759-r2 { fill: #56c278 } - .terminal-180633759-r3 { fill: #c5c8c6 } - .terminal-180633759-r4 { fill: #2e3339 } - .terminal-180633759-r5 { fill: #e3e4e4 } + .terminal-1185109701-r1 { fill: #c5c8c6 } + .terminal-1185109701-r2 { fill: #24292f } + .terminal-1185109701-r3 { fill: #e1e1e1 } + .terminal-1185109701-r4 { fill: #121212 } + .terminal-1185109701-r5 { fill: #e2e3e3 } + .terminal-1185109701-r6 { fill: #96989b } + .terminal-1185109701-r7 { fill: #0053aa } + .terminal-1185109701-r8 { fill: #008139 } + .terminal-1185109701-r9 { fill: #dde8f3;font-weight: bold } + .terminal-1185109701-r10 { fill: #939393;font-weight: bold } + .terminal-1185109701-r11 { fill: #14191f } + .terminal-1185109701-r12 { fill: #e2e3e3;font-weight: bold } + .terminal-1185109701-r13 { fill: #4ebf71;font-weight: bold } + .terminal-1185109701-r14 { fill: #e1e1e1;font-style: italic; } + .terminal-1185109701-r15 { fill: #e1e1e1;font-weight: bold } - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - NotifyDownScreensApp + MarkdownExampleApp - - - - Screen 10 - 4 - - - - 5 - - - - 6 - - - - 7 - - - - 8 - - - - 9 - - + + + + ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ + ▼ Ⅰ Markdown Viewer + ├── Ⅱ FeaturesMarkdown Viewer + ├── Ⅱ Tables + └── Ⅱ Code Blocks▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ + This is an example of Textual's MarkdownViewer + widget. + + ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▅▅ + +                  Features                  + + ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ + Markdown syntax and extensions are supported. + + ● Typography emphasisstronginline code + etc. + ● Headers + ● Lists (bullet and ordered) + ● Syntax highlighted code blocks + ● Tables! + + ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ + ''' # --- -# name: test_offsets +# name: test_max_height_100 ''' @@ -21246,133 +22045,134 @@ font-weight: 700; } - .terminal-4150241775-matrix { + .terminal-3027843796-matrix { font-family: Fira Code, monospace; font-size: 20px; line-height: 24.4px; font-variant-east-asian: full-width; } - .terminal-4150241775-title { + .terminal-3027843796-title { font-size: 18px; font-weight: bold; font-family: arial; } - .terminal-4150241775-r1 { fill: #e1e1e1 } - .terminal-4150241775-r2 { fill: #c5c8c6 } - .terminal-4150241775-r3 { fill: #ffffff } - .terminal-4150241775-r4 { fill: #ddddef } + .terminal-3027843796-r1 { fill: #dde6ed;font-weight: bold } + .terminal-3027843796-r2 { fill: #e1e1e1 } + .terminal-3027843796-r3 { fill: #c5c8c6 } + .terminal-3027843796-r4 { fill: #211505 } + .terminal-3027843796-r5 { fill: #14191f } - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - OffsetsApp + HappyDataTableFunApp - - - - - - - - - ────────────── - FOO - BAR - BAZ - ────────────── - - - - - - ────────────── - FOO - BAR - BAZ - ────────────── - - - + + + +  Column 0  Column 1  Column 2  Column 3  Column 4  Column 5  Column 6  Column  +  0         0         0         0         0         0         0         0       +  0         1         2         3         4         5         6         7       +  0         2         4         6         8         10        12        14      +  0         3         6         9         12        15        18        21      +  0         4         8         12        16        20        24        28     ▆▆ +  0         5         10        15        20        25        30        35      +  0         6         12        18        24        30        36        42      +  0         7         14        21        28        35        42        49      +  0         8         16        24        32        40        48        56      +  0         9         18        27        36        45        54        63      +  0         10        20        30        40        50        60        70      +  0         11        22        33        44        55        66        77      +  0         12        24        36        48        60        72        84      +  0         13        26        39        52        65        78        91      +  0         14        28        42        56        70        84        98      +  0         15        30        45        60        75        90        105     +  0         16        32        48        64        80        96        112     +  0         17        34        51        68        85        102       119     +  0         18        36        54        72        90        108       126     +  0         19        38        57        76        95        114       133     +  0         20        40        60        80        100       120       140     +  0         21        42        63        84        105       126       147     @@ -21380,7 +22180,7 @@ ''' # --- -# name: test_option_list_build +# name: test_missing_vertical_scroll ''' @@ -21403,145 +22203,144 @@ font-weight: 700; } - .terminal-72094857-matrix { + .terminal-3017029652-matrix { font-family: Fira Code, monospace; font-size: 20px; line-height: 24.4px; font-variant-east-asian: full-width; } - .terminal-72094857-title { + .terminal-3017029652-title { font-size: 18px; font-weight: bold; font-family: arial; } - .terminal-72094857-r1 { fill: #1e1e1e } - .terminal-72094857-r2 { fill: #0178d4 } - .terminal-72094857-r3 { fill: #c5c8c6 } - .terminal-72094857-r4 { fill: #ddedf9;font-weight: bold } - .terminal-72094857-r5 { fill: #e2e2e2;font-weight: bold } - .terminal-72094857-r6 { fill: #e2e2e2 } - .terminal-72094857-r7 { fill: #434343 } - .terminal-72094857-r8 { fill: #cc555a } + .terminal-3017029652-r1 { fill: #1e1e1e } + .terminal-3017029652-r2 { fill: #0178d4 } + .terminal-3017029652-r3 { fill: #c5c8c6 } + .terminal-3017029652-r4 { fill: #ddedf9;font-weight: bold } + .terminal-3017029652-r5 { fill: #e2e2e2 } + .terminal-3017029652-r6 { fill: #e2e2e2;font-weight: bold } + .terminal-3017029652-r7 { fill: #14191f } - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - OptionListApp + MissingScrollbarApp - - - - ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ - OneOneOne - TwoTwoTwo - ──────────────────────────────────────────────────────────────────── - ThreeThreeThree - ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ - - - - - - - - - - - - - - - - - - + + + + ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ + 000 + 111 + 2▄▄2▄▄2▄▄ + 333 + 444 + 555 + 666 + 777 + 888 + 999 + 101010 + 111111 + 121212 + 131313 + 141414 + 151515 + 161616 + 171717 + 181818 + 191919 + 202020 + 212121 + ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ ''' # --- -# name: test_option_list_options +# name: test_modal_dialog_bindings ''' @@ -21564,148 +22363,142 @@ font-weight: 700; } - .terminal-371403050-matrix { + .terminal-543315859-matrix { font-family: Fira Code, monospace; font-size: 20px; line-height: 24.4px; font-variant-east-asian: full-width; } - .terminal-371403050-title { + .terminal-543315859-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-543315859-r1 { fill: #c5c8c6 } + .terminal-543315859-r2 { fill: #e3e3e3 } + .terminal-543315859-r3 { fill: #e1e1e1 } + .terminal-543315859-r4 { fill: #dde8f3;font-weight: bold } + .terminal-543315859-r5 { fill: #ddedf9 } - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - OptionListApp + ModalApp - - - - OptionListApp - - - ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ - Aerilon - Aquaria - ─────────────────────────────────────────────────── - Canceron - Caprica - ─────────────────────────────────────────────────── - Gemenon - ─────────────────────────────────────────────────── - Leonis - Libran - ─────────────────────────────────────────────────── - Picon - ─────────────────────────────────────────────────── - Sagittaron▄▄ - Scorpia - ─────────────────────────────────────────────────── - ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ - - - + + + + ModalApp + Hello + + + + + + + + + + + + + + + + + + + + + +  ⏎  Open Dialog  ''' # --- -# name: test_option_list_replace_prompt_from_single_line_to_single_line +# name: test_modal_dialog_bindings_input ''' @@ -21728,145 +22521,148 @@ font-weight: 700; } - .terminal-1891202557-matrix { + .terminal-2766044148-matrix { font-family: Fira Code, monospace; font-size: 20px; line-height: 24.4px; font-variant-east-asian: full-width; } - .terminal-1891202557-title { + .terminal-2766044148-title { font-size: 18px; font-weight: bold; font-family: arial; } - .terminal-1891202557-r1 { fill: #c5c8c6 } - .terminal-1891202557-r2 { fill: #e3e3e3 } - .terminal-1891202557-r3 { fill: #1e1e1e } - .terminal-1891202557-r4 { fill: #0178d4 } - .terminal-1891202557-r5 { fill: #ddedf9;font-weight: bold } - .terminal-1891202557-r6 { fill: #e2e2e2 } - .terminal-1891202557-r7 { fill: #e1e1e1 } - .terminal-1891202557-r8 { fill: #ddedf9 } + .terminal-2766044148-r1 { fill: #e0e0e0 } + .terminal-2766044148-r2 { fill: #656565 } + .terminal-2766044148-r3 { fill: #c5c8c6 } + .terminal-2766044148-r4 { fill: #121212 } + .terminal-2766044148-r5 { fill: #e1e1e1 } + .terminal-2766044148-r6 { fill: #454a50 } + .terminal-2766044148-r7 { fill: #646464 } + .terminal-2766044148-r8 { fill: #24292f;font-weight: bold } + .terminal-2766044148-r9 { fill: #000000 } + .terminal-2766044148-r10 { fill: #63676c;font-weight: bold } + .terminal-2766044148-r11 { fill: #63696e } - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - OptionListApp + ModalApp - - - - OptionListApp - ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ - 1. Another single line - 2. Two - lines - 3. Three - lines - of text - ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ - - - - - - - - - - - - - - - + + + + DialogModalApp + ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ + hi! + ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ + ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ + OK + ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ + + + + + + + + + + + + + + + + +  ⏎  Open Dialog  ''' # --- -# name: test_option_list_replace_prompt_from_single_line_to_two_lines +# name: test_mount_style_fix ''' @@ -21889,137 +22685,133 @@ font-weight: 700; } - .terminal-2188746417-matrix { + .terminal-1197668808-matrix { font-family: Fira Code, monospace; font-size: 20px; line-height: 24.4px; font-variant-east-asian: full-width; } - .terminal-2188746417-title { + .terminal-1197668808-title { font-size: 18px; font-weight: bold; font-family: arial; } - .terminal-2188746417-r1 { fill: #c5c8c6 } - .terminal-2188746417-r2 { fill: #e3e3e3 } - .terminal-2188746417-r3 { fill: #1e1e1e } - .terminal-2188746417-r4 { fill: #0178d4 } - .terminal-2188746417-r5 { fill: #ddedf9;font-weight: bold } - .terminal-2188746417-r6 { fill: #e2e2e2 } - .terminal-2188746417-r7 { fill: #e1e1e1 } - .terminal-2188746417-r8 { fill: #ddedf9 } + .terminal-1197668808-r1 { fill: #e1e1e1 } + .terminal-1197668808-r2 { fill: #c5c8c6 } + .terminal-1197668808-r3 { fill: #00ff00 } + .terminal-1197668808-r4 { fill: #ffdddd } - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - OptionListApp + BrokenClassesApp - - - - OptionListApp - ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ - 1. Two - lines - 2. Two - lines - 3. Three - lines - of text - ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ - - - - - - - - - - - - - + + + + + + + + + ─────────────────────────────────────── + This should have a red background + + + + + + + + + + + ─────────────────────────────────────── + + + + + @@ -22027,7 +22819,7 @@ ''' # --- -# name: test_option_list_replace_prompt_from_two_lines_to_three_lines +# name: test_multiple_css ''' @@ -22050,145 +22842,141 @@ font-weight: 700; } - .terminal-2667681921-matrix { + .terminal-1292433193-matrix { font-family: Fira Code, monospace; font-size: 20px; line-height: 24.4px; font-variant-east-asian: full-width; } - .terminal-2667681921-title { + .terminal-1292433193-title { font-size: 18px; font-weight: bold; font-family: arial; } - .terminal-2667681921-r1 { fill: #c5c8c6 } - .terminal-2667681921-r2 { fill: #e3e3e3 } - .terminal-2667681921-r3 { fill: #1e1e1e } - .terminal-2667681921-r4 { fill: #0178d4 } - .terminal-2667681921-r5 { fill: #ddedf9;font-weight: bold } - .terminal-2667681921-r6 { fill: #e2e2e2 } - .terminal-2667681921-r7 { fill: #e1e1e1 } - .terminal-2667681921-r8 { fill: #ddedf9 } + .terminal-1292433193-r1 { fill: #8b0000 } + .terminal-1292433193-r2 { fill: #c5c8c6 } + .terminal-1292433193-r3 { fill: #ff0000 } + .terminal-1292433193-r4 { fill: #e1e1e1 } - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - OptionListApp + MultipleCSSApp - - - - OptionListApp - ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ - 1. Single line - 1. Three - lines - of text - 3. Three - lines - of text - ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ - - - - - - - - - - - - - - - - - - - ''' + + + + #one + #two + + + + + + + + + + + + + + + + + + + + + + + + + + + ''' # --- -# name: test_option_list_strings +# name: test_nested_auto_heights ''' @@ -22211,137 +22999,136 @@ font-weight: 700; } - .terminal-2341816165-matrix { + .terminal-3211563364-matrix { font-family: Fira Code, monospace; font-size: 20px; line-height: 24.4px; font-variant-east-asian: full-width; } - .terminal-2341816165-title { + .terminal-3211563364-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-3211563364-r1 { fill: #90ee90 } + .terminal-3211563364-r2 { fill: #c5c8c6 } + .terminal-3211563364-r3 { fill: #add8e6 } + .terminal-3211563364-r4 { fill: #ddeedd } + .terminal-3211563364-r5 { fill: #808080 } + .terminal-3211563364-r6 { fill: #dddddd } + .terminal-3211563364-r7 { fill: #ffdddd } - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - OptionListApp + NestedAutoApp - - - - OptionListApp - - - ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ - Aerilon - Aquaria - Canceron - Caprica - Gemenon - Leonis - Libran - Picon - Sagittaron - Scorpia - Tauron - Virgon - - - - - ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ - - + + + + ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + ━━━━━━━━━━━━━━━ + ━━━━━━━━━━━━━ + JUST ONE LINE + ━━━━━━━━━━━━━ + ━━━━━━━━━━━━━━━ + ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + + + + + + + + + + + + + + + @@ -22349,7 +23136,7 @@ ''' # --- -# name: test_option_list_tables +# name: test_nested_fr ''' @@ -22372,149 +23159,141 @@ font-weight: 700; } - .terminal-228828675-matrix { + .terminal-3973651935-matrix { font-family: Fira Code, monospace; font-size: 20px; line-height: 24.4px; font-variant-east-asian: full-width; } - .terminal-228828675-title { + .terminal-3973651935-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-3973651935-r1 { fill: #ffffff } + .terminal-3973651935-r2 { fill: #c5c8c6 } + .terminal-3973651935-r3 { fill: #ffff00 } + .terminal-3973651935-r4 { fill: #002121 } - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - OptionListApp + AutoApp - - - - 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   - ┡━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━┩ - ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ - - - + + + + ────────────────────────────────────────────────────────────────────────────── + ──────────────────────────────────────────────────────────────────────────── + Hello + World! + foo + + + + + + + + + + + + + + + + + + ──────────────────────────────────────────────────────────────────────────── + ────────────────────────────────────────────────────────────────────────────── ''' # --- -# name: test_order_independence +# name: test_notification_with_inline_link ''' @@ -22537,143 +23316,143 @@ font-weight: 700; } - .terminal-1392305496-matrix { + .terminal-1795141768-matrix { font-family: Fira Code, monospace; font-size: 20px; line-height: 24.4px; font-variant-east-asian: full-width; } - .terminal-1392305496-title { + .terminal-1795141768-title { font-size: 18px; font-weight: bold; font-family: arial; } - .terminal-1392305496-r1 { fill: #ffff00 } - .terminal-1392305496-r2 { fill: #e3e3e3 } - .terminal-1392305496-r3 { fill: #c5c8c6 } - .terminal-1392305496-r4 { fill: #e1e1e1 } - .terminal-1392305496-r5 { fill: #dde8f3;font-weight: bold } - .terminal-1392305496-r6 { fill: #ddedf9 } + .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; } - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - Layers + NotifyWithInlineLinkApp - - - - ──────────────────────────────────Layers - It's full of stars! My God! It's full of sta - - This should float over the top - - - ────────────────────────────────── - - - - - - - - - - - - - - - - -  T  Toggle Screen  + + + + + + + + + + + + + + + + + + + + + + + + + Click here for the bell sound. + + ''' # --- -# name: test_order_independence_toggle +# name: test_notification_with_inline_link_hover ''' @@ -22696,143 +23475,143 @@ font-weight: 700; } - .terminal-3727479996-matrix { + .terminal-3756307740-matrix { font-family: Fira Code, monospace; font-size: 20px; line-height: 24.4px; font-variant-east-asian: full-width; } - .terminal-3727479996-title { + .terminal-3756307740-title { font-size: 18px; font-weight: bold; font-family: arial; } - .terminal-3727479996-r1 { fill: #ffff00 } - .terminal-3727479996-r2 { fill: #e3e3e3 } - .terminal-3727479996-r3 { fill: #c5c8c6 } - .terminal-3727479996-r4 { fill: #ddeedd } - .terminal-3727479996-r5 { fill: #dde8f3;font-weight: bold } - .terminal-3727479996-r6 { fill: #ddedf9 } + .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 } - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - Layers + NotifyWithInlineLinkApp - - - - ──────────────────────────────────Layers - It's full of stars! My God! It's full of sta - - This should float over the top - - - ────────────────────────────────── - - - - - - - - - - - - - - - - -  T  Toggle Screen  + + + + + + + + + + + + + + + + + + + + + + + + + Click here for the bell sound. + + ''' # --- -# name: test_placeholder_render +# name: test_notifications_example ''' @@ -22855,142 +23634,139 @@ font-weight: 700; } - .terminal-1570661136-matrix { + .terminal-99275963-matrix { font-family: Fira Code, monospace; font-size: 20px; line-height: 24.4px; font-variant-east-asian: full-width; } - .terminal-1570661136-title { + .terminal-99275963-title { font-size: 18px; font-weight: bold; font-family: arial; } - .terminal-1570661136-r1 { fill: #c5c8c6 } - .terminal-1570661136-r2 { fill: #eae3e5 } - .terminal-1570661136-r3 { fill: #e8e0e7 } - .terminal-1570661136-r4 { fill: #efe9e4 } - .terminal-1570661136-r5 { fill: #ede6e6 } - .terminal-1570661136-r6 { fill: #efeedf } - .terminal-1570661136-r7 { fill: #e9eee5 } - .terminal-1570661136-r8 { fill: #e2edeb } - .terminal-1570661136-r9 { fill: #e4eee8;font-weight: bold } - .terminal-1570661136-r10 { fill: #dfebed;font-weight: bold } - .terminal-1570661136-r11 { fill: #dfe9ed } - .terminal-1570661136-r12 { fill: #e3e6eb;font-weight: bold } - .terminal-1570661136-r13 { fill: #e6e3e9 } + .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 } - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - PlaceholderApp + ToastApp - - - - - Placeholder p2 here! - This is a custom label for p1. - #p4 - #p3#p5Placeholde - r - - Lorem ipsum dolor sit  - 26 x 6amet, consectetur 27 x 6 - adipiscing elit. Etiam  - feugiat ac elit sit amet  - - - Lorem ipsum dolor sit amet,  - consectetur adipiscing elit. Etiam 40 x 6 - feugiat ac elit sit amet accumsan.  - Suspendisse bibendum nec libero quis  - gravida. Phasellus id eleifend ligula. - Nullam imperdiet sem tellus, sed  - vehicula nisl faucibus sit amet. Lorem ipsum dolor sit amet,  - Praesent iaculis tempor ultricies. Sedconsectetur adipiscing elit. Etiam  - lacinia, tellus id rutrum lacinia, feugiat ac elit sit amet accumsan.  - sapien sapien congue mauris, sit amet Suspendisse bibendum nec libero quis  + + + + + + + + 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. + @@ -22998,9 +23774,9 @@ ''' # --- -# name: test_print_capture +# name: test_notifications_loading_overlap_order ''' - + - - + + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - - - - - - - - - - - - - CaptureApp + LoadingOverlayApp - - - - RichLog - This will be captured! - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + 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. + + @@ -23153,7 +23915,7 @@ ''' # --- -# name: test_programmatic_scrollbar_gutter_change +# name: test_notifications_through_modes ''' @@ -23176,131 +23938,134 @@ font-weight: 700; } - .terminal-2761669360-matrix { + .terminal-2569815150-matrix { font-family: Fira Code, monospace; font-size: 20px; line-height: 24.4px; font-variant-east-asian: full-width; } - .terminal-2761669360-title { + .terminal-2569815150-title { font-size: 18px; font-weight: bold; font-family: arial; } - .terminal-2761669360-r1 { fill: #ffdddd } - .terminal-2761669360-r2 { fill: #c5c8c6 } + .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 } - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - ProgrammaticScrollbarGutterChange + NotifyThroughModesApp - - - - onetwo - - - - - - - - - - - - threefour - - - - - - - - - - + + + + This is a mode screen + 4 + + + + 5 + + + + 6 + + + + 7 + + + + 8 + + + + 9 + @@ -23308,7 +24073,7 @@ ''' # --- -# name: test_progress_bar_completed +# name: test_notifications_through_screens ''' @@ -23331,142 +24096,142 @@ font-weight: 700; } - .terminal-230009450-matrix { + .terminal-4257366247-matrix { font-family: Fira Code, monospace; font-size: 20px; line-height: 24.4px; font-variant-east-asian: full-width; } - .terminal-230009450-title { + .terminal-4257366247-title { font-size: 18px; font-weight: bold; font-family: arial; } - .terminal-230009450-r1 { fill: #c5c8c6 } - .terminal-230009450-r2 { fill: #e1e1e1 } - .terminal-230009450-r3 { fill: #4ebf71 } - .terminal-230009450-r4 { fill: #dde8f3;font-weight: bold } - .terminal-230009450-r5 { fill: #ddedf9 } + .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 } - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - IndeterminateProgressBar + NotifyDownScreensApp - - - - - - - - - - - - - - - ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━100%--:--:-- - - - - - - - - - - - -  S  Start  + + + + Screen 10 + 4 + + + + 5 + + + + 6 + + + + 7 + + + + 8 + + + + 9 + + ''' # --- -# name: test_progress_bar_completed_styled +# name: test_offsets ''' @@ -23489,144 +24254,141 @@ font-weight: 700; } - .terminal-3162092160-matrix { + .terminal-4150241775-matrix { font-family: Fira Code, monospace; font-size: 20px; line-height: 24.4px; font-variant-east-asian: full-width; } - .terminal-3162092160-title { + .terminal-4150241775-title { font-size: 18px; font-weight: bold; font-family: arial; } - .terminal-3162092160-r1 { fill: #c5c8c6 } - .terminal-3162092160-r2 { fill: #e1e1e1 } - .terminal-3162092160-r3 { fill: #b93c5b } - .terminal-3162092160-r4 { fill: #1e1e1e } - .terminal-3162092160-r5 { fill: #e1e1e1;text-decoration: underline; } - .terminal-3162092160-r6 { fill: #dde8f3;font-weight: bold } - .terminal-3162092160-r7 { fill: #ddedf9 } + .terminal-4150241775-r1 { fill: #e1e1e1 } + .terminal-4150241775-r2 { fill: #c5c8c6 } + .terminal-4150241775-r3 { fill: #ffffff } + .terminal-4150241775-r4 { fill: #ddddef } - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - StyledProgressBar + OffsetsApp - - - - - - - - - - - - - - - ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━100%--:--:-- - - - - - - - - - - - -  S  Start  + + + + + + + + + ────────────── + FOO + BAR + BAZ + ────────────── + + + + + + ────────────── + FOO + BAR + BAZ + ────────────── + + + + ''' # --- -# name: test_progress_bar_halfway +# name: test_option_list_build ''' @@ -23649,143 +24411,146 @@ font-weight: 700; } - .terminal-1630089489-matrix { + .terminal-2860072847-matrix { font-family: Fira Code, monospace; font-size: 20px; line-height: 24.4px; font-variant-east-asian: full-width; } - .terminal-1630089489-title { + .terminal-2860072847-title { font-size: 18px; font-weight: bold; font-family: arial; } - .terminal-1630089489-r1 { fill: #c5c8c6 } - .terminal-1630089489-r2 { fill: #e1e1e1 } - .terminal-1630089489-r3 { fill: #fea62b } - .terminal-1630089489-r4 { fill: #323232 } - .terminal-1630089489-r5 { fill: #dde8f3;font-weight: bold } - .terminal-1630089489-r6 { fill: #ddedf9 } + .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 } - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - IndeterminateProgressBar + OptionListApp - - - - - - - - - - - - - - - ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━39%00:00:07 - - - - - - - - - - - -  S  Start  + + + + ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ + OneOneOne + TwoTwoTwo + ──────────────────────────────────────────────────────────────────── + ThreeThreeThree + ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ + + + + + + + + + + + + + + + + + + ''' # --- -# name: test_progress_bar_halfway_styled +# name: test_option_list_options ''' @@ -23808,145 +24573,148 @@ font-weight: 700; } - .terminal-1532901142-matrix { + .terminal-371403050-matrix { font-family: Fira Code, monospace; font-size: 20px; line-height: 24.4px; font-variant-east-asian: full-width; } - .terminal-1532901142-title { + .terminal-371403050-title { font-size: 18px; font-weight: bold; font-family: arial; } - .terminal-1532901142-r1 { fill: #c5c8c6 } - .terminal-1532901142-r2 { fill: #e1e1e1 } - .terminal-1532901142-r3 { fill: #004578 } - .terminal-1532901142-r4 { fill: #152939 } - .terminal-1532901142-r5 { fill: #1e1e1e } - .terminal-1532901142-r6 { fill: #e1e1e1;text-decoration: underline; } - .terminal-1532901142-r7 { fill: #dde8f3;font-weight: bold } - .terminal-1532901142-r8 { fill: #ddedf9 } + .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 } - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - StyledProgressBar + OptionListApp - - - - - - - - - - - - - - - ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━39%00:00:07 - - - - - - - - - - - -  S  Start  + + + + OptionListApp + + + ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ + Aerilon + Aquaria + ─────────────────────────────────────────────────── + Canceron + Caprica + ─────────────────────────────────────────────────── + Gemenon + ─────────────────────────────────────────────────── + Leonis + Libran + ─────────────────────────────────────────────────── + Picon + ─────────────────────────────────────────────────── + Sagittaron▄▄ + Scorpia + ─────────────────────────────────────────────────── + ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ + + + ''' # --- -# name: test_progress_bar_indeterminate +# name: test_option_list_replace_prompt_from_single_line_to_single_line ''' @@ -23969,143 +24737,145 @@ font-weight: 700; } - .terminal-3440292978-matrix { + .terminal-1891202557-matrix { font-family: Fira Code, monospace; font-size: 20px; line-height: 24.4px; font-variant-east-asian: full-width; } - .terminal-3440292978-title { + .terminal-1891202557-title { font-size: 18px; font-weight: bold; font-family: arial; } - .terminal-3440292978-r1 { fill: #c5c8c6 } - .terminal-3440292978-r2 { fill: #e1e1e1 } - .terminal-3440292978-r3 { fill: #323232 } - .terminal-3440292978-r4 { fill: #b93c5b } - .terminal-3440292978-r5 { fill: #dde8f3;font-weight: bold } - .terminal-3440292978-r6 { fill: #ddedf9 } + .terminal-1891202557-r1 { fill: #c5c8c6 } + .terminal-1891202557-r2 { fill: #e3e3e3 } + .terminal-1891202557-r3 { fill: #1e1e1e } + .terminal-1891202557-r4 { fill: #0178d4 } + .terminal-1891202557-r5 { fill: #ddedf9;font-weight: bold } + .terminal-1891202557-r6 { fill: #e2e2e2 } + .terminal-1891202557-r7 { fill: #e1e1e1 } + .terminal-1891202557-r8 { fill: #ddedf9 } - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - IndeterminateProgressBar + OptionListApp - - - - - - - - - - - - - - - ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━--%--:--:-- - - - - - - - - - - - -  S  Start  + + + + OptionListApp + ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ + 1. Another single line + 2. Two + lines + 3. Three + lines + of text + ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ + + + + + + + + + + + + + + + ''' # --- -# name: test_progress_bar_indeterminate_styled +# name: test_option_list_replace_prompt_from_single_line_to_two_lines ''' @@ -24128,145 +24898,145 @@ font-weight: 700; } - .terminal-4046569674-matrix { + .terminal-2188746417-matrix { font-family: Fira Code, monospace; font-size: 20px; line-height: 24.4px; font-variant-east-asian: full-width; } - .terminal-4046569674-title { + .terminal-2188746417-title { font-size: 18px; font-weight: bold; font-family: arial; } - .terminal-4046569674-r1 { fill: #c5c8c6 } - .terminal-4046569674-r2 { fill: #e1e1e1 } - .terminal-4046569674-r3 { fill: #fea62b } - .terminal-4046569674-r4 { fill: #004578 } - .terminal-4046569674-r5 { fill: #1e1e1e } - .terminal-4046569674-r6 { fill: #e1e1e1;text-decoration: underline; } - .terminal-4046569674-r7 { fill: #dde8f3;font-weight: bold } - .terminal-4046569674-r8 { fill: #ddedf9 } + .terminal-2188746417-r1 { fill: #c5c8c6 } + .terminal-2188746417-r2 { fill: #e3e3e3 } + .terminal-2188746417-r3 { fill: #1e1e1e } + .terminal-2188746417-r4 { fill: #0178d4 } + .terminal-2188746417-r5 { fill: #ddedf9;font-weight: bold } + .terminal-2188746417-r6 { fill: #e2e2e2 } + .terminal-2188746417-r7 { fill: #e1e1e1 } + .terminal-2188746417-r8 { fill: #ddedf9 } - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - StyledProgressBar + OptionListApp - - - - - - - - - - - - - - - ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━--%--:--:-- - - - - - - - - - - - -  S  Start  + + + + OptionListApp + ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ + 1. Two + lines + 2. Two + lines + 3. Three + lines + of text + ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ + + + + + + + + + + + + + + ''' # --- -# name: test_quickly_change_tabs +# name: test_option_list_replace_prompt_from_two_lines_to_three_lines ''' @@ -24289,135 +25059,137 @@ font-weight: 700; } - .terminal-1484676870-matrix { + .terminal-2667681921-matrix { font-family: Fira Code, monospace; font-size: 20px; line-height: 24.4px; font-variant-east-asian: full-width; } - .terminal-1484676870-title { + .terminal-2667681921-title { font-size: 18px; font-weight: bold; font-family: arial; } - .terminal-1484676870-r1 { fill: #c5c8c6 } - .terminal-1484676870-r2 { fill: #737373 } - .terminal-1484676870-r3 { fill: #e1e1e1;font-weight: bold } - .terminal-1484676870-r4 { fill: #474747 } - .terminal-1484676870-r5 { fill: #0178d4 } - .terminal-1484676870-r6 { fill: #e1e1e1 } - - - - - + .terminal-2667681921-r1 { fill: #c5c8c6 } + .terminal-2667681921-r2 { fill: #e3e3e3 } + .terminal-2667681921-r3 { fill: #1e1e1e } + .terminal-2667681921-r4 { fill: #0178d4 } + .terminal-2667681921-r5 { fill: #ddedf9;font-weight: bold } + .terminal-2667681921-r6 { fill: #e2e2e2 } + .terminal-2667681921-r7 { fill: #e1e1e1 } + .terminal-2667681921-r8 { fill: #ddedf9 } + + + + + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - QuicklyChangeTabsApp + OptionListApp - - - - - onetwothree - ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ - - three - - - - - - - - - - - - - - - - - - + + + + OptionListApp + ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ + 1. Single line + 1. Three + lines + of text + 3. Three + lines + of text + ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ + + + + + + + + + + + + + @@ -24425,7 +25197,7 @@ ''' # --- -# name: test_radio_button_example +# name: test_option_list_strings ''' @@ -24448,139 +25220,137 @@ font-weight: 700; } - .terminal-398709012-matrix { + .terminal-2341816165-matrix { font-family: Fira Code, monospace; font-size: 20px; line-height: 24.4px; font-variant-east-asian: full-width; } - .terminal-398709012-title { + .terminal-2341816165-title { font-size: 18px; font-weight: bold; font-family: arial; } - .terminal-398709012-r1 { fill: #e1e1e1 } - .terminal-398709012-r2 { fill: #c5c8c6 } - .terminal-398709012-r3 { fill: #1e1e1e } - .terminal-398709012-r4 { fill: #0178d4 } - .terminal-398709012-r5 { fill: #575757 } - .terminal-398709012-r6 { fill: #262626;font-weight: bold } - .terminal-398709012-r7 { fill: #e2e2e2 } - .terminal-398709012-r8 { fill: #e2e2e2;text-decoration: underline; } - .terminal-398709012-r9 { fill: #434343 } - .terminal-398709012-r10 { fill: #4ebf71;font-weight: bold } + .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 } - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - RadioChoicesApp + OptionListApp - - - - - - - - - - ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ - Battlestar Galactica -  Dune 1984 -  Dune 2021 -  Serenity -  Star Trek: The Motion Picture -  Star Wars: A New Hope -  The Last Starfighter -  Total Recall 👉 🔴 -  Wing Commander - ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ - - - - - - + + + + OptionListApp + + + ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ + Aerilon + Aquaria + Canceron + Caprica + Gemenon + Leonis + Libran + Picon + Sagittaron + Scorpia + Tauron + Virgon + + + + + ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ + + @@ -24588,7 +25358,7 @@ ''' # --- -# name: test_radio_set_example +# name: test_option_list_tables ''' @@ -24611,140 +25381,141 @@ font-weight: 700; } - .terminal-2369252398-matrix { + .terminal-228828675-matrix { font-family: Fira Code, monospace; font-size: 20px; line-height: 24.4px; font-variant-east-asian: full-width; } - .terminal-2369252398-title { + .terminal-228828675-title { font-size: 18px; font-weight: bold; font-family: arial; } - .terminal-2369252398-r1 { fill: #e1e1e1 } - .terminal-2369252398-r2 { fill: #c5c8c6 } - .terminal-2369252398-r3 { fill: #1e1e1e } - .terminal-2369252398-r4 { fill: #0178d4 } - .terminal-2369252398-r5 { fill: #575757 } - .terminal-2369252398-r6 { fill: #262626;font-weight: bold } - .terminal-2369252398-r7 { fill: #e2e2e2 } - .terminal-2369252398-r8 { fill: #e2e2e2;text-decoration: underline; } - .terminal-2369252398-r9 { fill: #434343 } - .terminal-2369252398-r10 { fill: #4ebf71;font-weight: bold } - .terminal-2369252398-r11 { fill: #cc555a;font-weight: bold;font-style: italic; } + .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 } - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - RadioChoicesApp + OptionListApp - - - - - - - - - - ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ - 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 - ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ - - - - - - + + + + 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   + ┡━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━┩ + ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ + + @@ -24752,7 +25523,7 @@ ''' # --- -# name: test_remove_with_auto_height +# name: test_order_independence ''' @@ -24775,144 +25546,143 @@ font-weight: 700; } - .terminal-1869274227-matrix { + .terminal-159476969-matrix { font-family: Fira Code, monospace; font-size: 20px; line-height: 24.4px; font-variant-east-asian: full-width; } - .terminal-1869274227-title { + .terminal-159476969-title { font-size: 18px; font-weight: bold; font-family: arial; } - .terminal-1869274227-r1 { fill: #c5c8c6 } - .terminal-1869274227-r2 { fill: #e3e3e3 } - .terminal-1869274227-r3 { fill: #008000 } - .terminal-1869274227-r4 { fill: #ffff00 } - .terminal-1869274227-r5 { fill: #e1e1e1 } - .terminal-1869274227-r6 { fill: #dde8f3;font-weight: bold } - .terminal-1869274227-r7 { fill: #ddedf9 } + .terminal-159476969-r1 { fill: #ffff00 } + .terminal-159476969-r2 { fill: #e3e3e3 } + .terminal-159476969-r3 { fill: #c5c8c6 } + .terminal-159476969-r4 { fill: #e1e1e1 } + .terminal-159476969-r5 { fill: #dde8f3;font-weight: bold } + .terminal-159476969-r6 { fill: #ddedf9 } - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - VerticalRemoveApp + Layers - - - - VerticalRemoveApp - ────────────────────────────────────────────────────────────────────────────── - ──────────────────── - This is a test label - ──────────────────── - ────────────────────────────────────────────────────────────────────────────── - - - - - - - - - - - - - - - - - -  A  Add  D  Delete  + + + + ──────────────────────────────────Layers + It's full of stars! My God! It's full of sta + + This should float over the top + + + ────────────────────────────────── + + + + + + + + + + + + + + + + +  T  Toggle Screen  ''' # --- -# name: test_richlog_max_lines +# name: test_order_independence_toggle ''' @@ -24935,139 +25705,143 @@ font-weight: 700; } - .terminal-707514202-matrix { + .terminal-4133316721-matrix { font-family: Fira Code, monospace; font-size: 20px; line-height: 24.4px; font-variant-east-asian: full-width; } - .terminal-707514202-title { + .terminal-4133316721-title { font-size: 18px; font-weight: bold; font-family: arial; } - .terminal-707514202-r1 { fill: #e1e1e1 } - .terminal-707514202-r2 { fill: #c5c8c6 } + .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 } - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - RichLogLines + Layers - - - - Key press #3 - Key press #4 - Key press #5 - - - - - - - - - - - - - - - - - - - - - + + + + ──────────────────────────────────Layers + It's full of stars! My God! It's full of sta + + This should float over the top + + + ────────────────────────────────── + + + + + + + + + + + + + + + + +  T  Toggle Screen  ''' # --- -# name: test_richlog_scroll +# name: test_placeholder_render ''' @@ -25090,131 +25864,142 @@ font-weight: 700; } - .terminal-2577369616-matrix { + .terminal-3476127971-matrix { font-family: Fira Code, monospace; font-size: 20px; line-height: 24.4px; font-variant-east-asian: full-width; } - .terminal-2577369616-title { + .terminal-3476127971-title { font-size: 18px; font-weight: bold; font-family: arial; } - .terminal-2577369616-r1 { fill: #e1e1e1 } - .terminal-2577369616-r2 { fill: #c5c8c6 } + .terminal-3476127971-r1 { fill: #c5c8c6 } + .terminal-3476127971-r2 { fill: #eae3e5 } + .terminal-3476127971-r3 { fill: #e8e0e7 } + .terminal-3476127971-r4 { fill: #efe9e4 } + .terminal-3476127971-r5 { fill: #ede6e6 } + .terminal-3476127971-r6 { fill: #efeedf } + .terminal-3476127971-r7 { fill: #e9eee5 } + .terminal-3476127971-r8 { fill: #e3e6eb } + .terminal-3476127971-r9 { fill: #dfe9ed;font-weight: bold } + .terminal-3476127971-r10 { fill: #e6e3e9;font-weight: bold } + .terminal-3476127971-r11 { fill: #e4eee8 } + .terminal-3476127971-r12 { fill: #e2edeb;font-weight: bold } + .terminal-3476127971-r13 { fill: #dfebed } - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - RichLogScrollApp + PlaceholderApp - - - - Line 0Line 10Line 0 - Line 1Line 11Line 1 - Line 2Line 12Line 2 - Line 3Line 13Line 3 - Line 4Line 14Line 4 - Line 5Line 15Line 5 - Line 6Line 16Line 6 - Line 7Line 17Line 7 - Line 8Line 18Line 8 - Line 9Line 19Line 9 - - - - - - - - - - - - - + + + + + Placeholder p2 here! + This is a custom label for p1. + #p4 + #p3#p5Placeholde + r + + Lorem ipsum dolor sit  + 26 x 6amet, consectetur 27 x 6 + adipiscing elit. Etiam  + feugiat ac elit sit amet  + + + Lorem ipsum dolor sit amet,  + consectetur adipiscing elit. Etiam 40 x 6 + feugiat ac elit sit amet accumsan.  + Suspendisse bibendum nec libero quis  + gravida. Phasellus id eleifend ligula. + Nullam imperdiet sem tellus, sed  + vehicula nisl faucibus sit amet. Lorem ipsum dolor sit amet,  + Praesent iaculis tempor ultricies. Sedconsectetur adipiscing elit. Etiam  + lacinia, tellus id rutrum lacinia, feugiat ac elit sit amet accumsan.  + sapien sapien congue mauris, sit amet Suspendisse bibendum nec libero quis  @@ -25222,7 +26007,7 @@ ''' # --- -# name: test_rule_horizontal_rules +# name: test_print_capture ''' @@ -25245,132 +26030,131 @@ font-weight: 700; } - .terminal-2805746586-matrix { + .terminal-3935013562-matrix { font-family: Fira Code, monospace; font-size: 20px; line-height: 24.4px; font-variant-east-asian: full-width; } - .terminal-2805746586-title { + .terminal-3935013562-title { font-size: 18px; font-weight: bold; font-family: arial; } - .terminal-2805746586-r1 { fill: #e1e1e1 } - .terminal-2805746586-r2 { fill: #c5c8c6 } - .terminal-2805746586-r3 { fill: #004578 } + .terminal-3935013562-r1 { fill: #e1e1e1 } + .terminal-3935013562-r2 { fill: #c5c8c6 } - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - HorizontalRulesApp + CaptureApp - - - -                         solid (default)                          - - ──────────────────────────────────────────────────────────────── - -                              heavy                               - - ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ - -                              thick                               - - ████████████████████████████████████████████████████████████████ - -                              dashed                              - - ╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍ - -                              double                              - - ════════════════════════════════════════════════════════════════ - -                              ascii                               - - ---------------------------------------------------------------- + + + + RichLog + This will be captured! + + + + + + + + + + + + + + + + + + + + + @@ -25378,7 +26162,7 @@ ''' # --- -# name: test_rule_vertical_rules +# name: test_programmatic_scrollbar_gutter_change ''' @@ -25401,132 +26185,132 @@ font-weight: 700; } - .terminal-1162739243-matrix { + .terminal-4222066429-matrix { font-family: Fira Code, monospace; font-size: 20px; line-height: 24.4px; font-variant-east-asian: full-width; } - .terminal-1162739243-title { + .terminal-4222066429-title { font-size: 18px; font-weight: bold; font-family: arial; } - .terminal-1162739243-r1 { fill: #e1e1e1 } - .terminal-1162739243-r2 { fill: #c5c8c6 } - .terminal-1162739243-r3 { fill: #004578 } + .terminal-4222066429-r1 { fill: #ffdddd } + .terminal-4222066429-r2 { fill: #c5c8c6 } + .terminal-4222066429-r3 { fill: #e1e1e1 } - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - VerticalRulesApp + ProgrammaticScrollbarGutterChange - - - - - - solid heavy thick dasheddoubleascii | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - - + + + + onetwo + + + + + + + + + + + + threefour + + + + + + + + + + @@ -25534,7 +26318,7 @@ ''' # --- -# name: test_screen_switch +# name: test_progress_bar_completed ''' @@ -25557,144 +26341,144 @@ font-weight: 700; } - .terminal-1316892474-matrix { + .terminal-2592118671-matrix { font-family: Fira Code, monospace; font-size: 20px; line-height: 24.4px; font-variant-east-asian: full-width; } - .terminal-1316892474-title { + .terminal-2592118671-title { font-size: 18px; font-weight: bold; font-family: arial; } - .terminal-1316892474-r1 { fill: #c5c8c6 } - .terminal-1316892474-r2 { fill: #e3e3e3 } - .terminal-1316892474-r3 { fill: #e1e1e1 } - .terminal-1316892474-r4 { fill: #dde8f3;font-weight: bold } - .terminal-1316892474-r5 { fill: #ddedf9 } + .terminal-2592118671-r1 { fill: #e1e1e1 } + .terminal-2592118671-r2 { fill: #c5c8c6 } + .terminal-2592118671-r3 { fill: #4ebf71 } + .terminal-2592118671-r4 { fill: #dde8f3;font-weight: bold } + .terminal-2592118671-r5 { fill: #ddedf9 } - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - ModalApp + IndeterminateProgressBar - - - - ModalApp - B - - - - - - - - - - - - - - - - - - - - - -  A  Push screen A  + + + + + + + + + + + + + + + ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━100%--:--:-- + + + + + + + + + + + +  S  Start  ''' # --- -# name: test_scroll_to +# name: test_progress_bar_completed_styled ''' - + - - + + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - - - - ScrollOffByOne + StyledProgressBar - - - - X 43 - ▁▁▁▁▁▁▁▁ - ▔▔▔▔▔▔▔▔ - X 44 - ▁▁▁▁▁▁▁▁ - ▔▔▔▔▔▔▔▔ - X 45 - ▁▁▁▁▁▁▁▁ - ▔▔▔▔▔▔▔▔ - X 46 - ▁▁▁▁▁▁▁▁▃▃ - ▔▔▔▔▔▔▔▔ - X 47▂▂ - ▁▁▁▁▁▁▁▁ - ▔▔▔▔▔▔▔▔ - X 48 - ▁▁▁▁▁▁▁▁ - ▔▔▔▔▔▔▔▔ - X 49 - ▁▁▁▁▁▁▁▁ - ▔▔▔▔▔▔▔▔ - X 50 - ▁▁▁▁▁▁▁▁ - ▔▔▔▔▔▔▔▔ - + + + + + + + + + + + + + + + ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━100%--:--:-- + + + + + + + + + + + +  S  Start  ''' # --- -# name: test_scroll_to_center +# name: test_progress_bar_halfway ''' @@ -25881,144 +26659,143 @@ font-weight: 700; } - .terminal-1487675823-matrix { + .terminal-2764447286-matrix { font-family: Fira Code, monospace; font-size: 20px; line-height: 24.4px; font-variant-east-asian: full-width; } - .terminal-1487675823-title { + .terminal-2764447286-title { font-size: 18px; font-weight: bold; font-family: arial; } - .terminal-1487675823-r1 { fill: #e1e1e1 } - .terminal-1487675823-r2 { fill: #c5c8c6 } - .terminal-1487675823-r3 { fill: #004578 } - .terminal-1487675823-r4 { fill: #23568b } - .terminal-1487675823-r5 { fill: #fea62b } - .terminal-1487675823-r6 { fill: #cc555a } - .terminal-1487675823-r7 { fill: #14191f } + .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 } - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - MyApp + IndeterminateProgressBar - - - - SPAM - SPAM - SPAM - ──────────────────────────────────────────────────────────────────────────── - SPAM - SPAM - SPAM - SPAM - SPAM - SPAM▄▄ - SPAM - SPAM - ──────────────────────────────────────────────────────────────────────── - @@@@@@@@@@@@@@@@@@@@@@@@@@@@@@>>bullseye<<@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@ - ▇▇ - ▄▄ - - - - - - - - ──────────────────────────────────────────────────────────────────────────── + + + + + + + + + + + + + + + ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━39%00:00:07 + + + + + + + + + + + +  S  Start  ''' # --- -# name: test_scroll_visible +# name: test_progress_bar_halfway_styled ''' @@ -26041,140 +26818,145 @@ font-weight: 700; } - .terminal-150767416-matrix { + .terminal-3956614203-matrix { font-family: Fira Code, monospace; font-size: 20px; line-height: 24.4px; font-variant-east-asian: full-width; } - .terminal-150767416-title { + .terminal-3956614203-title { font-size: 18px; font-weight: bold; font-family: arial; } - .terminal-150767416-r1 { fill: #e1e1e1 } - .terminal-150767416-r2 { fill: #c5c8c6 } - .terminal-150767416-r3 { fill: #23568b } + .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 } - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - MyApp + StyledProgressBar - - - - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - |▆▆ - | - | - | - | - SHOULD BE VISIBLE + + + + + + + + + + + + + + + ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━39%00:00:07 + + + + + + + + + + + +  S  Start  ''' # --- -# name: test_scrollbar_thumb_height +# name: test_progress_bar_indeterminate ''' @@ -26197,142 +26979,143 @@ font-weight: 700; } - .terminal-4204360114-matrix { + .terminal-852664727-matrix { font-family: Fira Code, monospace; font-size: 20px; line-height: 24.4px; font-variant-east-asian: full-width; } - .terminal-4204360114-title { + .terminal-852664727-title { font-size: 18px; font-weight: bold; font-family: arial; } - .terminal-4204360114-r1 { fill: #c5c8c6 } - .terminal-4204360114-r2 { fill: #e3e3e3 } - .terminal-4204360114-r3 { fill: #ff0000 } - .terminal-4204360114-r4 { fill: #dde2e8 } - .terminal-4204360114-r5 { fill: #ddedf9 } + .terminal-852664727-r1 { fill: #e1e1e1 } + .terminal-852664727-r2 { fill: #c5c8c6 } + .terminal-852664727-r3 { fill: #323232 } + .terminal-852664727-r4 { fill: #b93c5b } + .terminal-852664727-r5 { fill: #dde8f3;font-weight: bold } + .terminal-852664727-r6 { fill: #ddedf9 } - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - ScrollViewTester + IndeterminateProgressBar - - - - ScrollViewTester -  1 ────────────────────────────────────────────────────────────────────────── - Welcome to line 980 - Welcome to line 981 - Welcome to line 982 - Welcome to line 983 - Welcome to line 984 - Welcome to line 985 - Welcome to line 986 - Welcome to line 987 - Welcome to line 988 - Welcome to line 989 - Welcome to line 990 - Welcome to line 991 - Welcome to line 992 - Welcome to line 993 - Welcome to line 994 - Welcome to line 995 - Welcome to line 996 - Welcome to line 997 - Welcome to line 998 - Welcome to line 999 - ────────────────────────────────────────────────────────────────────────────── - + + + + + + + + + + + + + + + ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━--%--:--:-- + + + + + + + + + + + +  S  Start  ''' # --- -# name: test_select +# name: test_progress_bar_indeterminate_styled ''' @@ -26355,144 +27138,145 @@ font-weight: 700; } - .terminal-1161182100-matrix { + .terminal-1175498223-matrix { font-family: Fira Code, monospace; font-size: 20px; line-height: 24.4px; font-variant-east-asian: full-width; } - .terminal-1161182100-title { + .terminal-1175498223-title { font-size: 18px; font-weight: bold; font-family: arial; } - .terminal-1161182100-r1 { fill: #c5c8c6 } - .terminal-1161182100-r2 { fill: #e3e3e3 } - .terminal-1161182100-r3 { fill: #e1e1e1 } - .terminal-1161182100-r4 { fill: #1e1e1e } - .terminal-1161182100-r5 { fill: #0178d4 } - .terminal-1161182100-r6 { fill: #787878 } - .terminal-1161182100-r7 { fill: #a8a8a8 } + .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 } - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - SelectApp + StyledProgressBar - - - - SelectApp - - - ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ - Select - ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━--%--:--:-- + + + + + + + + + + + +  S  Start  ''' # --- -# name: test_select_expanded +# name: test_quickly_change_tabs ''' @@ -26515,140 +27299,135 @@ font-weight: 700; } - .terminal-2035490498-matrix { + .terminal-1103805314-matrix { font-family: Fira Code, monospace; font-size: 20px; line-height: 24.4px; font-variant-east-asian: full-width; } - .terminal-2035490498-title { + .terminal-1103805314-title { font-size: 18px; font-weight: bold; font-family: arial; } - .terminal-2035490498-r1 { fill: #c5c8c6 } - .terminal-2035490498-r2 { fill: #e3e3e3 } - .terminal-2035490498-r3 { fill: #e1e1e1 } - .terminal-2035490498-r4 { fill: #1e1e1e } - .terminal-2035490498-r5 { fill: #0178d4 } - .terminal-2035490498-r6 { fill: #787878 } - .terminal-2035490498-r7 { fill: #a8a8a8 } - .terminal-2035490498-r8 { fill: #121212 } - .terminal-2035490498-r9 { fill: #ddedf9;font-weight: bold } - .terminal-2035490498-r10 { fill: #85beea;font-weight: bold } - .terminal-2035490498-r11 { fill: #e2e3e3 } + .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 } - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - SelectApp + QuicklyChangeTabsApp - - - - SelectApp - - - ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ - Select - ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ - ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ - Select - I must not fear. - Fear is the mind-killer. - Fear is the little-death that brings total  - obliteration. - I will face my fear. - I will permit it to pass over me and through me. - ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ - - - - - - - - + + + + + onetwothree + ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + three + + + + + + + + + + + + + + + + + + @@ -26656,7 +27435,7 @@ ''' # --- -# name: test_select_expanded_changed +# name: test_radio_button_example ''' @@ -26679,136 +27458,139 @@ font-weight: 700; } - .terminal-4010426174-matrix { + .terminal-3755206349-matrix { font-family: Fira Code, monospace; font-size: 20px; line-height: 24.4px; font-variant-east-asian: full-width; } - .terminal-4010426174-title { + .terminal-3755206349-title { font-size: 18px; font-weight: bold; font-family: arial; } - .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 } + .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 } - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - SelectApp + RadioChoicesApp - - - - I must not fear. - - - ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ - I must not fear. - ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ - - - - - - - - - - - - - - - - - + + + + + + + + + + ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ + Battlestar Galactica +  Dune 1984 +  Dune 2021 +  Serenity +  Star Trek: The Motion Picture +  Star Wars: A New Hope +  The Last Starfighter +  Total Recall 👉 🔴 +  Wing Commander + ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ + + + + + + @@ -26816,7 +27598,7 @@ ''' # --- -# name: test_select_rebuild +# name: test_radio_set_example ''' @@ -26839,139 +27621,140 @@ font-weight: 700; } - .terminal-330554958-matrix { + .terminal-3259211563-matrix { font-family: Fira Code, monospace; font-size: 20px; line-height: 24.4px; font-variant-east-asian: full-width; } - .terminal-330554958-title { + .terminal-3259211563-title { font-size: 18px; font-weight: bold; font-family: arial; } - .terminal-330554958-r1 { fill: #1e1e1e } - .terminal-330554958-r2 { fill: #0178d4 } - .terminal-330554958-r3 { fill: #c5c8c6 } - .terminal-330554958-r4 { fill: #787878 } - .terminal-330554958-r5 { fill: #a8a8a8 } - .terminal-330554958-r6 { fill: #121212 } - .terminal-330554958-r7 { fill: #ddedf9;font-weight: bold } - .terminal-330554958-r8 { fill: #85beea;font-weight: bold } - .terminal-330554958-r9 { fill: #e2e3e3 } - .terminal-330554958-r10 { fill: #e1e1e1 } + .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; } - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - SelectRebuildApp + RadioChoicesApp - - - - ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ - Select - ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ - ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ - Select - This - Should - Be - What - Goes - Into - The - Snapshit - ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ - - - - - - - - - + + + + + + + + + + ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ + 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 + ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ + + + + + + @@ -26979,7 +27762,7 @@ ''' # --- -# name: test_selection_list_selected +# name: test_remove_with_auto_height ''' @@ -27002,149 +27785,144 @@ font-weight: 700; } - .terminal-4089366530-matrix { + .terminal-1855273214-matrix { font-family: Fira Code, monospace; font-size: 20px; line-height: 24.4px; font-variant-east-asian: full-width; } - .terminal-4089366530-title { + .terminal-1855273214-title { font-size: 18px; font-weight: bold; font-family: arial; } - .terminal-4089366530-r1 { fill: #c5c8c6 } - .terminal-4089366530-r2 { fill: #e3e3e3 } - .terminal-4089366530-r3 { fill: #e1e1e1 } - .terminal-4089366530-r4 { fill: #0178d4 } - .terminal-4089366530-r5 { fill: #e1e1e1;font-weight: bold } - .terminal-4089366530-r6 { fill: #575757 } - .terminal-4089366530-r7 { fill: #4ebf71;font-weight: bold } - .terminal-4089366530-r8 { fill: #ddedf9;font-weight: bold } - .terminal-4089366530-r9 { fill: #98a84b } - .terminal-4089366530-r10 { fill: #262626;font-weight: bold } - .terminal-4089366530-r11 { fill: #e2e2e2 } - .terminal-4089366530-r12 { fill: #ddedf9 } + .terminal-1855273214-r1 { fill: #c5c8c6 } + .terminal-1855273214-r2 { fill: #e3e3e3 } + .terminal-1855273214-r3 { fill: #008000 } + .terminal-1855273214-r4 { fill: #ffff00 } + .terminal-1855273214-r5 { fill: #e1e1e1 } + .terminal-1855273214-r6 { fill: #dde8f3;font-weight: bold } + .terminal-1855273214-r7 { fill: #ddedf9 } - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - SelectionListApp + VerticalRemoveApp - - - - 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 - - ────────────────────────────── - - - - - - - - + + + + VerticalRemoveApp + ────────────────────────────────────────────────────────────────────────────── + ──────────────────── + This is a test label + ──────────────────── + ────────────────────────────────────────────────────────────────────────────── + + + + + + + + + + + + + + + + + +  A  Add  D  Delete  ''' # --- -# name: test_selection_list_selections +# name: test_richlog_max_lines ''' @@ -27167,139 +27945,131 @@ font-weight: 700; } - .terminal-3401996005-matrix { + .terminal-707514202-matrix { font-family: Fira Code, monospace; font-size: 20px; line-height: 24.4px; font-variant-east-asian: full-width; } - .terminal-3401996005-title { + .terminal-707514202-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-707514202-r1 { fill: #e1e1e1 } + .terminal-707514202-r2 { fill: #c5c8c6 } - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - SelectionListApp + RichLogLines - - - - SelectionListApp - - -  Shall we play some games? ────────────────────────────────── - - XFalken's Maze - XBlack Jack - XGin Rummy - XHearts - XBridge - XCheckers - XChess - XPoker - XFighter Combat - - - - - - - ────────────────────────────────────────────────────────────── - - + + + + Key press #3 + Key press #4 + Key press #5 + + + + + + + + + + + + + + + + + + + + @@ -27307,7 +28077,7 @@ ''' # --- -# name: test_selection_list_tuples +# name: test_richlog_scroll ''' @@ -27330,139 +28100,131 @@ font-weight: 700; } - .terminal-3401996005-matrix { + .terminal-1498692038-matrix { font-family: Fira Code, monospace; font-size: 20px; line-height: 24.4px; font-variant-east-asian: full-width; } - .terminal-3401996005-title { + .terminal-1498692038-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-1498692038-r1 { fill: #e1e1e1 } + .terminal-1498692038-r2 { fill: #c5c8c6 } - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - SelectionListApp + RichLogScrollApp - - - - SelectionListApp - - -  Shall we play some games? ────────────────────────────────── - - XFalken's Maze - XBlack Jack - XGin Rummy - XHearts - XBridge - XCheckers - XChess - XPoker - XFighter Combat - - - - - - - ────────────────────────────────────────────────────────────── - - + + + + Line 0Line 10Line 0 + Line 1Line 11Line 1 + Line 2Line 12Line 2 + Line 3Line 13Line 3 + Line 4Line 14Line 4 + Line 5Line 15Line 5 + Line 6Line 16Line 6 + Line 7Line 17Line 7 + Line 8Line 18Line 8 + Line 9Line 19Line 9 + + + + + + + + + + + + + @@ -27470,7 +28232,7 @@ ''' # --- -# name: test_sparkline_component_classes_colors +# name: test_rule_horizontal_rules ''' @@ -27493,264 +28255,3153 @@ font-weight: 700; } - .terminal-2491064415-matrix { + .terminal-2818358681-matrix { font-family: Fira Code, monospace; font-size: 20px; line-height: 24.4px; font-variant-east-asian: full-width; } - .terminal-2491064415-title { + .terminal-2818358681-title { font-size: 18px; font-weight: bold; font-family: arial; } - .terminal-2491064415-r1 { fill: #e1e1e1 } - .terminal-2491064415-r2 { fill: #c5c8c6 } - .terminal-2491064415-r3 { fill: #fea62b } - .terminal-2491064415-r4 { fill: #eea831 } - .terminal-2491064415-r5 { fill: #d0ac3c } - .terminal-2491064415-r6 { fill: #c2ae42 } - .terminal-2491064415-r7 { fill: #b4b048 } - .terminal-2491064415-r8 { fill: #9ab452 } - .terminal-2491064415-r9 { fill: #8db557 } - .terminal-2491064415-r10 { fill: #78b860 } - .terminal-2491064415-r11 { fill: #6eba63 } - .terminal-2491064415-r12 { fill: #66bb67 } - .terminal-2491064415-r13 { fill: #59bd6c } - .terminal-2491064415-r14 { fill: #54be6e } - .terminal-2491064415-r15 { fill: #4ebe70 } - .terminal-2491064415-r16 { fill: #50be70 } - .terminal-2491064415-r17 { fill: #57bd6d } - .terminal-2491064415-r18 { fill: #5cbc6b } - .terminal-2491064415-r19 { fill: #63bb68 } - .terminal-2491064415-r20 { fill: #74b961 } - .terminal-2491064415-r21 { fill: #7eb85d } - .terminal-2491064415-r22 { fill: #94b454 } - .terminal-2491064415-r23 { fill: #a1b34f } - .terminal-2491064415-r24 { fill: #aeb14a } - .terminal-2491064415-r25 { fill: #caad3f } - .terminal-2491064415-r26 { fill: #d9ab39 } - .terminal-2491064415-r27 { fill: #f7a62d } - .terminal-2491064415-r28 { fill: #f5a72e } - .terminal-2491064415-r29 { fill: #d7ab3a } - .terminal-2491064415-r30 { fill: #c8ad40 } - .terminal-2491064415-r31 { fill: #baaf45 } - .terminal-2491064415-r32 { fill: #9fb350 } - .terminal-2491064415-r33 { fill: #93b555 } - .terminal-2491064415-r34 { fill: #7cb85e } - .terminal-2491064415-r35 { fill: #72b962 } - .terminal-2491064415-r36 { fill: #6abb65 } - .terminal-2491064415-r37 { fill: #5bbd6b } - .terminal-2491064415-r38 { fill: #56bd6d } - .terminal-2491064415-r39 { fill: #4fbe70 } - .terminal-2491064415-r40 { fill: #55bd6e } - .terminal-2491064415-r41 { fill: #5abd6c } - .terminal-2491064415-r42 { fill: #60bc69 } - .terminal-2491064415-r43 { fill: #70ba63 } - .terminal-2491064415-r44 { fill: #79b85f } - .terminal-2491064415-r45 { fill: #8fb556 } - .terminal-2491064415-r46 { fill: #9bb352 } - .terminal-2491064415-r47 { fill: #a8b24c } - .terminal-2491064415-r48 { fill: #c4ae41 } - .terminal-2491064415-r49 { fill: #d3ac3c } - .terminal-2491064415-r50 { fill: #f1a730 } - .terminal-2491064415-r51 { fill: #fba62b } - .terminal-2491064415-r52 { fill: #ddaa37 } - .terminal-2491064415-r53 { fill: #ceac3d } - .terminal-2491064415-r54 { fill: #c0ae43 } - .terminal-2491064415-r55 { fill: #a5b24e } - .terminal-2491064415-r56 { fill: #98b453 } - .terminal-2491064415-r57 { fill: #81b75c } - .terminal-2491064415-r58 { fill: #76b960 } - .terminal-2491064415-r59 { fill: #6dba64 } - .terminal-2491064415-r60 { fill: #5ebc6a } - .terminal-2491064415-r61 { fill: #58bd6c } - .terminal-2491064415-r62 { fill: #50be6f } - .terminal-2491064415-r63 { fill: #4ebf71 } - .terminal-2491064415-r64 { fill: #53be6e } - .terminal-2491064415-r65 { fill: #58bd6d } - .terminal-2491064415-r66 { fill: #5dbc6a } - .terminal-2491064415-r67 { fill: #6cba64 } - .terminal-2491064415-r68 { fill: #75b961 } - .terminal-2491064415-r69 { fill: #8ab658 } - .terminal-2491064415-r70 { fill: #96b454 } - .terminal-2491064415-r71 { fill: #a3b24f } - .terminal-2491064415-r72 { fill: #beaf44 } - .terminal-2491064415-r73 { fill: #ccac3e } - .terminal-2491064415-r74 { fill: #7bb85f } - .terminal-2491064415-r75 { fill: #89b659 } - .terminal-2491064415-r76 { fill: #97b453 } - .terminal-2491064415-r77 { fill: #b1b049 } - .terminal-2491064415-r78 { fill: #d3ac3b } - .terminal-2491064415-r79 { fill: #ddaa38 } - .terminal-2491064415-r80 { fill: #e5a934 } - .terminal-2491064415-r81 { fill: #f2a72f } - .terminal-2491064415-r82 { fill: #fda62b } - .terminal-2491064415-r83 { fill: #f4a72e } - .terminal-2491064415-r84 { fill: #efa830 } - .terminal-2491064415-r85 { fill: #e8a933 } - .terminal-2491064415-r86 { fill: #cdac3e } - .terminal-2491064415-r87 { fill: #b7b047 } - .terminal-2491064415-r88 { fill: #aab14c } - .terminal-2491064415-r89 { fill: #9db351 } - .terminal-2491064415-r90 { fill: #83b75b } - .terminal-2491064415-r91 { fill: #91b556 } - .terminal-2491064415-r92 { fill: #acb14b } - .terminal-2491064415-r93 { fill: #b8af46 } - .terminal-2491064415-r94 { fill: #cfac3d } - .terminal-2491064415-r95 { fill: #e1a936 } - .terminal-2491064415-r96 { fill: #f0a730 } - .terminal-2491064415-r97 { fill: #fca62b } - .terminal-2491064415-r98 { fill: #f6a72d } - .terminal-2491064415-r99 { fill: #f1a72f } - .terminal-2491064415-r100 { fill: #eba832 } - .terminal-2491064415-r101 { fill: #dbaa38 } - .terminal-2491064415-r102 { fill: #d2ac3c } - .terminal-2491064415-r103 { fill: #bcaf45 } - .terminal-2491064415-r104 { fill: #b0b149 } - .terminal-2491064415-r105 { fill: #87b65a } - .terminal-2491064415-r106 { fill: #78b85f } - .terminal-2491064415-r107 { fill: #5abd6b } - .terminal-2491064415-r108 { fill: #6eba64 } - .terminal-2491064415-r109 { fill: #7db85e } - .terminal-2491064415-r110 { fill: #8bb658 } - .terminal-2491064415-r111 { fill: #a6b24d } - .terminal-2491064415-r112 { fill: #b3b048 } - .terminal-2491064415-r113 { fill: #d5ab3b } - .terminal-2491064415-r114 { fill: #deaa37 } - .terminal-2491064415-r115 { fill: #eda831 } - .terminal-2491064415-r116 { fill: #f3a72f } - .terminal-2491064415-r117 { fill: #fba62c } - .terminal-2491064415-r118 { fill: #f8a62d } - .terminal-2491064415-r119 { fill: #f3a72e } - .terminal-2491064415-r120 { fill: #dfaa37 } - .terminal-2491064415-r121 { fill: #d6ab3a } - .terminal-2491064415-r122 { fill: #c1ae43 } - .terminal-2491064415-r123 { fill: #b5b047 } - .terminal-2491064415-r124 { fill: #7fb85d } - .terminal-2491064415-r125 { fill: #f89c2f } - .terminal-2491064415-r126 { fill: #ec8a37 } - .terminal-2491064415-r127 { fill: #e6823b } - .terminal-2491064415-r128 { fill: #e1793f } - .terminal-2491064415-r129 { fill: #d66946 } - .terminal-2491064415-r130 { fill: #d26249 } - .terminal-2491064415-r131 { fill: #c9554f } - .terminal-2491064415-r132 { fill: #c54f52 } - .terminal-2491064415-r133 { fill: #c24a54 } - .terminal-2491064415-r134 { fill: #bd4257 } - .terminal-2491064415-r135 { fill: #bb4059 } - .terminal-2491064415-r136 { fill: #b93c5a } - .terminal-2491064415-r137 { fill: #b93d5a } - .terminal-2491064415-r138 { fill: #bc4158 } - .terminal-2491064415-r139 { fill: #be4456 } - .terminal-2491064415-r140 { fill: #c14855 } - .terminal-2491064415-r141 { fill: #c75350 } - .terminal-2491064415-r142 { fill: #cb584d } - .terminal-2491064415-r143 { fill: #d46647 } - .terminal-2491064415-r144 { fill: #d96e44 } - .terminal-2491064415-r145 { fill: #de7640 } - .terminal-2491064415-r146 { fill: #e98738 } - .terminal-2491064415-r147 { fill: #ef8f34 } - .terminal-2491064415-r148 { fill: #fba22c } - .terminal-2491064415-r149 { fill: #faa02d } - .terminal-2491064415-r150 { fill: #ee8e35 } - .terminal-2491064415-r151 { fill: #e98539 } - .terminal-2491064415-r152 { fill: #e37d3d } - .terminal-2491064415-r153 { fill: #d86d44 } - .terminal-2491064415-r154 { fill: #d46548 } - .terminal-2491064415-r155 { fill: #cb584e } - .terminal-2491064415-r156 { fill: #c75250 } - .terminal-2491064415-r157 { fill: #c44c53 } - .terminal-2491064415-r158 { fill: #be4457 } - .terminal-2491064415-r159 { fill: #bd4357 } - .terminal-2491064415-r160 { fill: #c04755 } - .terminal-2491064415-r161 { fill: #c65051 } - .terminal-2491064415-r162 { fill: #ca564f } - .terminal-2491064415-r163 { fill: #d26349 } - .terminal-2491064415-r164 { fill: #d76a45 } - .terminal-2491064415-r165 { fill: #dc7242 } - .terminal-2491064415-r166 { fill: #e7833a } - .terminal-2491064415-r167 { fill: #ed8c36 } - .terminal-2491064415-r168 { fill: #f89e2e } - .terminal-2491064415-r169 { fill: #fda42b } - .terminal-2491064415-r170 { fill: #f19233 } - .terminal-2491064415-r171 { fill: #eb8937 } - .terminal-2491064415-r172 { fill: #e5803b } - .terminal-2491064415-r173 { fill: #db7043 } - .terminal-2491064415-r174 { fill: #d66846 } - .terminal-2491064415-r175 { fill: #cd5a4d } - .terminal-2491064415-r176 { fill: #c9544f } - .terminal-2491064415-r177 { fill: #bf4556 } - .terminal-2491064415-r178 { fill: #bd4258 } - .terminal-2491064415-r179 { fill: #ba3d5a } - .terminal-2491064415-r180 { fill: #b93c5b } - .terminal-2491064415-r181 { fill: #bb3f59 } - .terminal-2491064415-r182 { fill: #bc4258 } - .terminal-2491064415-r183 { fill: #c44e52 } - .terminal-2491064415-r184 { fill: #c85350 } - .terminal-2491064415-r185 { fill: #d0604a } - .terminal-2491064415-r186 { fill: #d56747 } - .terminal-2491064415-r187 { fill: #da6f43 } - .terminal-2491064415-r188 { fill: #e57f3c } - .terminal-2491064415-r189 { fill: #ea8838 } - .terminal-2491064415-r190 { fill: #be4556 } - .terminal-2491064415-r191 { fill: #ca574e } - .terminal-2491064415-r192 { fill: #d05f4a } - .terminal-2491064415-r193 { fill: #d56846 } - .terminal-2491064415-r194 { fill: #e0783f } - .terminal-2491064415-r195 { fill: #e47f3c } - .terminal-2491064415-r196 { fill: #f49731 } - .terminal-2491064415-r197 { fill: #f99f2e } - .terminal-2491064415-r198 { fill: #fba12c } - .terminal-2491064415-r199 { fill: #fda52b } - .terminal-2491064415-r200 { fill: #f89d2f } - .terminal-2491064415-r201 { fill: #f59930 } - .terminal-2491064415-r202 { fill: #ef8e35 } - .terminal-2491064415-r203 { fill: #eb8938 } - .terminal-2491064415-r204 { fill: #e27b3e } - .terminal-2491064415-r205 { fill: #dd7341 } - .terminal-2491064415-r206 { fill: #d86b45 } - .terminal-2491064415-r207 { fill: #c75251 } - .terminal-2491064415-r208 { fill: #cd5c4c } - .terminal-2491064415-r209 { fill: #d36448 } - .terminal-2491064415-r210 { fill: #de7441 } - .terminal-2491064415-r211 { fill: #e27c3d } - .terminal-2491064415-r212 { fill: #ef8f35 } - .terminal-2491064415-r213 { fill: #f29532 } - .terminal-2491064415-r214 { fill: #f89d2e } - .terminal-2491064415-r215 { fill: #f99e2e } - .terminal-2491064415-r216 { fill: #f69a30 } - .terminal-2491064415-r217 { fill: #f09134 } - .terminal-2491064415-r218 { fill: #ec8b36 } - .terminal-2491064415-r219 { fill: #e47e3c } - .terminal-2491064415-r220 { fill: #df7740 } - .terminal-2491064415-r221 { fill: #cf5e4b } - .terminal-2491064415-r222 { fill: #be4357 } - .terminal-2491064415-r223 { fill: #d1614a } - .terminal-2491064415-r224 { fill: #db7142 } - .terminal-2491064415-r225 { fill: #e0793f } - .terminal-2491064415-r226 { fill: #ed8d36 } - .terminal-2491064415-r227 { fill: #f79c2f } - .terminal-2491064415-r228 { fill: #f99f2d } - .terminal-2491064415-r229 { fill: #fca42b } - .terminal-2491064415-r230 { fill: #fa9f2d } - .terminal-2491064415-r231 { fill: #f29333 } - .terminal-2491064415-r232 { fill: #e6813b } - .terminal-2491064415-r233 { fill: #e17a3e } - .terminal-2491064415-r234 { fill: #d16249 } - .terminal-2491064415-r235 { fill: #cc594d } - .terminal-2491064415-r236 { fill: #153954 } - .terminal-2491064415-r237 { fill: #133e5f } - .terminal-2491064415-r238 { fill: #0f4974 } - .terminal-2491064415-r239 { fill: #0e4e7f } - .terminal-2491064415-r240 { fill: #0c5389 } - .terminal-2491064415-r241 { fill: #095c9c } - .terminal-2491064415-r242 { fill: #0861a5 } - .terminal-2491064415-r243 { fill: #0568b5 } - .terminal-2491064415-r244 { fill: #046cbc } - .terminal-2491064415-r245 { fill: #036fc2 } + .terminal-2818358681-r1 { fill: #e1e1e1 } + .terminal-2818358681-r2 { fill: #c5c8c6 } + .terminal-2818358681-r3 { fill: #004578 } + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + HorizontalRulesApp + + + + + + + + + +                         solid (default)                          + + ──────────────────────────────────────────────────────────────── + +                              heavy                               + + ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +                              thick                               + + ████████████████████████████████████████████████████████████████ + +                              dashed                              + + ╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍ + +                              double                              + + ════════════════════════════════════════════════════════════════ + +                              ascii                               + + ---------------------------------------------------------------- + + + + + + ''' +# --- +# name: test_rule_vertical_rules + ''' + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + VerticalRulesApp + + + + + + + + + + + + solid heavy thick dasheddoubleascii | + | + | + | + | + | + | + | + | + | + | + | + | + | + | + | + | + | + | + + + + + + + + ''' +# --- +# name: test_scoped_css + ''' + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + MyApp + + + + + + + + + + ────────────────────────────────────────────────────────────────────────────── + ─── + foo + ─── + ─── + bar + ─── + ────────────────────────────────────────────────────────────────────────────── + ────────────────────────────────────────────────────────────────────────────── + ─── + foo + ─── + ─── + bar + ─── + ────────────────────────────────────────────────────────────────────────────── + I should not be styled + + + + + + + + + + + + ''' +# --- +# name: test_screen_switch + ''' + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ModalApp + + + + + + + + + + ModalApp + B + + + + + + + + + + + + + + + + + + + + + +  A  Push screen A  + + + + + ''' +# --- +# name: test_scroll_to + ''' + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ScrollOffByOne + + + + + + + + + + X 43 + ▁▁▁▁▁▁▁▁ + ▔▔▔▔▔▔▔▔ + X 44 + ▁▁▁▁▁▁▁▁ + ▔▔▔▔▔▔▔▔ + X 45 + ▁▁▁▁▁▁▁▁ + ▔▔▔▔▔▔▔▔ + X 46 + ▁▁▁▁▁▁▁▁▃▃ + ▔▔▔▔▔▔▔▔ + X 47▂▂ + ▁▁▁▁▁▁▁▁ + ▔▔▔▔▔▔▔▔ + X 48 + ▁▁▁▁▁▁▁▁ + ▔▔▔▔▔▔▔▔ + X 49 + ▁▁▁▁▁▁▁▁ + ▔▔▔▔▔▔▔▔ + X 50 + ▁▁▁▁▁▁▁▁ + ▔▔▔▔▔▔▔▔ + + + + + + ''' +# --- +# name: test_scroll_to_center + ''' + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + MyApp + + + + + + + + + + SPAM + SPAM + SPAM + ──────────────────────────────────────────────────────────────────────────── + SPAM + SPAM + SPAM + SPAM + SPAM + SPAM▄▄ + SPAM + SPAM + ──────────────────────────────────────────────────────────────────────── + @@@@@@@@@@@@@@@@@@@@@@@@@@@@@@>>bullseye<<@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@ + ▇▇ + ▄▄ + + + + + + + + ──────────────────────────────────────────────────────────────────────────── + + + + + ''' +# --- +# name: test_scroll_visible + ''' + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + MyApp + + + + + + + + + + | + | + | + | + | + | + | + | + | + | + | + | + | + | + | + | + | + | + |▆▆ + | + | + | + | + SHOULD BE VISIBLE + + + + + ''' +# --- +# name: test_scrollbar_thumb_height + ''' + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ScrollViewTester + + + + + + + + + + ScrollViewTester +  1 ────────────────────────────────────────────────────────────────────────── + Welcome to line 980 + Welcome to line 981 + Welcome to line 982 + Welcome to line 983 + Welcome to line 984 + Welcome to line 985 + Welcome to line 986 + Welcome to line 987 + Welcome to line 988 + Welcome to line 989 + Welcome to line 990 + Welcome to line 991 + Welcome to line 992 + Welcome to line 993 + Welcome to line 994 + Welcome to line 995 + Welcome to line 996 + Welcome to line 997 + Welcome to line 998 + Welcome to line 999 + ────────────────────────────────────────────────────────────────────────────── + + + + + + ''' +# --- +# name: test_select + ''' + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + SelectApp + + + + + + + + + + SelectApp + + + ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ + Select + ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ + + + + + + + + + + + + + + + + + + + + + + + ''' +# --- +# name: test_select_expanded + ''' + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + SelectApp + + + + + + + + + + SelectApp + + + ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ + Select + ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ + ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ + Select + I must not fear. + Fear is the mind-killer. + Fear is the little-death that brings total  + obliteration. + I will face my fear. + I will permit it to pass over me and through me. + ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ + + + + + + + + + + + + + + ''' +# --- +# name: test_select_expanded_changed + ''' + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + SelectApp + + + + + + + + + + I must not fear. + + + ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ + I must not fear. + ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ + + + + + + + + + + + + + + + + + + + + + + + ''' +# --- +# name: test_select_from_values_expanded + ''' + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + SelectApp + + + + + + + + + + SelectApp + + + ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ + Select + ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ + ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ + Select + I must not fear. + Fear is the mind-killer. + Fear is the little-death that brings total  + obliteration. + I will face my fear. + I will permit it to pass over me and through me. + ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ + + + + + + + + + + + + + + ''' +# --- +# name: test_select_no_blank_has_default_value + ''' + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + SelectApp + + + + + + + + + + SelectApp + + + ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ + I must not fear. + ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ + + + + + + + + + + + + + + + + + + + + + + + ''' +# --- +# name: test_select_rebuild + ''' + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + SelectRebuildApp + + + + + + + + + + ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ + Select + ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ + ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ + Select + This + Should + Be + What + Goes + Into + The + Snapshit + ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ + + + + + + + + + + + + + + + ''' +# --- +# name: test_select_set_options + ''' + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + SelectApp + + + + + + + + + + SelectApp + + + ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ + Twinkle, twinkle, little star, + ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ + + + + + + + + + + + + + + + + + + + + + + + ''' +# --- +# name: test_selection_list_selected + ''' + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 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 + + ────────────────────────────── + + + + + + + + + + + + + ''' +# --- +# name: test_selection_list_selections + ''' + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + SelectionListApp + + + + + + + + + + SelectionListApp + + +  Shall we play some games? ────────────────────────────────── + + XFalken's Maze + XBlack Jack + XGin Rummy + XHearts + XBridge + XCheckers + XChess + XPoker + XFighter Combat + + + + + + + ────────────────────────────────────────────────────────────── + + + + + + + + ''' +# --- +# name: test_selection_list_tuples + ''' + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + SelectionListApp + + + + + + + + + + SelectionListApp + + +  Shall we play some games? ────────────────────────────────── + + XFalken's Maze + XBlack Jack + XGin Rummy + XHearts + XBridge + XCheckers + XChess + XPoker + XFighter Combat + + + + + + + ────────────────────────────────────────────────────────────── + + + + + + + + ''' +# --- +# name: test_sparkline_component_classes_colors + ''' + + + - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + SparklineColorsApp + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ''' +# --- +# name: test_sparkline_render + ''' + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + SparklineSummaryFunctionApp + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ''' +# --- +# name: test_switches + ''' + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + SwitchApp + + + + + + + + + + + + + + Example switches + + + ▔▔▔▔▔▔▔▔ + off:      + ▁▁▁▁▁▁▁▁ + ▔▔▔▔▔▔▔▔ + on:       + ▁▁▁▁▁▁▁▁ + ▔▔▔▔▔▔▔▔ + focused:  + ▁▁▁▁▁▁▁▁ + ▔▔▔▔▔▔▔▔ + custom:   + ▁▁▁▁▁▁▁▁ + + + + + + + + + + ''' +# --- +# name: test_tabbed_content + ''' + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + TabbedApp + + + + + + + + + + + LetoJessicaPaul + ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ + + Lady Jessica + + ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ + Bene Gesserit and concubine of Leto, and mother of Paul and Alia. + + + + PaulAlia + ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + First child + + + + + + +  L  Leto  J  Jessica  P  Paul  + + + + + ''' +# --- +# name: test_tabbed_content_with_modified_tabs + ''' + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + FiddleWithTabsApp + + + + + + + + + + + Tab 1Tab 2Tab 4Tab 5 + ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ + Button + ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ + + + + + + + + + + + + + + + + + + + + + + ''' +# --- +# name: test_table_markup + ''' + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + TableStaticApp + + + + + + + + + + ┏━━━━━━━━━━━━━━┳━━━━━━━━┳━━━━━━━━━━━┓ + FooBar   baz       + ┡━━━━━━━━━━━━━━╇━━━━━━━━╇━━━━━━━━━━━┩ + Hello World!ItalicUnderline + └──────────────┴────────┴───────────┘ + + + + + + + + + + + + + + + + + + + + + + + + ''' +# --- +# name: test_tabs_invalidate + ''' + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + TabApp + + + + + + + + + + + Tab 1Tab 2 + ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + ────────────────────────────────────────────────────────────────────────────── + + world + + ────────────────────────────────────────────────────────────────────────────── + + + + + + + + + + + + + + + + + + + + + ''' +# --- +# 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   + + + + + + ''' +# --- +# 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   + + + + + + ''' +# --- +# 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   + + + + + + ''' +# --- +# 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.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   + + + + + + ''' +# --- +# name: test_text_area_language_rendering[python] + ''' + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 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[regex] + ''' + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 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   + + + + + + ''' +# --- +# name: test_text_area_language_rendering[sql] + ''' + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 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   + + + + + + ''' +# --- +# name: test_text_area_language_rendering[toml] + ''' + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 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[yaml] + ''' + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 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   + + + + + + ''' +# --- +# name: test_text_area_selection_rendering[selection0] + ''' + + + + + + + + + + + + + + + + + + + + + + + TextAreaSnapshot + + + + + + + + + + I am a line. + ▌                     + I am another line.             + + I am the final line.  + + + + + ''' +# --- +# name: test_text_area_selection_rendering[selection1] + ''' + + + + + + + - - - - - - - - - - - + + - - + + - - + + - - + + - - + + + TextAreaSnapshot + + + + + + + + + + I am a line. + ▌                     + I am another line.    + + I am the final line.  + + + + + ''' +# --- +# name: test_text_area_selection_rendering[selection2] + ''' + + + + + + + + + + - - + + - - + + - - + + - - + + + TextAreaSnapshot + + + + + + + + + + I am a line. + ▌                     + I am another line. + ▌                     + I am the final line.  + + + + + ''' +# --- +# name: test_text_area_selection_rendering[selection3] + ''' + + + + + + + + + + - - + + - - + + - - + + - - + + + TextAreaSnapshot + + + + + + + + + + I am a line. + ▌                     + I am another line. + ▌                     + I am the final line. + + + + + ''' +# --- +# name: test_text_area_selection_rendering[selection4] + ''' + + + + + + + + + + - - + + - - + + - - + + - - + + + TextAreaSnapshot + + + + + + + + + + I am a line.          + + I am another line.    + + I am the final line.  + + + + + ''' +# --- +# name: test_text_area_selection_rendering[selection5] + ''' + + + + + + + + + + - - + + - - + + - - + + - SparklineColorsApp + TextAreaSnapshot - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + I am a line.          + + I am another line.             + + I am the final line.  ''' # --- -# name: test_sparkline_render +# name: test_text_area_themes[dracula] ''' - + - - + + - - - - - - - - - - - - - - - - - + + - - + + - - + + - - + + - - + + - - + + - - + + + TextAreaSnapshot + + + + + + + + + + 1  defhello(name): + 2      x =123 + 3  whilenotFalse:            + 4  print("hello "+ name)  + 5  continue + 6   + + + + + + ''' +# --- +# name: test_text_area_themes[github_light] + ''' + + + + + + + + + + - - + + - - + + - - + + - - + + - - + + - - + + + TextAreaSnapshot + + + + + + + + + + 1  defhello(name): + 2  x=123 + 3  whilenotFalse:            + 4  print("hello "+name + 5  continue + 6   + + + + + + ''' +# --- +# name: test_text_area_themes[monokai] + ''' + + + + + + + + + + - - + + - - + + - - + + - - + + - - + + - SparklineSummaryFunctionApp + TextAreaSnapshot - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + 1  defhello(name): + 2      x =123 + 3  whilenotFalse:            + 4  print("hello "+ name)  + 5  continue + 6   @@ -28417,9 +36025,9 @@ ''' # --- -# name: test_switches +# name: test_text_area_themes[vscode_dark] ''' - + - - + + - - - - - - - - + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + - - + + - - + + - - + + - - + + - SwitchApp + TextAreaSnapshot - - - - - - - - Example switches - - - ▔▔▔▔▔▔▔▔ - off:      - ▁▁▁▁▁▁▁▁ - ▔▔▔▔▔▔▔▔ - on:       - ▁▁▁▁▁▁▁▁ - ▔▔▔▔▔▔▔▔ - focused:  - ▁▁▁▁▁▁▁▁ - ▔▔▔▔▔▔▔▔ - custom:   - ▁▁▁▁▁▁▁▁ - - - - + + + + 1  defhello(name): + 2      x =123 + 3  whilenotFalse:            + 4  print("hello "+ name)  + 5  continue + 6   @@ -28577,7 +36119,7 @@ ''' # --- -# name: test_tabbed_content +# name: test_text_log_blank_write ''' @@ -28600,148 +36142,139 @@ font-weight: 700; } - .terminal-3875007613-matrix { + .terminal-2946008658-matrix { font-family: Fira Code, monospace; font-size: 20px; line-height: 24.4px; font-variant-east-asian: full-width; } - .terminal-3875007613-title { + .terminal-2946008658-title { font-size: 18px; font-weight: bold; font-family: arial; } - .terminal-3875007613-r1 { fill: #c5c8c6 } - .terminal-3875007613-r2 { fill: #737373 } - .terminal-3875007613-r3 { fill: #e1e1e1;font-weight: bold } - .terminal-3875007613-r4 { fill: #474747 } - .terminal-3875007613-r5 { fill: #0178d4 } - .terminal-3875007613-r6 { fill: #121212 } - .terminal-3875007613-r7 { fill: #0053aa } - .terminal-3875007613-r8 { fill: #dde8f3;font-weight: bold } - .terminal-3875007613-r9 { fill: #e1e1e1 } - .terminal-3875007613-r10 { fill: #323232 } - .terminal-3875007613-r11 { fill: #ddedf9 } + .terminal-2946008658-r1 { fill: #e1e1e1 } + .terminal-2946008658-r2 { fill: #c5c8c6 } - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - TabbedApp + RichLogApp - - - - - LetoJessicaPaul - ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ - - ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ - - Lady Jessica - - ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ - Bene Gesserit and concubine of Leto, and mother of Paul and Alia. - - - - PaulAlia - ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ - - First child - - - - - - -  L  Leto  J  Jessica  P  Paul  + + + + Hello + + World + + + + + + + + + + + + + + + + + + + + + ''' # --- -# name: test_tabbed_content_with_modified_tabs +# name: test_textual_dev_border_preview ''' @@ -28764,147 +36297,146 @@ font-weight: 700; } - .terminal-3132124347-matrix { + .terminal-1538625093-matrix { font-family: Fira Code, monospace; font-size: 20px; line-height: 24.4px; font-variant-east-asian: full-width; } - .terminal-3132124347-title { + .terminal-1538625093-title { font-size: 18px; font-weight: bold; font-family: arial; } - .terminal-3132124347-r1 { fill: #c5c8c6 } - .terminal-3132124347-r2 { fill: #484848;font-weight: bold } - .terminal-3132124347-r3 { fill: #484848 } - .terminal-3132124347-r4 { fill: #737373 } - .terminal-3132124347-r5 { fill: #474747 } - .terminal-3132124347-r6 { fill: #0178d4 } - .terminal-3132124347-r7 { fill: #a32327 } - .terminal-3132124347-r8 { fill: #f09d9e;font-weight: bold } - .terminal-3132124347-r9 { fill: #810000 } - .terminal-3132124347-r10 { fill: #e1e1e1 } + .terminal-1538625093-r1 { fill: #454a50 } + .terminal-1538625093-r2 { fill: #e1e1e1 } + .terminal-1538625093-r3 { fill: #c5c8c6 } + .terminal-1538625093-r4 { fill: #24292f;font-weight: bold } + .terminal-1538625093-r5 { fill: #000000 } + .terminal-1538625093-r6 { fill: #fea62b } + .terminal-1538625093-r7 { fill: #e2e3e3;font-weight: bold } + .terminal-1538625093-r8 { fill: #e2e3e3 } + .terminal-1538625093-r9 { fill: #14191f } - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - FiddleWithTabsApp + BorderApp - - - - - Tab 1Tab 2Tab 4Tab 5 - ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ - - ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ -  Button  - ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ - - - - - - - - - - - - - - - - - + + + + ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ + ascii + ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ + ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔+------------------- ascii --------------------+ + blank|| + ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁|| + ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔|I must not fear.| + dashed|Fear is the mind-killer.| + ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁|Fear is the little-death that brings | + ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔|total obliteration.| + double|I will face my fear.| + ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▅▅|I will permit it to pass over me and | + ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔|through me.| + heavy|And when it has gone past, I will turn| + ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁|the inner eye to see its path.| + ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔|Where the fear has gone there will be | + hidden|nothing. Only I will remain.| + ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁|| + ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔|| + hkey+----------------------------------------------+ + ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ + ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ + inner + ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ ''' # --- -# name: test_table_markup +# name: test_textual_dev_colors_preview ''' @@ -28927,144 +36459,159 @@ font-weight: 700; } - .terminal-2727430444-matrix { + .terminal-3186860707-matrix { font-family: Fira Code, monospace; font-size: 20px; line-height: 24.4px; font-variant-east-asian: full-width; } - .terminal-2727430444-title { + .terminal-3186860707-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-3186860707-r1 { fill: #c5c8c6 } + .terminal-3186860707-r2 { fill: #e1e1e1 } + .terminal-3186860707-r3 { fill: #e1e1e1;font-weight: bold } + .terminal-3186860707-r4 { fill: #737373 } + .terminal-3186860707-r5 { fill: #474747 } + .terminal-3186860707-r6 { fill: #0178d4 } + .terminal-3186860707-r7 { fill: #454a50 } + .terminal-3186860707-r8 { fill: #e0e0e0 } + .terminal-3186860707-r9 { fill: #e2e3e3;font-weight: bold } + .terminal-3186860707-r10 { fill: #000000 } + .terminal-3186860707-r11 { fill: #1e1e1e } + .terminal-3186860707-r12 { fill: #dde0e6 } + .terminal-3186860707-r13 { fill: #99a1b3 } + .terminal-3186860707-r14 { fill: #dde2e8 } + .terminal-3186860707-r15 { fill: #99a7b9 } + .terminal-3186860707-r16 { fill: #dde4ea } + .terminal-3186860707-r17 { fill: #99adc1 } + .terminal-3186860707-r18 { fill: #dde6ed } + .terminal-3186860707-r19 { fill: #99b4c9 } + .terminal-3186860707-r20 { fill: #23568b } + .terminal-3186860707-r21 { fill: #dde8f3;font-weight: bold } + .terminal-3186860707-r22 { fill: #ddedf9 } - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - TableStaticApp + ColorsApp - - - - ┏━━━━━━━━━━━━━━┳━━━━━━━━┳━━━━━━━━━━━┓ - FooBar   baz       - ┡━━━━━━━━━━━━━━╇━━━━━━━━╇━━━━━━━━━━━┩ - Hello World!ItalicUnderline - └──────────────┴────────┴───────────┘ - - - - - - - - - - - - - - - - - - - + + + + + Theme ColorsNamed Colors + ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ + primary + ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ + ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ + secondary"primary" + ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ + ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ + background$primary-darken-3$t + ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ + ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ + primary-background$primary-darken-2$t + ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ + ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ + secondary-background$primary-darken-1$t + ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ + ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ + surface$primary$t + ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ + +  D  Toggle dark mode  ''' # --- -# name: test_tabs_invalidate +# name: test_textual_dev_easing_preview ''' @@ -29087,144 +36634,155 @@ font-weight: 700; } - .terminal-2444717977-matrix { + .terminal-2839369084-matrix { font-family: Fira Code, monospace; font-size: 20px; line-height: 24.4px; font-variant-east-asian: full-width; } - .terminal-2444717977-title { + .terminal-2839369084-title { font-size: 18px; font-weight: bold; font-family: arial; } - .terminal-2444717977-r1 { fill: #c5c8c6 } - .terminal-2444717977-r2 { fill: #737373 } - .terminal-2444717977-r3 { fill: #e1e1e1;font-weight: bold } - .terminal-2444717977-r4 { fill: #474747 } - .terminal-2444717977-r5 { fill: #0178d4 } - .terminal-2444717977-r6 { fill: #0000ff } - .terminal-2444717977-r7 { fill: #e1e1e1 } + .terminal-2839369084-r1 { fill: #454a50 } + .terminal-2839369084-r2 { fill: #e1e1e1 } + .terminal-2839369084-r3 { fill: #c5c8c6 } + .terminal-2839369084-r4 { fill: #24292f;font-weight: bold } + .terminal-2839369084-r5 { fill: #262626 } + .terminal-2839369084-r6 { fill: #e2e2e2 } + .terminal-2839369084-r7 { fill: #000000 } + .terminal-2839369084-r8 { fill: #e3e3e3 } + .terminal-2839369084-r9 { fill: #e2e3e3;font-weight: bold } + .terminal-2839369084-r10 { fill: #14191f } + .terminal-2839369084-r11 { fill: #b93c5b } + .terminal-2839369084-r12 { fill: #121212 } + .terminal-2839369084-r13 { fill: #1e1e1e } + .terminal-2839369084-r14 { fill: #fea62b } + .terminal-2839369084-r15 { fill: #211505;font-weight: bold } + .terminal-2839369084-r16 { fill: #211505 } + .terminal-2839369084-r17 { fill: #dde8f3;font-weight: bold } + .terminal-2839369084-r18 { fill: #ddedf9 } - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - TabApp + EasingApp - - - - - Tab 1Tab 2 - ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ - ────────────────────────────────────────────────────────────────────────────── - - world - - ────────────────────────────────────────────────────────────────────────────── - - - - - - - - - - - - - - - - + + + + ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ + round▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ + ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁Animation Duration:1.0 + ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ + out_sine + ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ + ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ + out_quint + ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁Welcome to Textual! + ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ + out_quartI must not fear. + ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁Fear is the  + ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔mind-killer. + out_quadFear is the  + ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁little-death that  + ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔brings total  + out_expoobliteration. + ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁I will face my fear. + ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔I will permit it to  + out_elasticpass over me and  + ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁through me. + ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔And when it has gone  + out_cubic + ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ CTRL+P  Focus: Duration Input  CTRL+B  Toggle Dark  ''' # --- -# name: test_text_log_blank_write +# name: test_textual_dev_keys_preview ''' @@ -29247,139 +36805,153 @@ font-weight: 700; } - .terminal-2946008658-matrix { + .terminal-4085160594-matrix { font-family: Fira Code, monospace; font-size: 20px; line-height: 24.4px; font-variant-east-asian: full-width; } - .terminal-2946008658-title { + .terminal-4085160594-title { font-size: 18px; font-weight: bold; font-family: arial; } - .terminal-2946008658-r1 { fill: #e1e1e1 } - .terminal-2946008658-r2 { fill: #c5c8c6 } + .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 } - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - RichLogApp + Textual Keys - - - - Hello - - World - - - - - - - - - - - - - - - - - - - - - + + + + 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 + ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ ''' # --- -# name: test_textual_dev_border_preview +# name: test_tooltips_in_compound_widgets ''' @@ -29402,148 +36974,142 @@ font-weight: 700; } - .terminal-4254142758-matrix { + .terminal-3455460968-matrix { font-family: Fira Code, monospace; font-size: 20px; line-height: 24.4px; font-variant-east-asian: full-width; } - .terminal-4254142758-title { + .terminal-3455460968-title { font-size: 18px; font-weight: bold; font-family: arial; } - .terminal-4254142758-r1 { fill: #05080f } - .terminal-4254142758-r2 { fill: #e1e1e1 } - .terminal-4254142758-r3 { fill: #c5c8c6 } - .terminal-4254142758-r4 { fill: #1e2226;font-weight: bold } - .terminal-4254142758-r5 { fill: #35393d } - .terminal-4254142758-r6 { fill: #454a50 } - .terminal-4254142758-r7 { fill: #fea62b } - .terminal-4254142758-r8 { fill: #e2e3e3;font-weight: bold } - .terminal-4254142758-r9 { fill: #000000 } - .terminal-4254142758-r10 { fill: #e2e3e3 } - .terminal-4254142758-r11 { fill: #14191f } + .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 } - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - BorderApp + TooltipApp - - - - ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ -  ascii  - ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ - ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔+------------------- ascii --------------------+ -  blank || - ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁|| - ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔|I must not fear.| -  dashed |Fear is the mind-killer.| - ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁|Fear is the little-death that brings | - ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔|total obliteration.| -  double |I will face my fear.| - ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▅▅|I will permit it to pass over me and | - ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔|through me.| -  heavy |And when it has gone past, I will turn| - ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁|the inner eye to see its path.| - ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔|Where the fear has gone there will be | -  hidden |nothing. Only I will remain.| - ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁|| - ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔|| -  hkey +----------------------------------------------+ - ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ - ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ -  inner  - ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ + + + + ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━10%--:--:-- + + Hello, Tooltip! + + + + + + + + + + + + + + + + + + + + + ''' # --- -# name: test_textual_dev_colors_preview +# name: test_tree_clearing_and_expansion ''' @@ -29566,159 +37132,141 @@ font-weight: 700; } - .terminal-1131328884-matrix { + .terminal-3765519511-matrix { font-family: Fira Code, monospace; font-size: 20px; line-height: 24.4px; font-variant-east-asian: full-width; } - .terminal-1131328884-title { + .terminal-3765519511-title { font-size: 18px; font-weight: bold; font-family: arial; } - .terminal-1131328884-r1 { fill: #c5c8c6 } - .terminal-1131328884-r2 { fill: #e1e1e1;font-weight: bold } - .terminal-1131328884-r3 { fill: #737373 } - .terminal-1131328884-r4 { fill: #474747 } - .terminal-1131328884-r5 { fill: #0178d4 } - .terminal-1131328884-r6 { fill: #454a50 } - .terminal-1131328884-r7 { fill: #e1e1e1 } - .terminal-1131328884-r8 { fill: #e0e0e0 } - .terminal-1131328884-r9 { fill: #e2e3e3;font-weight: bold } - .terminal-1131328884-r10 { fill: #14191f } - .terminal-1131328884-r11 { fill: #000000 } - .terminal-1131328884-r12 { fill: #1e1e1e } - .terminal-1131328884-r13 { fill: #dde0e6 } - .terminal-1131328884-r14 { fill: #99a1b3 } - .terminal-1131328884-r15 { fill: #dde2e8 } - .terminal-1131328884-r16 { fill: #99a7b9 } - .terminal-1131328884-r17 { fill: #dde4ea } - .terminal-1131328884-r18 { fill: #99adc1 } - .terminal-1131328884-r19 { fill: #dde6ed } - .terminal-1131328884-r20 { fill: #99b4c9 } - .terminal-1131328884-r21 { fill: #dde8f3;font-weight: bold } - .terminal-1131328884-r22 { fill: #ddedf9 } + .terminal-3765519511-r1 { fill: #e2e3e3 } + .terminal-3765519511-r2 { fill: #211505;font-weight: bold } + .terminal-3765519511-r3 { fill: #1a1000;font-weight: bold } + .terminal-3765519511-r4 { fill: #c5c8c6 } - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - ColorsApp + TreeClearingSnapshotApp - - - - - Theme ColorsNamed Colors - ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ - - ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ -  primary ▇▇ - ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ - ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ -  secondary "primary" - ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ - ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ -  background $primary-darken-3$t - ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ - ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ -  primary-background $primary-darken-2$t - ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ - ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▆▆ -  secondary-background $primary-darken-1$t - ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ - ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ -  surface $primary$t - ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ - ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ -  D  Toggle dark mode  + + + + ▼ Left▶ Right + + + + + + + + + + + + + + + + + + + + + + + ''' # --- -# name: test_textual_dev_easing_preview +# name: test_tree_example ''' @@ -29741,155 +37289,141 @@ font-weight: 700; } - .terminal-456227705-matrix { + .terminal-3137592172-matrix { font-family: Fira Code, monospace; font-size: 20px; line-height: 24.4px; font-variant-east-asian: full-width; } - .terminal-456227705-title { + .terminal-3137592172-title { font-size: 18px; font-weight: bold; font-family: arial; } - .terminal-456227705-r1 { fill: #454a50 } - .terminal-456227705-r2 { fill: #e1e1e1 } - .terminal-456227705-r3 { fill: #c5c8c6 } - .terminal-456227705-r4 { fill: #24292f;font-weight: bold } - .terminal-456227705-r5 { fill: #262626 } - .terminal-456227705-r6 { fill: #000000 } - .terminal-456227705-r7 { fill: #e2e2e2 } - .terminal-456227705-r8 { fill: #e3e3e3 } - .terminal-456227705-r9 { fill: #e2e3e3;font-weight: bold } - .terminal-456227705-r10 { fill: #14191f } - .terminal-456227705-r11 { fill: #b93c5b } - .terminal-456227705-r12 { fill: #121212 } - .terminal-456227705-r13 { fill: #1e1e1e } - .terminal-456227705-r14 { fill: #fea62b } - .terminal-456227705-r15 { fill: #211505;font-weight: bold } - .terminal-456227705-r16 { fill: #211505 } - .terminal-456227705-r17 { fill: #dde8f3;font-weight: bold } - .terminal-456227705-r18 { fill: #ddedf9 } + .terminal-3137592172-r1 { fill: #e2e3e3 } + .terminal-3137592172-r2 { fill: #211505;font-weight: bold } + .terminal-3137592172-r3 { fill: #c5c8c6 } + .terminal-3137592172-r4 { fill: #fea62b;font-weight: bold } - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - EasingApp + TreeApp - - - - ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ -  round ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ - ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁Animation Duration:1.0 - ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ -  out_sine  - ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ - ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ -  out_quint  - ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁Welcome to Textual! - ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ -  out_quart I must not fear. - ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁Fear is the  - ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔mind-killer. -  out_quad Fear is the  - ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁little-death that  - ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔brings total  -  out_expo obliteration. - ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁I will face my fear. - ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔I will permit it to  -  out_elastic pass over me and  - ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁through me. - ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔And when it has gone  -  out_cubic  - ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ CTRL+P  Focus: Duration Input  CTRL+B  Toggle Dark  + + + + ▼ Dune + ┗━━ ▼ Characters + ┣━━ Paul + ┣━━ Jessica + ┗━━ Chani + + + + + + + + + + + + + + + + + + + ''' # --- -# name: test_textual_dev_keys_preview +# name: test_unscoped_css ''' @@ -29912,153 +37446,141 @@ font-weight: 700; } - .terminal-391476017-matrix { + .terminal-699265099-matrix { font-family: Fira Code, monospace; font-size: 20px; line-height: 24.4px; font-variant-east-asian: full-width; } - .terminal-391476017-title { + .terminal-699265099-title { font-size: 18px; font-weight: bold; font-family: arial; } - .terminal-391476017-r1 { fill: #c5c8c6 } - .terminal-391476017-r2 { fill: #e3e3e3 } - .terminal-391476017-r3 { fill: #e1e1e1 } - .terminal-391476017-r4 { fill: #e1e1e1;text-decoration: underline; } - .terminal-391476017-r5 { fill: #e1e1e1;font-weight: bold } - .terminal-391476017-r6 { fill: #e1e1e1;font-style: italic; } - .terminal-391476017-r7 { fill: #98729f;font-weight: bold } - .terminal-391476017-r8 { fill: #d0b344 } - .terminal-391476017-r9 { fill: #98a84b } - .terminal-391476017-r10 { fill: #00823d;font-style: italic; } - .terminal-391476017-r11 { fill: #ffcf56 } - .terminal-391476017-r12 { fill: #e76580 } - .terminal-391476017-r13 { fill: #fea62b;font-weight: bold } - .terminal-391476017-r14 { fill: #f5e5e9;font-weight: bold } - .terminal-391476017-r15 { fill: #b86b00 } - .terminal-391476017-r16 { fill: #780028 } + .terminal-699265099-r1 { fill: #ff00ff } + .terminal-699265099-r2 { fill: #c5c8c6 } + .terminal-699265099-r3 { fill: #008000 } + .terminal-699265099-r4 { fill: #e1e1e1 } - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - Textual Keys + MyApp - - - - 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) - - - - - - - - - - - - - - ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ -  Clear  Quit  - ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ + + + + ────────────────────────────────────────────────────────────────────────────── + ─── + foo + ─── + ─── + bar + ─── + ────────────────────────────────────────────────────────────────────────────── + ────────────────────────────────────────────────────────────────────────────── + ─── + foo + ─── + ─── + bar + ─── + ────────────────────────────────────────────────────────────────────────────── + ─────────────────── + This will be styled + ─────────────────── + + + + + ''' # --- -# name: test_tooltips_in_compound_widgets +# name: test_vertical_layout ''' @@ -30081,142 +37603,140 @@ font-weight: 700; } - .terminal-3455460968-matrix { + .terminal-452684828-matrix { font-family: Fira Code, monospace; font-size: 20px; line-height: 24.4px; font-variant-east-asian: full-width; } - .terminal-3455460968-title { + .terminal-452684828-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-452684828-r1 { fill: #008000 } + .terminal-452684828-r2 { fill: #c5c8c6 } + .terminal-452684828-r3 { fill: #e1e1e1 } - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - - - - - - - - - TooltipApp - - - - - - - - - - ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━10%--:--:-- - - Hello, Tooltip! - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + VerticalLayoutExample + + + + + + + + + + ────────────────────────────────────────────────────────────────────────────── + One + + + + + + ────────────────────────────────────────────────────────────────────────────── + ────────────────────────────────────────────────────────────────────────────── + Two + + + + + + ────────────────────────────────────────────────────────────────────────────── + ────────────────────────────────────────────────────────────────────────────── + Three + + + + + + ────────────────────────────────────────────────────────────────────────────── ''' # --- -# name: test_tree_example +# name: test_vertical_max_height ''' @@ -30239,141 +37759,141 @@ font-weight: 700; } - .terminal-3137592172-matrix { + .terminal-4274547359-matrix { font-family: Fira Code, monospace; font-size: 20px; line-height: 24.4px; font-variant-east-asian: full-width; } - .terminal-3137592172-title { + .terminal-4274547359-title { font-size: 18px; font-weight: bold; font-family: arial; } - .terminal-3137592172-r1 { fill: #e2e3e3 } - .terminal-3137592172-r2 { fill: #211505;font-weight: bold } - .terminal-3137592172-r3 { fill: #c5c8c6 } - .terminal-3137592172-r4 { fill: #fea62b;font-weight: bold } + .terminal-4274547359-r1 { fill: #ffffff } + .terminal-4274547359-r2 { fill: #c5c8c6 } + .terminal-4274547359-r3 { fill: #e8e0e7 } + .terminal-4274547359-r4 { fill: #eae3e5 } - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - TreeApp + VerticalApp - - - - ▼ Dune - ┗━━ ▼ Characters - ┣━━ Paul - ┣━━ Jessica - ┗━━ Chani - - - - - - - - - - - - - - - - - - - + + + + ────────────────────────────────────────────────────────────────────────────── + + + + + + #top + + + + + + + ────────────────────────────────────────────────────────────────────────────── + ────────────────────────────────────────────────────────────────────────────── + + + + #bottom + + + + + ────────────────────────────────────────────────────────────────────────────── ''' # --- -# name: test_vertical_layout +# name: test_vertical_min_height ''' @@ -30396,133 +37916,134 @@ font-weight: 700; } - .terminal-452684828-matrix { + .terminal-1897622187-matrix { font-family: Fira Code, monospace; font-size: 20px; line-height: 24.4px; font-variant-east-asian: full-width; } - .terminal-452684828-title { + .terminal-1897622187-title { font-size: 18px; font-weight: bold; font-family: arial; } - .terminal-452684828-r1 { fill: #008000 } - .terminal-452684828-r2 { fill: #c5c8c6 } - .terminal-452684828-r3 { fill: #e1e1e1 } + .terminal-1897622187-r1 { fill: #ffffff } + .terminal-1897622187-r2 { fill: #c5c8c6 } + .terminal-1897622187-r3 { fill: #e8e0e7 } + .terminal-1897622187-r4 { fill: #eae3e5 } - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - VerticalLayoutExample + VerticalApp - - - - ────────────────────────────────────────────────────────────────────────────── - One - - - - - - ────────────────────────────────────────────────────────────────────────────── - ────────────────────────────────────────────────────────────────────────────── - Two - - - - - - ────────────────────────────────────────────────────────────────────────────── - ────────────────────────────────────────────────────────────────────────────── - Three - - - - - - ────────────────────────────────────────────────────────────────────────────── + + + + ────────────────────────────────────────────────────────────────────────────── + + + + #top + + + + + ────────────────────────────────────────────────────────────────────────────── + ────────────────────────────────────────────────────────────────────────────── + + + + + + #bottom + + + + + + + ────────────────────────────────────────────────────────────────────────────── @@ -30708,134 +38229,289 @@ font-weight: 700; } - .terminal-4186799416-matrix { + .terminal-4287604798-matrix { font-family: Fira Code, monospace; font-size: 20px; line-height: 24.4px; font-variant-east-asian: full-width; } - .terminal-4186799416-title { + .terminal-4287604798-title { font-size: 18px; font-weight: bold; font-family: arial; } - .terminal-4186799416-r1 { fill: #e1e1e1 } - .terminal-4186799416-r2 { fill: #ff0000 } - .terminal-4186799416-r3 { fill: #c5c8c6 } - .terminal-4186799416-r4 { fill: #0000ff } + .terminal-4287604798-r1 { fill: #e1e1e1 } + .terminal-4287604798-r2 { fill: #ff0000 } + .terminal-4287604798-r3 { fill: #c5c8c6 } + .terminal-4287604798-r4 { fill: #0000ff } - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - Visibility + Visibility - + - - ────────────────────────────────────── - bar - ──────────────────────────────────────────────────────────────────────── - floatfloat - ──────────────────────────────────────────────────────────────────────── - - - - - - - - - - - - - - - - - - - ────────────────────────────────────── + + ────────────────────────────────────── + bar + ──────────────────────────────────────────────────────────────────────── + floatfloat + ──────────────────────────────────────────────────────────────────────── + + + + + + + + + + + + + + + + + + + ────────────────────────────────────── + + + + + ''' +# --- +# name: test_zero_scrollbar_size + ''' + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + TestApp + + + + + + + + + + Hello, world! + Hello, world! + Hello, world! + Hello, world! + Hello, world! + Hello, world! + Hello, world! + Hello, world! + Hello, world! + Hello, world! + Hello, world! + Hello, world! + Hello, world! + Hello, world! + Hello, world! + Hello, world! + Hello, world! + Hello, world! + Hello, world! + Hello, world! + Hello, world! + Hello, world! + Hello, world! + Hello, world! diff --git a/tests/snapshot_tests/language_snippets.py b/tests/snapshot_tests/language_snippets.py new file mode 100644 index 0000000000..6b55775159 --- /dev/null +++ b/tests/snapshot_tests/language_snippets.py @@ -0,0 +1,465 @@ +PYTHON = """\ +import math +from os import path + +# I'm a comment :) + +string_var = "Hello, world!" +int_var = 42 +float_var = 3.14 +complex_var = 1 + 2j + +list_var = [1, 2, 3, 4, 5] +tuple_var = (1, 2, 3, 4, 5) +set_var = {1, 2, 3, 4, 5} +dict_var = {"a": 1, "b": 2, "c": 3} + +def function_no_args(): + return "No arguments" + +def function_with_args(a, b): + return a + b + +def function_with_default_args(a=0, b=0): + return a * b + +lambda_func = lambda x: x**2 + +if int_var == 42: + print("It's the answer!") +elif int_var < 42: + print("Less than the answer.") +else: + print("Greater than the answer.") + +for index, value in enumerate(list_var): + print(f"Index: {index}, Value: {value}") + +counter = 0 +while counter < 5: + print(f"Counter value: {counter}") + counter += 1 + +squared_numbers = [x**2 for x in range(10) if x % 2 == 0] + +try: + result = 10 / 0 +except ZeroDivisionError: + print("Cannot divide by zero!") +finally: + print("End of try-except block.") + +class Animal: + def __init__(self, name): + self.name = name + + def speak(self): + raise NotImplementedError("Subclasses must implement this method.") + +class Dog(Animal): + def speak(self): + return f"{self.name} says Woof!" + +def fibonacci(n): + a, b = 0, 1 + for _ in range(n): + yield a + a, b = b, a + b + +for num in fibonacci(5): + print(num) + +with open('test.txt', 'w') as f: + f.write("Testing with statement.") + +@my_decorator +def say_hello(): + print("Hello!") + +say_hello() +""" + + +MARKDOWN = """\ +Heading +======= + +Sub-heading +----------- + +### Heading + +#### H4 Heading + +##### H5 Heading + +###### H6 Heading + + +Paragraphs are separated +by a blank line. + +Two spaces at the end of a line +produces a line break. + +Text attributes _italic_, +**bold**, `monospace`. + +Horizontal rule: + +--- + +Bullet list: + + * apples + * oranges + * pears + +Numbered list: + + 1. lather + 2. rinse + 3. repeat + +An [example](http://example.com). + +> Markdown uses email-style > characters for blockquoting. +> +> Lorem ipsum + +![progress](https://github.com/textualize/rich/raw/master/imgs/progress.gif) + + +``` +a=1 +``` + +```python +import this +``` + +```somelang +foobar +``` + + import this + + +1. List item + + Code block +""" + +YAML = """\ +# This is a comment in YAML + +# Scalars +string: "Hello, world!" +integer: 42 +float: 3.14 +boolean: true + +# Sequences (Arrays) +fruits: + - Apple + - Banana + - Cherry + +# Nested sequences +persons: + - name: John + age: 28 + is_student: false + - name: Jane + age: 22 + is_student: true + +# Mappings (Dictionaries) +address: + street: 123 Main St + city: Anytown + state: CA + zip: '12345' + +# Multiline string +description: | + This is a multiline + string in YAML. + +# Inline and nested collections +colors: { red: FF0000, green: 00FF00, blue: 0000FF } +""" + +TOML = """\ +# This is a comment in TOML + +string = "Hello, world!" +integer = 42 +float = 3.14 +boolean = true +datetime = 1979-05-27T07:32:00Z + +fruits = ["apple", "banana", "cherry"] + +[address] +street = "123 Main St" +city = "Anytown" +state = "CA" +zip = "12345" + +[person.john] +name = "John Doe" +age = 28 +is_student = false + + +[[animals]] +name = "Fido" +type = "dog" +""" + +SQL = """\ +-- This is a comment in SQL + +-- Create tables +CREATE TABLE Authors ( + AuthorID INT PRIMARY KEY, + Name VARCHAR(255) NOT NULL, + Country VARCHAR(50) +); + +CREATE TABLE Books ( + BookID INT PRIMARY KEY, + Title VARCHAR(255) NOT NULL, + AuthorID INT, + PublishedDate DATE, + FOREIGN KEY (AuthorID) REFERENCES Authors(AuthorID) +); + +-- Insert data +INSERT INTO Authors (AuthorID, Name, Country) VALUES (1, 'George Orwell', 'UK'); + +INSERT INTO Books (BookID, Title, AuthorID, PublishedDate) VALUES (1, '1984', 1, '1949-06-08'); + +-- Update data +UPDATE Authors SET Country = 'United Kingdom' WHERE Country = 'UK'; + +-- Select data with JOIN +SELECT Books.Title, Authors.Name +FROM Books +JOIN Authors ON Books.AuthorID = Authors.AuthorID; + +-- Delete data (commented to preserve data for other examples) +-- DELETE FROM Books WHERE BookID = 1; + +-- Alter table structure +ALTER TABLE Authors ADD COLUMN BirthDate DATE; + +-- Create index +CREATE INDEX idx_author_name ON Authors(Name); + +-- Drop index (commented to avoid actually dropping it) +-- DROP INDEX idx_author_name ON Authors; + +-- End of script +""" + +CSS = """\ +/* This is a comment in CSS */ + +/* Basic selectors and properties */ +body { + font-family: Arial, sans-serif; + background-color: #f4f4f4; + margin: 0; + padding: 0; +} + +/* Class and ID selectors */ +.header { + background-color: #333; + color: #fff; + padding: 10px 0; + text-align: center; +} + +#logo { + font-size: 24px; + font-weight: bold; +} + +/* Descendant and child selectors */ +.nav ul { + list-style-type: none; + padding: 0; +} + +.nav > li { + display: inline-block; + margin-right: 10px; +} + +/* Pseudo-classes */ +a:hover { + text-decoration: underline; +} + +input:focus { + border-color: #007BFF; +} + +/* Media query */ +@media (max-width: 768px) { + body { + font-size: 16px; + } + + .header { + padding: 5px 0; + } +} + +/* Keyframes animation */ +@keyframes slideIn { + from { + transform: translateX(-100%); + } + to { + transform: translateX(0); + } +} + +.slide-in-element { + animation: slideIn 0.5s forwards; +} +""" + +HTML = """\ + + + + + + + + + HTML Test Page + + + + + + +
+

HTML Test Page

+
+ + + + + +
+
+

Welcome to the Test Page

+

This is a paragraph to test the HTML structure.

+ Test Image +
+
+ + +
+
+ + + + +
+ + +
+

© 2023 HTML Test Page

+
+ + + + + + +""" + +JSON = """\ +{ + "name": "John Doe", + "age": 30, + "isStudent": false, + "address": { + "street": "123 Main St", + "city": "Anytown", + "state": "CA", + "zip": "12345" + }, + "phoneNumbers": [ + { + "type": "home", + "number": "555-555-1234" + }, + { + "type": "work", + "number": "555-555-5678" + } + ], + "hobbies": ["reading", "hiking", "swimming"], + "pets": [ + { + "type": "dog", + "name": "Fido" + }, + ], + "graduationYear": null +} + +""" + +REGEX = r"""^abc # Matches any string that starts with "abc" +abc$ # Matches any string that ends with "abc" +^abc$ # Matches the string "abc" and nothing else +a.b # Matches any string containing "a", any character, then "b" +a[.]b # Matches the string "a.b" +a|b # Matches either "a" or "b" +a{2} # Matches "aa" +a{2,} # Matches two or more consecutive "a" characters +a{2,5} # Matches between 2 and 5 consecutive "a" characters +a? # Matches "a" or nothing (0 or 1 occurrence of "a") +a* # Matches zero or more consecutive "a" characters +a+ # Matches one or more consecutive "a" characters +\d # Matches any digit (equivalent to [0-9]) +\D # Matches any non-digit +\w # Matches any word character (equivalent to [a-zA-Z0-9_]) +\W # Matches any non-word character +\s # Matches any whitespace character (spaces, tabs, line breaks) +\S # Matches any non-whitespace character +(?i)abc # Case-insensitive match for "abc" +(?:a|b) # Non-capturing group for either "a" or "b" +(?<=a)b # Positive lookbehind: matches "b" that is preceded by "a" +(? ComposeResult: + yield Button("Hello") + yield Button("Hello\nWorld !!") + + +if __name__ == "__main__": + app = ButtonApp() + app.run() diff --git a/tests/snapshot_tests/snapshot_apps/button_outline.py b/tests/snapshot_tests/snapshot_apps/button_outline.py new file mode 100644 index 0000000000..1247565b8d --- /dev/null +++ b/tests/snapshot_tests/snapshot_apps/button_outline.py @@ -0,0 +1,18 @@ +from textual.app import App, ComposeResult +from textual.widgets import Button + + +class ButtonIssue(App[None]): + AUTO_FOCUS = None + CSS = """ + Button { + outline: white; + } + """ + + def compose(self) -> ComposeResult: + yield Button("Test") + + +if __name__ == "__main__": + ButtonIssue().run() diff --git a/tests/snapshot_tests/snapshot_apps/data_table_add_row_auto_height.py b/tests/snapshot_tests/snapshot_apps/data_table_add_row_auto_height.py new file mode 100644 index 0000000000..23a224b4ff --- /dev/null +++ b/tests/snapshot_tests/snapshot_apps/data_table_add_row_auto_height.py @@ -0,0 +1,25 @@ +from rich.panel import Panel +from rich.text import Text + +from textual.app import App +from textual.widgets import DataTable + + +class AutoHeightRowsApp(App[None]): + def compose(self): + table = DataTable() + self.column = table.add_column("N") + table.add_column("Column", width=10) + table.add_row(3, "hey there", height=None) + table.add_row(1, Text("hey there"), height=None) + table.add_row(5, Text("long string", overflow="fold"), height=None) + table.add_row(2, Panel.fit("Hello\nworld"), height=None) + table.add_row(4, "1\n2\n3\n4\n5\n6\n7", height=None) + yield table + + def key_s(self): + self.query_one(DataTable).sort(self.column) + + +if __name__ == "__main__": + AutoHeightRowsApp().run() diff --git a/tests/snapshot_tests/snapshot_apps/data_table_cell_padding.py b/tests/snapshot_tests/snapshot_apps/data_table_cell_padding.py new file mode 100644 index 0000000000..df7283abb3 --- /dev/null +++ b/tests/snapshot_tests/snapshot_apps/data_table_cell_padding.py @@ -0,0 +1,28 @@ +from textual.app import App, ComposeResult +from textual.widgets import DataTable + + +class TableApp(App): + CSS = """ + DataTable { + margin: 1; + } + """ + + def compose(self) -> ComposeResult: + for cell_padding in range(5): + dt = DataTable(cell_padding=cell_padding) + dt.add_columns("one", "two", "three") + dt.add_row("value", "value", "val") + yield dt + + def key_a(self): + self.query(DataTable).last().cell_padding = 20 + + def key_b(self): + self.query(DataTable).last().cell_padding = 10 + + +app = TableApp() +if __name__ == "__main__": + app.run() diff --git a/tests/snapshot_tests/snapshot_apps/data_table_style_order.py b/tests/snapshot_tests/snapshot_apps/data_table_style_order.py index 40844053da..2558c79e51 100644 --- a/tests/snapshot_tests/snapshot_apps/data_table_style_order.py +++ b/tests/snapshot_tests/snapshot_apps/data_table_style_order.py @@ -10,10 +10,14 @@ ] -def make_datatable(foreground_priority: Literal["css", "renderable"], - background_priority: Literal["css", "renderable"]) -> DataTable: - table = DataTable(cursor_foreground_priority=foreground_priority, - cursor_background_priority=background_priority) +def make_datatable( + foreground_priority: Literal["css", "renderable"], + background_priority: Literal["css", "renderable"], +) -> DataTable: + table = DataTable( + cursor_foreground_priority=foreground_priority, + cursor_background_priority=background_priority, + ) table.zebra_stripes = True table.add_column("Movies") for row in data: @@ -30,15 +34,17 @@ class DataTableCursorStyles(App): CSS = """ DataTable {margin-bottom: 1;} -DataTable > .datatable--cursor { - color: $secondary; - background: $success; - text-style: bold italic; -} + DataTable > .datatable--cursor { + color: $secondary; + background: $success; + text-style: bold italic; + } """ def compose(self) -> ComposeResult: - priorities: list[tuple[Literal["css", "renderable"], Literal["css", "renderable"]]] = [ + priorities: list[ + tuple[Literal["css", "renderable"], Literal["css", "renderable"]] + ] = [ ("css", "css"), ("css", "renderable"), ("renderable", "renderable"), @@ -52,5 +58,5 @@ def compose(self) -> ComposeResult: app = DataTableCursorStyles() -if __name__ == '__main__': +if __name__ == "__main__": app.run() diff --git a/tests/snapshot_tests/snapshot_apps/datatable_hot_reloading.py b/tests/snapshot_tests/snapshot_apps/datatable_hot_reloading.py new file mode 100644 index 0000000000..7e3a803155 --- /dev/null +++ b/tests/snapshot_tests/snapshot_apps/datatable_hot_reloading.py @@ -0,0 +1,58 @@ +from pathlib import Path + +from textual.app import App, ComposeResult +from textual.widgets import DataTable + +CSS_PATH = (Path(__file__) / "../datatable_hot_reloading.tcss").resolve() + +# Write some CSS to the file before the app loads. +# Then, the test will clear all the CSS to see if the +# hot reloading applies the changes correctly. +CSS_PATH.write_text( + """\ +DataTable > .datatable--cursor { + background: purple; +} + +DataTable > .datatable--fixed { + background: red; +} + +DataTable > .datatable--fixed-cursor { + background: blue; +} + +DataTable > .datatable--header { + background: yellow; +} + +DataTable > .datatable--odd-row { + background: pink; +} + +DataTable > .datatable--even-row { + background: brown; +} +""" +) + + +class DataTableHotReloadingApp(App[None]): + CSS_PATH = CSS_PATH + + def compose(self) -> ComposeResult: + yield DataTable(zebra_stripes=True, cursor_type="row") + + def on_mount(self) -> None: + dt = self.query_one(DataTable) + dt.add_column("A", width=10) + self.c = dt.add_column("B") + dt.fixed_columns = 1 + dt.add_row("one", "two") + dt.add_row("three", "four") + dt.add_row("five", "six") + + +if __name__ == "__main__": + app = DataTableHotReloadingApp() + app.run() diff --git a/tests/snapshot_tests/snapshot_apps/datatable_hot_reloading.tcss b/tests/snapshot_tests/snapshot_apps/datatable_hot_reloading.tcss new file mode 100644 index 0000000000..5e9ee82eb7 --- /dev/null +++ b/tests/snapshot_tests/snapshot_apps/datatable_hot_reloading.tcss @@ -0,0 +1 @@ +/* This file is purposefully empty. */ diff --git a/tests/snapshot_tests/snapshot_apps/hot_reloading_app_with_screen_css.py b/tests/snapshot_tests/snapshot_apps/hot_reloading_app_with_screen_css.py new file mode 100644 index 0000000000..a40d31daed --- /dev/null +++ b/tests/snapshot_tests/snapshot_apps/hot_reloading_app_with_screen_css.py @@ -0,0 +1,41 @@ +from pathlib import Path + +from textual.app import App, ComposeResult +from textual.containers import Container +from textual.screen import Screen +from textual.widgets import Label + + +SCREEN_CSS_PATH = (Path(__file__) / "../hot_reloading_app_with_screen_css.tcss").resolve() + +# Write some CSS to the file before the app loads. +# Then, the test will clear all the CSS to see if the +# hot reloading applies the changes correctly. +SCREEN_CSS_PATH.write_text( + """ +Container { + align: center middle; +} + +Label { + border: round $primary; + padding: 3; +} +""" +) + + +class MyScreen(Screen[None]): + CSS_PATH = SCREEN_CSS_PATH + + def compose(self) -> ComposeResult: + yield Container(Label("Hello, world!")) + + +class HotReloadingApp(App[None]): + def on_mount(self) -> None: + self.push_screen(MyScreen()) + + +if __name__ == "__main__": + HotReloadingApp(watch_css=True).run() diff --git a/tests/snapshot_tests/snapshot_apps/hot_reloading_app_with_screen_css.tcss b/tests/snapshot_tests/snapshot_apps/hot_reloading_app_with_screen_css.tcss new file mode 100644 index 0000000000..5e9ee82eb7 --- /dev/null +++ b/tests/snapshot_tests/snapshot_apps/hot_reloading_app_with_screen_css.tcss @@ -0,0 +1 @@ +/* This file is purposefully empty. */ diff --git a/tests/snapshot_tests/snapshot_apps/input_suggestions.py b/tests/snapshot_tests/snapshot_apps/input_suggestions.py index d932066364..9955fa4175 100644 --- a/tests/snapshot_tests/snapshot_apps/input_suggestions.py +++ b/tests/snapshot_tests/snapshot_apps/input_suggestions.py @@ -16,6 +16,10 @@ class FruitsApp(App[None]): def compose(self) -> ComposeResult: yield Input("straw", suggester=SuggestFromList(fruits)) + yield Input("straw", suggester=SuggestFromList(fruits)) + yield Input("p", suggester=SuggestFromList(fruits)) + yield Input("b", suggester=SuggestFromList(fruits)) + yield Input("a", suggester=SuggestFromList(fruits)) if __name__ == "__main__": diff --git a/tests/snapshot_tests/snapshot_apps/keyline.py b/tests/snapshot_tests/snapshot_apps/keyline.py new file mode 100644 index 0000000000..6cb192d450 --- /dev/null +++ b/tests/snapshot_tests/snapshot_apps/keyline.py @@ -0,0 +1,52 @@ +from textual.app import App, ComposeResult +from textual.widgets import Static +from textual.containers import Horizontal, Vertical, Grid + + +class Box(Static): + pass + + +class KeylineApp(App): + CSS = """ + Vertical { + keyline: thin red; + } + Horizontal { + keyline: heavy green; + } + Grid { + keyline: double magenta; + } + Box { + width: 1fr; + height: 1fr; + } + Horizontal > Box, Vertical > Box { + margin: 1; + } + Grid { + grid-size: 2; + grid-gutter: 1; + } + + """ + + def compose(self) -> ComposeResult: + with Vertical(): + yield Box("1") + yield Box("2") + yield Box("3") + with Horizontal(): + yield Box("4") + yield Box("5") + yield Box("6") + with Grid(): + yield Box("7") + yield Box("8") + yield Box("9") + + +if __name__ == "__main__": + app = KeylineApp() + app.run() diff --git a/tests/snapshot_tests/snapshot_apps/loading.py b/tests/snapshot_tests/snapshot_apps/loading.py new file mode 100644 index 0000000000..d496709b37 --- /dev/null +++ b/tests/snapshot_tests/snapshot_apps/loading.py @@ -0,0 +1,56 @@ +from rich.console import RenderableType + +from textual.app import App, ComposeResult +from textual.containers import Horizontal +from textual.widget import Widget +from textual.widgets import OptionList + + +class SimpleLoadingIndicator(Widget): + """A loading indicator that doesn't animate.""" + + DEFAULT_CSS = """ + SimpleLoadingIndicator { + width: 100%; + height: 100%; + min-height: 1; + content-align: center middle; + color: $accent; + } + SimpleLoadingIndicator.-textual-loading-indicator { + layer: _loading; + background: $boost; + dock: top; + } + """ + + def render(self) -> RenderableType: + return "Loading!" + + +class LoadingOverlayRedux(App[None]): + CSS = """ + OptionList { + scrollbar-gutter: stable; + width: 1fr; + height: 1fr; + } + """ + + BINDINGS = [("space", "toggle")] + + def get_loading_widget(self) -> Widget: + return SimpleLoadingIndicator() + + def compose(self) -> ComposeResult: + with Horizontal(): + yield OptionList(*[("hello world " * 5) for _ in range(100)]) + yield OptionList(*[("foo bar" * 5) for _ in range(100)]) + + def action_toggle(self) -> None: + option_list = self.query(OptionList).first() + option_list.loading = not option_list.loading + + +if __name__ == "__main__": + LoadingOverlayRedux().run() diff --git a/tests/snapshot_tests/snapshot_apps/max_height_100.py b/tests/snapshot_tests/snapshot_apps/max_height_100.py new file mode 100644 index 0000000000..83bc94386a --- /dev/null +++ b/tests/snapshot_tests/snapshot_apps/max_height_100.py @@ -0,0 +1,27 @@ +from textual.app import App, ComposeResult +from textual.widgets import DataTable, Static + + +class HappyDataTableFunApp(App[None]): + """The DataTable should expand as if it has height 1fr.""" + + CSS = """ + DataTable { + max-height: 100%; + } + """ + + def populate(self, table: DataTable) -> DataTable: + for n in range(20): + table.add_column(f"Column {n}") + for row in range(100): + table.add_row(*[str(row * n) for n in range(20)]) + return table + + def compose(self) -> ComposeResult: + with Static(id="s"): + yield self.populate(DataTable()) + + +if __name__ == "__main__": + HappyDataTableFunApp().run() diff --git a/tests/snapshot_tests/snapshot_apps/missing_vertical_scroll.py b/tests/snapshot_tests/snapshot_apps/missing_vertical_scroll.py new file mode 100644 index 0000000000..cb91bc06d9 --- /dev/null +++ b/tests/snapshot_tests/snapshot_apps/missing_vertical_scroll.py @@ -0,0 +1,34 @@ +from textual.app import App, ComposeResult +from textual.containers import Horizontal +from textual.widgets import OptionList + + +class MissingScrollbarApp(App[None]): + CSS = """ + OptionList { + height: 1fr; + } + + #left { + min-width: 25; + } + + #middle { + width: 5fr; + } + + #right { + min-width: 30; + } + """ + + def compose(self) -> ComposeResult: + options = [str(n) for n in range(200)] + with Horizontal(): + yield OptionList(*options, id="left") + yield OptionList(*options, id="middle") + yield OptionList(*options, id="right") + + +if __name__ == "__main__": + MissingScrollbarApp().run() diff --git a/tests/snapshot_tests/snapshot_apps/modified_tabs.py b/tests/snapshot_tests/snapshot_apps/modified_tabs.py index 48bb66d567..c94771c3d3 100644 --- a/tests/snapshot_tests/snapshot_apps/modified_tabs.py +++ b/tests/snapshot_tests/snapshot_apps/modified_tabs.py @@ -18,9 +18,9 @@ def compose(self) -> ComposeResult: yield Button() def on_mount(self) -> None: - self.query_one(TabbedContent).disable_tab(f"tab-1") - self.query_one(TabbedContent).disable_tab(f"tab-2") - self.query_one(TabbedContent).hide_tab(f"tab-3") + self.query_one(TabbedContent).disable_tab("tab-1") + self.query_one(TabbedContent).disable_tab("tab-2") + self.query_one(TabbedContent).hide_tab("tab-3") if __name__ == "__main__": diff --git a/tests/snapshot_tests/snapshot_apps/mount_style_fix.py b/tests/snapshot_tests/snapshot_apps/mount_style_fix.py new file mode 100644 index 0000000000..038ce75ba6 --- /dev/null +++ b/tests/snapshot_tests/snapshot_apps/mount_style_fix.py @@ -0,0 +1,32 @@ +from textual import __version__ +from textual.app import App, ComposeResult +from textual.widgets import Static + + +# Regression test for https://github.com/Textualize/textual/issues/3858 +class BrokenClassesApp(App[None]): + CSS = """ + Screen { + align: center middle; + } + + Static { + width: 50%; + height: 50%; + border: solid; + } + + Screen.go-red Static { + background: red; + } + """ + + def compose(self) -> ComposeResult: + yield Static("This should have a red background") + + def on_mount(self) -> None: + self.screen.set_class(True, "go-red") + + +if __name__ == "__main__": + BrokenClassesApp().run() diff --git a/tests/snapshot_tests/snapshot_apps/notification_with_inline_link.py b/tests/snapshot_tests/snapshot_apps/notification_with_inline_link.py new file mode 100644 index 0000000000..c12b03c41b --- /dev/null +++ b/tests/snapshot_tests/snapshot_apps/notification_with_inline_link.py @@ -0,0 +1,11 @@ +from textual.app import App + + +class NotifyWithInlineLinkApp(App): + def on_mount(self) -> None: + self.notify("Click [@click=bell]here[/] for the bell sound.") + + +if __name__ == "__main__": + app = NotifyWithInlineLinkApp() + app.run() diff --git a/tests/snapshot_tests/snapshot_apps/notifications_above_loading.py b/tests/snapshot_tests/snapshot_apps/notifications_above_loading.py new file mode 100644 index 0000000000..491e13649c --- /dev/null +++ b/tests/snapshot_tests/snapshot_apps/notifications_above_loading.py @@ -0,0 +1,29 @@ +from textual.app import App, ComposeResult +from textual.containers import VerticalScroll +from textual.widgets import Label + + +class LoadingOverlayApp(App[None]): + CSS = """ + VerticalScroll { + height: 20; + } + + Toast { + max-width: 100%; + width: 70%; /* We need this to cover the dots of the loading indicator + so that we don't have flakiness in the tests because the screenshot + might be taken a couple ms earlier or later. */ + }""" + + def compose(self) -> ComposeResult: + with VerticalScroll(): + yield Label("another big label\n" * 30) # Ensure there's a scrollbar. + + def on_mount(self): + self.notify("This is a big notification.\n" * 10, timeout=10) + self.query_one(VerticalScroll).loading = True + + +if __name__ == "__main__": + LoadingOverlayApp().run() diff --git a/tests/snapshot_tests/snapshot_apps/scoped_css.py b/tests/snapshot_tests/snapshot_apps/scoped_css.py new file mode 100644 index 0000000000..3ee46c108f --- /dev/null +++ b/tests/snapshot_tests/snapshot_apps/scoped_css.py @@ -0,0 +1,34 @@ +from textual.app import App, ComposeResult +from textual.widget import Widget +from textual.widgets import Label + + +class MyWidget(Widget): + DEFAULT_CSS = """ + MyWidget { + height: auto; + border: magenta; + } + Label { + border: solid green; + } + """ + + def compose(self) -> ComposeResult: + yield Label("foo") + yield Label("bar") + + def on_mount(self) -> None: + self.log(self.app.stylesheet.css) + + +class MyApp(App): + def compose(self) -> ComposeResult: + yield MyWidget() + yield MyWidget() + yield Label("I should not be styled") + + +if __name__ == "__main__": + app = MyApp() + app.run() diff --git a/tests/snapshot_tests/snapshot_apps/text_area.py b/tests/snapshot_tests/snapshot_apps/text_area.py new file mode 100644 index 0000000000..da6bd06992 --- /dev/null +++ b/tests/snapshot_tests/snapshot_apps/text_area.py @@ -0,0 +1,15 @@ +"""Tests the rendering of the TextArea for all supported languages.""" +from textual.app import App, ComposeResult +from textual.widgets import TextArea + + +class TextAreaSnapshot(App): + def compose(self) -> ComposeResult: + text_area = TextArea() + text_area.cursor_blink = False + yield text_area + + +app = TextAreaSnapshot() +if __name__ == "__main__": + app.run() diff --git a/tests/snapshot_tests/snapshot_apps/text_area_unfocus.py b/tests/snapshot_tests/snapshot_apps/text_area_unfocus.py new file mode 100644 index 0000000000..e092f16721 --- /dev/null +++ b/tests/snapshot_tests/snapshot_apps/text_area_unfocus.py @@ -0,0 +1,17 @@ +"""Tests the rendering of the TextArea for all supported languages.""" +from textual.app import App, ComposeResult +from textual.widgets import TextArea + + +class TextAreaUnfocusSnapshot(App): + AUTO_FOCUS = None + + def compose(self) -> ComposeResult: + text_area = TextArea() + text_area.cursor_blink = False + yield text_area + + +app = TextAreaUnfocusSnapshot() +if __name__ == "__main__": + app.run() diff --git a/tests/snapshot_tests/snapshot_apps/tree_clearing.py b/tests/snapshot_tests/snapshot_apps/tree_clearing.py new file mode 100644 index 0000000000..0d867fdf74 --- /dev/null +++ b/tests/snapshot_tests/snapshot_apps/tree_clearing.py @@ -0,0 +1,30 @@ +from textual.app import App, ComposeResult +from textual.widgets import Tree + +class TreeClearingSnapshotApp(App[None]): + + CSS = """ + Screen { + layout: horizontal; + } + """ + + @staticmethod + def _populate(tree: Tree) -> Tree: + for n in range(5): + branch = tree.root.add(str(n)) + for m in range(5): + branch.add_leaf(f"{n}-{m}") + return tree + + def compose(self) -> ComposeResult: + yield self._populate(Tree("Left", id="left")) + yield self._populate(Tree("Right", id="right")) + + def on_mount(self) -> None: + self.query_one("#left", Tree).root.expand() + self.query_one("#left", Tree).clear() + self.query_one("#right", Tree).clear() + +if __name__ == "__main__": + TreeClearingSnapshotApp().run() diff --git a/tests/snapshot_tests/snapshot_apps/unscoped_css.py b/tests/snapshot_tests/snapshot_apps/unscoped_css.py new file mode 100644 index 0000000000..f0cecbadff --- /dev/null +++ b/tests/snapshot_tests/snapshot_apps/unscoped_css.py @@ -0,0 +1,35 @@ +from textual.app import App, ComposeResult +from textual.widget import Widget +from textual.widgets import Label + + +class MyWidget(Widget): + SCOPED_CSS = False + DEFAULT_CSS = """ + MyWidget { + height: auto; + border: magenta; + } + Label { + border: solid green; + } + """ + + def compose(self) -> ComposeResult: + yield Label("foo") + yield Label("bar") + + def on_mount(self) -> None: + self.log(self.app.stylesheet.css) + + +class MyApp(App): + def compose(self) -> ComposeResult: + yield MyWidget() + yield MyWidget() + yield Label("This will be styled") + + +if __name__ == "__main__": + app = MyApp() + app.run() diff --git a/tests/snapshot_tests/snapshot_apps/vertical_max_height.py b/tests/snapshot_tests/snapshot_apps/vertical_max_height.py new file mode 100644 index 0000000000..03196c52dd --- /dev/null +++ b/tests/snapshot_tests/snapshot_apps/vertical_max_height.py @@ -0,0 +1,26 @@ +from textual.app import App, ComposeResult +from textual.containers import Vertical +from textual.widgets import Placeholder + + +class VerticalApp(App): + CSS = """ + #top { + height: 1fr; + border: white; + } + #bottom { + height:3fr; + border: white; + max-height: 10; + } + """ + + def compose(self) -> ComposeResult: + with Vertical(): + yield Placeholder(id="top") + yield Placeholder(id="bottom") + + +if __name__ == "__main__": + VerticalApp().run() diff --git a/tests/snapshot_tests/snapshot_apps/vertical_min_height.py b/tests/snapshot_tests/snapshot_apps/vertical_min_height.py new file mode 100644 index 0000000000..b0f167f82e --- /dev/null +++ b/tests/snapshot_tests/snapshot_apps/vertical_min_height.py @@ -0,0 +1,26 @@ +from textual.app import App, ComposeResult +from textual.containers import Vertical +from textual.widgets import Placeholder + + +class VerticalApp(App): + CSS = """ + #top { + min-height: 10; + height: 1fr; + border: white; + } + #bottom { + height:3fr; + border: white; + } + """ + + def compose(self) -> ComposeResult: + with Vertical(): + yield Placeholder(id="top") + yield Placeholder(id="bottom") + + +if __name__ == "__main__": + VerticalApp().run() diff --git a/tests/snapshot_tests/snapshot_apps/zero_scrollbar_size.py b/tests/snapshot_tests/snapshot_apps/zero_scrollbar_size.py new file mode 100644 index 0000000000..346acbce96 --- /dev/null +++ b/tests/snapshot_tests/snapshot_apps/zero_scrollbar_size.py @@ -0,0 +1,17 @@ +from textual.app import App, ComposeResult +from textual.widgets import Static + + +class TestApp(App): + DEFAULT_CSS = """ + Screen { + scrollbar-size: 0 0; + } + """ + + def compose(self) -> ComposeResult: + yield Static("Hello, world!\n" * 100) + + +if __name__ == "__main__": + TestApp().run() diff --git a/tests/snapshot_tests/test_snapshots.py b/tests/snapshot_tests/test_snapshots.py index 68a731fdbb..0b9e309474 100644 --- a/tests/snapshot_tests/test_snapshots.py +++ b/tests/snapshot_tests/test_snapshots.py @@ -2,6 +2,11 @@ import pytest +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.text_area import TextAreaTheme + # These paths should be relative to THIS directory. WIDGET_EXAMPLES_DIR = Path("../../docs/examples/widgets") LAYOUT_EXAMPLES_DIR = Path("../../docs/examples/guide/layout") @@ -89,13 +94,19 @@ def test_input_validation(snap_compare): "tab", "3", # This is valid, so -valid should be applied "tab", - *"-2", # -2 is invalid, so -invalid should be applied (and :focus, since we stop here) + *"-2", + # -2 is invalid, so -invalid should be applied (and :focus, since we stop here) ] assert snap_compare(SNAPSHOT_APPS_DIR / "input_validation.py", press=press) def test_input_suggestions(snap_compare): - assert snap_compare(SNAPSHOT_APPS_DIR / "input_suggestions.py", press=[]) + async def run_before(pilot): + pilot.app.query(Input).first().cursor_blink = False + + assert snap_compare( + SNAPSHOT_APPS_DIR / "input_suggestions.py", press=[], run_before=run_before + ) def test_buttons_render(snap_compare): @@ -148,6 +159,30 @@ def test_datatable_add_column(snap_compare): assert snap_compare(SNAPSHOT_APPS_DIR / "data_table_add_column.py") +def test_datatable_add_row_auto_height(snap_compare): + # Check that rows added with auto height computation look right. + assert snap_compare(SNAPSHOT_APPS_DIR / "data_table_add_row_auto_height.py") + + +def test_datatable_add_row_auto_height_sorted(snap_compare): + # Check that rows added with auto height computation look right. + assert snap_compare( + SNAPSHOT_APPS_DIR / "data_table_add_row_auto_height.py", press=["s"] + ) + + +def test_datatable_cell_padding(snap_compare): + # Check that horizontal cell padding is respected. + assert snap_compare(SNAPSHOT_APPS_DIR / "data_table_cell_padding.py") + + +def test_datatable_change_cell_padding(snap_compare): + # Check that horizontal cell padding is respected. + assert snap_compare( + SNAPSHOT_APPS_DIR / "data_table_cell_padding.py", press=["a", "b"] + ) + + def test_footer_render(snap_compare): assert snap_compare(WIDGET_EXAMPLES_DIR / "footer.py") @@ -315,6 +350,12 @@ def test_select_expanded(snap_compare): ) +def test_select_from_values_expanded(snap_compare): + assert snap_compare( + WIDGET_EXAMPLES_DIR / "select_from_values_widget.py", press=["tab", "enter"] + ) + + def test_select_expanded_changed(snap_compare): assert snap_compare( WIDGET_EXAMPLES_DIR / "select_widget.py", @@ -322,6 +363,18 @@ def test_select_expanded_changed(snap_compare): ) +def test_select_no_blank_has_default_value(snap_compare): + """Make sure that the first value is selected by default if allow_blank=False.""" + assert snap_compare(WIDGET_EXAMPLES_DIR / "select_widget_no_blank.py") + + +def test_select_set_options(snap_compare): + assert snap_compare( + WIDGET_EXAMPLES_DIR / "select_widget_no_blank.py", + press=["s"], + ) + + def test_sparkline_render(snap_compare): assert snap_compare(WIDGET_EXAMPLES_DIR / "sparkline.py") @@ -497,9 +550,13 @@ def test_scrollbar_thumb_height(snap_compare): ) -def test_css_hot_reloading(snap_compare): +def test_css_hot_reloading(snap_compare, monkeypatch): """Regression test for https://github.com/Textualize/textual/issues/2063.""" + 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: @@ -511,6 +568,43 @@ async def run_before(pilot): ) +def test_css_hot_reloading_on_screen(snap_compare, monkeypatch): + """Regression test for https://github.com/Textualize/textual/issues/3454.""" + + monkeypatch.setenv( + "TEXTUAL", "debug" + ) # This will make sure we create a file monitor. + + async def run_before(pilot): + css_file = pilot.app.screen.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 / "hot_reloading_app_with_screen_css.py", + run_before=run_before, + ) + + +def test_datatable_hot_reloading(snap_compare, monkeypatch): + """Regression test for https://github.com/Textualize/textual/issues/3312.""" + + 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 / "datatable_hot_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"]) @@ -612,6 +706,7 @@ def test_blur_on_disabled(snap_compare): def test_tooltips_in_compound_widgets(snap_compare): # https://github.com/Textualize/textual/issues/2641 async def run_before(pilot) -> None: + await pilot.pause() await pilot.hover("ProgressBar") await pilot.pause(0.3) await pilot.pause() @@ -620,12 +715,11 @@ async def run_before(pilot) -> None: def test_command_palette(snap_compare) -> None: - from textual.command import CommandPalette - async def run_before(pilot) -> None: - await pilot.press("ctrl+backslash") + # await pilot.press("ctrl+backslash") + pilot.app.screen.query_one(Input).cursor_blink = False await pilot.press("A") - await pilot.app.query_one(CommandPalette).workers.wait_for_complete() + await pilot.app.screen.workers.wait_for_complete() assert snap_compare(SNAPSHOT_APPS_DIR / "command_palette.py", run_before=run_before) @@ -634,7 +728,16 @@ async def run_before(pilot) -> None: def test_textual_dev_border_preview(snap_compare): - assert snap_compare(SNAPSHOT_APPS_DIR / "dev_previews_border.py", press=["enter"]) + async def run_before(pilot): + buttons = pilot.app.query(Button) + for button in buttons: + button.active_effect_duration = 0 + + assert snap_compare( + SNAPSHOT_APPS_DIR / "dev_previews_border.py", + press=["enter"], + run_before=run_before, + ) def test_textual_dev_colors_preview(snap_compare): @@ -661,6 +764,23 @@ def test_notifications_through_modes(snap_compare) -> None: assert snap_compare(SNAPSHOT_APPS_DIR / "notification_through_modes.py") +def test_notification_with_inline_link(snap_compare) -> None: + # https://github.com/Textualize/textual/issues/3530 + assert snap_compare(SNAPSHOT_APPS_DIR / "notification_with_inline_link.py") + + +def test_notification_with_inline_link_hover(snap_compare) -> None: + # https://github.com/Textualize/textual/issues/3530 + async def run_before(pilot) -> None: + await pilot.pause() + await pilot.hover("Toast", offset=(8, 1)) + + assert snap_compare( + SNAPSHOT_APPS_DIR / "notification_with_inline_link.py", + run_before=run_before, + ) + + def test_print_capture(snap_compare) -> None: assert snap_compare(SNAPSHOT_APPS_DIR / "capture_print.py") @@ -674,6 +794,88 @@ def test_nested_fr(snap_compare) -> None: assert snap_compare(SNAPSHOT_APPS_DIR / "nested_fr.py") +@pytest.mark.syntax +@pytest.mark.parametrize("language", BUILTIN_LANGUAGES) +def test_text_area_language_rendering(language, snap_compare): + # This test will fail if we're missing a snapshot test for a valid + # language. We should have a snapshot test for each language we support + # as the syntax highlighting will be completely different for each of them. + + snippet = SNIPPETS.get(language) + + def setup_language(pilot) -> None: + text_area = pilot.app.query_one(TextArea) + text_area.load_text(snippet) + text_area.language = language + + assert snap_compare( + SNAPSHOT_APPS_DIR / "text_area.py", + run_before=setup_language, + terminal_size=(80, snippet.count("\n") + 2), + ) + + +@pytest.mark.parametrize( + "selection", + [ + Selection((0, 0), (2, 8)), + Selection((1, 0), (0, 0)), + Selection((5, 2), (0, 0)), + Selection((0, 0), (4, 20)), + Selection.cursor((1, 0)), + Selection.cursor((2, 6)), + ], +) +def test_text_area_selection_rendering(snap_compare, selection): + text = """I am a line. + +I am another line. + +I am the final line.""" + + def setup_selection(pilot): + text_area = pilot.app.query_one(TextArea) + text_area.load_text(text) + text_area.show_line_numbers = False + text_area.selection = selection + + assert snap_compare( + SNAPSHOT_APPS_DIR / "text_area.py", + run_before=setup_selection, + terminal_size=(30, text.count("\n") + 1), + ) + + +@pytest.mark.syntax +@pytest.mark.parametrize( + "theme_name", [theme.name for theme in TextAreaTheme.builtin_themes()] +) +def test_text_area_themes(snap_compare, theme_name): + """Each theme should have its own snapshot with at least some Python + to check that the rendering is sensible. This also ensures that theme + switching results in the display changing correctly.""" + text = """\ +def hello(name): + x = 123 + while not False: + print("hello " + name) + continue +""" + + def setup_theme(pilot): + text_area = pilot.app.query_one(TextArea) + text_area.load_text(text) + text_area.language = "python" + text_area.selection = Selection((0, 1), (1, 9)) + text_area.theme = theme_name + + assert snap_compare( + SNAPSHOT_APPS_DIR / "text_area.py", + run_before=setup_theme, + terminal_size=(48, text.count("\n") + 2), + ) + + def test_digits(snap_compare) -> None: assert snap_compare(SNAPSHOT_APPS_DIR / "digits.py") @@ -684,3 +886,91 @@ def test_auto_grid(snap_compare) -> None: def test_auto_grid_default_height(snap_compare) -> None: assert snap_compare(SNAPSHOT_APPS_DIR / "auto_grid_default_height.py", press=["g"]) + + +def test_scoped_css(snap_compare) -> None: + assert snap_compare(SNAPSHOT_APPS_DIR / "scoped_css.py") + + +def test_unscoped_css(snap_compare) -> None: + assert snap_compare(SNAPSHOT_APPS_DIR / "unscoped_css.py") + + +def test_big_buttons(snap_compare) -> None: + assert snap_compare(SNAPSHOT_APPS_DIR / "big_button.py") + + +def test_keyline(snap_compare) -> None: + assert snap_compare(SNAPSHOT_APPS_DIR / "keyline.py") + + +def test_button_outline(snap_compare): + """Outline style rendered incorrectly when applied to a `Button` widget. + + Regression test for https://github.com/Textualize/textual/issues/3628 + """ + assert snap_compare(SNAPSHOT_APPS_DIR / "button_outline.py") + + +def test_notifications_loading_overlap_order(snap_compare): + """Regression test for https://github.com/Textualize/textual/issues/3677. + + This tests that notifications stay on top of loading indicators and it also + tests that loading a widget will remove its scrollbars. + """ + assert snap_compare( + SNAPSHOT_APPS_DIR / "notifications_above_loading.py", terminal_size=(80, 20) + ) + + +def test_missing_vertical_scroll(snap_compare): + """Regression test for https://github.com/Textualize/textual/issues/3687""" + assert snap_compare(SNAPSHOT_APPS_DIR / "missing_vertical_scroll.py") + + +def test_vertical_min_height(snap_compare): + """Test vertical min height takes border in to account.""" + assert snap_compare(SNAPSHOT_APPS_DIR / "vertical_min_height.py") + + +def test_vertical_max_height(snap_compare): + """Test vertical max height takes border in to account.""" + assert snap_compare(SNAPSHOT_APPS_DIR / "vertical_max_height.py") + + +def test_max_height_100(snap_compare): + """Test vertical max height takes border in to account.""" + assert snap_compare(SNAPSHOT_APPS_DIR / "max_height_100.py") + + +def test_loading_indicator(snap_compare): + """Test loading indicator.""" + # https://github.com/Textualize/textual/pull/3816 + assert snap_compare(SNAPSHOT_APPS_DIR / "loading.py", press=["space"]) + + +def test_loading_indicator_disables_widget(snap_compare): + """Test loading indicator disabled widget.""" + # https://github.com/Textualize/textual/pull/3816 + assert snap_compare( + SNAPSHOT_APPS_DIR / "loading.py", press=["space", "down", "down", "space"] + ) + + +def test_mount_style_fix(snap_compare): + """Regression test for broken style update on mount.""" + # https://github.com/Textualize/textual/issues/3858 + assert snap_compare(SNAPSHOT_APPS_DIR / "mount_style_fix.py") + + +def test_zero_scrollbar_size(snap_compare): + """Regression test for missing content with 0 sized scrollbars""" + # https://github.com/Textualize/textual/issues/3886 + assert snap_compare(SNAPSHOT_APPS_DIR / "zero_scrollbar_size.py") + + +def test_tree_clearing_and_expansion(snap_compare): + """Test the Tree.root.is_expanded state after a Tree.clear""" + # https://github.com/Textualize/textual/issues/3557 + assert snap_compare(SNAPSHOT_APPS_DIR / "tree_clearing.py") + diff --git a/tests/test_animation.py b/tests/test_animation.py index 3aca1d802f..5428f7a91a 100644 --- a/tests/test_animation.py +++ b/tests/test_animation.py @@ -51,8 +51,8 @@ async def test_scheduling_animation() -> None: styles.animate("background", "white", delay=delay, duration=0) - await pilot.pause(0.9 * delay) - assert styles.background.rgb == (0, 0, 0) # Still black + # Still black immediately after call, animation hasn't started yet due to `delay` + assert styles.background.rgb == (0, 0, 0) await pilot.wait_for_scheduled_animations() assert styles.background.rgb == (255, 255, 255) @@ -153,8 +153,8 @@ async def test_schedule_reverse_animations() -> None: assert styles.background.rgb == (0, 0, 0) # Now, the actual test is to make sure we go back to black if scheduling both at once. - styles.animate("background", "white", delay=0.05, duration=0.01) - await pilot.pause() + styles.animate("background", "white", delay=0.025, duration=0.05) + # While the black -> white animation runs, start the white -> black animation. styles.animate("background", "black", delay=0.05, duration=0.01) await pilot.wait_for_scheduled_animations() assert styles.background.rgb == (0, 0, 0) diff --git a/tests/test_app.py b/tests/test_app.py index 09e810bab1..e5afed1e2a 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -30,14 +30,25 @@ async def test_hover_update_styles(): app = MyApp() async with app.run_test() as pilot: button = app.query_one(Button) - assert button.pseudo_classes == {"enabled", "can-focus"} + assert button.pseudo_classes == { + "blur", + "can-focus", + "dark", + "enabled", + } # Take note of the initial background colour initial_background = button.styles.background await pilot.hover(Button) # We've hovered, so ensure the pseudoclass is present and background changed - assert button.pseudo_classes == {"enabled", "hover", "can-focus"} + assert button.pseudo_classes == { + "blur", + "can-focus", + "dark", + "enabled", + "hover", + } assert button.styles.background != initial_background diff --git a/tests/test_arrange.py b/tests/test_arrange.py index 38f5a191d0..ffedb6d787 100644 --- a/tests/test_arrange.py +++ b/tests/test_arrange.py @@ -2,6 +2,7 @@ from textual._arrange import TOP_Z, arrange from textual._layout import WidgetPlacement +from textual.app import App from textual.geometry import Region, Size, Spacing from textual.widget import Widget @@ -16,6 +17,7 @@ def test_arrange_empty(): def test_arrange_dock_top(): container = Widget(id="container") + container._parent = App() child = Widget(id="child") header = Widget(id="header") header.styles.dock = "top" @@ -34,6 +36,7 @@ def test_arrange_dock_top(): def test_arrange_dock_left(): container = Widget(id="container") + container._parent = App() child = Widget(id="child") header = Widget(id="header") header.styles.dock = "left" @@ -51,6 +54,7 @@ def test_arrange_dock_left(): def test_arrange_dock_right(): container = Widget(id="container") + container._parent = App() child = Widget(id="child") header = Widget(id="header") header.styles.dock = "right" @@ -68,6 +72,7 @@ def test_arrange_dock_right(): def test_arrange_dock_bottom(): container = Widget(id="container") + container._parent = App() child = Widget(id="child") header = Widget(id="header") header.styles.dock = "bottom" diff --git a/tests/test_box_drawing.py b/tests/test_box_drawing.py new file mode 100644 index 0000000000..66d3e78520 --- /dev/null +++ b/tests/test_box_drawing.py @@ -0,0 +1,25 @@ +import pytest + +from textual._box_drawing import combine_quads + + +@pytest.mark.parametrize( + "quad1, quad2, expected", + [ + ((0, 0, 0, 0), (0, 0, 0, 0), (0, 0, 0, 0)), + ((0, 0, 0, 1), (0, 0, 0, 0), (0, 0, 0, 1)), + ((0, 0, 0, 1), (0, 0, 0, 1), (0, 0, 0, 1)), + ((0, 0, 0, 2), (0, 0, 0, 1), (0, 0, 0, 1)), + ((0, 0, 0, 2), (1, 2, 3, 0), (1, 2, 3, 2)), + ((0, 1, 0, 2), (1, 0, 3, 0), (1, 1, 3, 2)), + # Repeating to check cached values + ((0, 0, 0, 0), (0, 0, 0, 0), (0, 0, 0, 0)), + ((0, 0, 0, 1), (0, 0, 0, 0), (0, 0, 0, 1)), + ((0, 0, 0, 1), (0, 0, 0, 1), (0, 0, 0, 1)), + ((0, 0, 0, 2), (0, 0, 0, 1), (0, 0, 0, 1)), + ((0, 0, 0, 2), (1, 2, 3, 0), (1, 2, 3, 2)), + ((0, 1, 0, 2), (1, 0, 3, 0), (1, 1, 3, 2)), + ], +) +def test_box_combine_quads(quad1, quad2, expected): + assert combine_quads(quad1, quad2) == expected diff --git a/tests/test_cache.py b/tests/test_cache.py index ed2cb028f8..935c5dbfbe 100644 --- a/tests/test_cache.py +++ b/tests/test_cache.py @@ -259,3 +259,29 @@ def test_discard(): cache.discard("key4") # key that does not exist assert len(cache) == 2 # size should not change + + +def test_discard_regression(): + """Regression test for https://github.com/Textualize/textual/issues/3537""" + + cache = LRUCache(maxsize=3) + cache[1] = "foo" + cache[2] = "bar" + cache[3] = "baz" + cache[4] = "egg" + + assert cache.keys() == {2, 3, 4} + + cache.discard(2) + assert cache.keys() == {3, 4} + + cache[5] = "bob" + assert cache.keys() == {3, 4, 5} + + cache.discard(5) + assert cache.keys() == {3, 4} + + cache.discard(4) + cache.discard(3) + + assert cache.keys() == set() diff --git a/tests/test_collapsible.py b/tests/test_collapsible.py index db6f4c2147..116ac74c9d 100644 --- a/tests/test_collapsible.py +++ b/tests/test_collapsible.py @@ -1,5 +1,6 @@ from __future__ import annotations +from textual import on from textual.app import App, ComposeResult from textual.widgets import Collapsible, Label from textual.widgets._collapsible import CollapsibleTitle @@ -115,3 +116,88 @@ def compose(self) -> ComposeResult: await pilot.click(CollapsibleTitle) assert not collapsible.collapsed + + +async def test_toggle_message(): + """Toggling should post a message.""" + + hits = [] + + class CollapsibleApp(App[None]): + def compose(self) -> ComposeResult: + yield Collapsible(collapsed=True) + + @on(Collapsible.Toggled) + def catch_collapsible_events(self) -> None: + hits.append("toggled") + + async with CollapsibleApp().run_test() as pilot: + assert pilot.app.query_one(Collapsible).collapsed + + await pilot.click(CollapsibleTitle) + await pilot.pause() + + assert not pilot.app.query_one(Collapsible).collapsed + assert len(hits) == 1 + + await pilot.click(CollapsibleTitle) + await pilot.pause() + + assert pilot.app.query_one(Collapsible).collapsed + assert len(hits) == 2 + + +async def test_expand_message(): + """Toggling 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 + + await pilot.click(CollapsibleTitle) + 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.""" + + 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 + + await pilot.click(CollapsibleTitle) + 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: + yield Collapsible(title="Old title") + + async with CollapsibleApp().run_test() as pilot: + collapsible = pilot.app.query_one(Collapsible) + assert get_title(collapsible).label == "Old title" + collapsible.title = "New title" + assert get_title(collapsible).label == "New title" diff --git a/tests/test_containers.py b/tests/test_containers.py index 22a4ad0ec7..52fddf1a71 100644 --- a/tests/test_containers.py +++ b/tests/test_containers.py @@ -98,3 +98,24 @@ def compose(self) -> ComposeResult: middle = app.query_one(Middle) assert middle.size.width == 4 assert middle.size.height == app.size.height + + +async def test_scrollbar_zero_thickness(): + """Ensuring that scrollbars can be set to zero thickness.""" + + class ScrollbarZero(App): + CSS = """* { + scrollbar-size: 0 0; + scrollbar-size-vertical: 0; /* just exercising the parser */ + scrollbar-size-horizontal: 0; /* exercise the parser */ + } + """ + + def compose(self) -> ComposeResult: + with Vertical(): + for _ in range(10): + yield Label("Hello, world!") + + app = ScrollbarZero() + async with app.run_test(size=(8, 6)): + pass diff --git a/tests/test_data_table.py b/tests/test_data_table.py index e00b9432a4..70b6ffe473 100644 --- a/tests/test_data_table.py +++ b/tests/test_data_table.py @@ -1,15 +1,17 @@ from __future__ import annotations import pytest +from rich.panel import Panel from rich.text import Text from textual._wait import wait_for_idle from textual.actions import SkipAction -from textual.app import App +from textual.app import App, RenderableType from textual.coordinate import Coordinate from textual.geometry import Offset from textual.message import Message from textual.widgets import DataTable +from textual.widgets._data_table import _DEFAULT_CELL_X_PADDING from textual.widgets.data_table import ( CellDoesNotExist, CellKey, @@ -270,7 +272,10 @@ async def test_add_column_with_width(): row = table.add_row("123") assert table.get_cell(row, column) == "123" assert table.columns[column].width == 10 - assert table.columns[column].render_width == 12 # 10 + (2 padding) + assert ( + table.columns[column].get_render_width(table) + == 10 + 2 * _DEFAULT_CELL_X_PADDING + ) async def test_add_columns(): @@ -304,6 +309,19 @@ async def test_remove_row(): assert len(table.rows) == 2 +async def test_remove_row_and_update(): + """Regression test for https://github.com/Textualize/textual/issues/3470 - + Crash when attempting to remove and update the same cell.""" + app = DataTableApp() + async with app.run_test() as pilot: + table: DataTable = app.query_one(DataTable) + table.add_column("A", key="A") + table.add_row("1", key="1") + table.update_cell("1", "A", "X", update_width=True) + table.remove_row("1") + await pilot.pause() + + async def test_remove_column(): app = DataTableApp() async with app.run_test(): @@ -318,6 +336,19 @@ async def test_remove_column(): assert table.get_row_at(2) == ["2/1"] +async def test_remove_column_and_update(): + """Regression test for https://github.com/Textualize/textual/issues/3470 - + Crash when attempting to remove and update the same cell.""" + app = DataTableApp() + async with app.run_test() as pilot: + table: DataTable = app.query_one(DataTable) + table.add_column("A", key="A") + table.add_row("1", key="1") + table.update_cell("1", "A", "X", update_width=True) + table.remove_column("A") + await pilot.pause() + + async def test_clear(): app = DataTableApp() async with app.run_test(): @@ -419,11 +450,11 @@ async def test_get_cell_coordinate_returns_coordinate(): table.add_row("ValR2C1", "ValR2C2", "ValR2C3", key="R2") table.add_row("ValR3C1", "ValR3C2", "ValR3C3", key="R3") - assert table.get_cell_coordinate('R1', 'C1') == Coordinate(0, 0) - assert table.get_cell_coordinate('R2', 'C2') == Coordinate(1, 1) - assert table.get_cell_coordinate('R1', 'C3') == Coordinate(0, 2) - assert table.get_cell_coordinate('R3', 'C1') == Coordinate(2, 0) - assert table.get_cell_coordinate('R3', 'C2') == Coordinate(2, 1) + assert table.get_cell_coordinate("R1", "C1") == Coordinate(0, 0) + assert table.get_cell_coordinate("R2", "C2") == Coordinate(1, 1) + assert table.get_cell_coordinate("R1", "C3") == Coordinate(0, 2) + assert table.get_cell_coordinate("R3", "C1") == Coordinate(2, 0) + assert table.get_cell_coordinate("R3", "C2") == Coordinate(2, 1) async def test_get_cell_coordinate_invalid_row_key(): @@ -434,7 +465,7 @@ async def test_get_cell_coordinate_invalid_row_key(): table.add_row("TargetValue", key="R1") with pytest.raises(CellDoesNotExist): - coordinate = table.get_cell_coordinate('INVALID_ROW', 'C1') + coordinate = table.get_cell_coordinate("INVALID_ROW", "C1") async def test_get_cell_coordinate_invalid_column_key(): @@ -445,7 +476,7 @@ async def test_get_cell_coordinate_invalid_column_key(): table.add_row("TargetValue", key="R1") with pytest.raises(CellDoesNotExist): - coordinate = table.get_cell_coordinate('R1', 'INVALID_COLUMN') + coordinate = table.get_cell_coordinate("R1", "INVALID_COLUMN") async def test_get_cell_at_returns_value_at_cell(): @@ -531,9 +562,9 @@ async def test_get_row_index_returns_index(): table.add_row("ValR2C1", "ValR2C2", key="R2") table.add_row("ValR3C1", "ValR3C2", key="R3") - assert table.get_row_index('R1') == 0 - assert table.get_row_index('R2') == 1 - assert table.get_row_index('R3') == 2 + assert table.get_row_index("R1") == 0 + assert table.get_row_index("R2") == 1 + assert table.get_row_index("R3") == 2 async def test_get_row_index_invalid_row_key(): @@ -544,7 +575,7 @@ async def test_get_row_index_invalid_row_key(): table.add_row("TargetValue", key="R1") with pytest.raises(RowDoesNotExist): - index = table.get_row_index('InvalidRow') + index = table.get_row_index("InvalidRow") async def test_get_column(): @@ -591,6 +622,7 @@ async def test_get_column_at_invalid_index(index): with pytest.raises(ColumnDoesNotExist): list(table.get_column_at(index)) + async def test_get_column_index_returns_index(): app = DataTableApp() async with app.run_test(): @@ -598,12 +630,12 @@ async def test_get_column_index_returns_index(): table.add_column("Column1", key="C1") table.add_column("Column2", key="C2") table.add_column("Column3", key="C3") - table.add_row("ValR1C1", "ValR1C2", "ValR1C3", key="R1") - table.add_row("ValR2C1", "ValR2C2", "ValR2C3", key="R2") + table.add_row("ValR1C1", "ValR1C2", "ValR1C3", key="R1") + table.add_row("ValR2C1", "ValR2C2", "ValR2C3", key="R2") - assert table.get_column_index('C1') == 0 - assert table.get_column_index('C2') == 1 - assert table.get_column_index('C3') == 2 + assert table.get_column_index("C1") == 0 + assert table.get_column_index("C2") == 1 + assert table.get_column_index("C3") == 2 async def test_get_column_index_invalid_column_key(): @@ -613,11 +645,10 @@ async def test_get_column_index_invalid_column_key(): table.add_column("Column1", key="C1") table.add_column("Column2", key="C2") table.add_column("Column3", key="C3") - table.add_row("TargetValue1", "TargetValue2", "TargetValue3", key="R1") + table.add_row("TargetValue1", "TargetValue2", "TargetValue3", key="R1") with pytest.raises(ColumnDoesNotExist): - index = table.get_column_index('InvalidCol') - + index = table.get_column_index("InvalidCol") async def test_update_cell_cell_exists(): @@ -640,6 +671,17 @@ async def test_update_cell_cell_doesnt_exist(): table.update_cell("INVALID", "CELL", "Value") +async def test_update_cell_invalid_column_key(): + """Regression test for https://github.com/Textualize/textual/issues/3335""" + app = DataTableApp() + async with app.run_test(): + table = app.query_one(DataTable) + table.add_column("Column1", key="C1") + table.add_row("TargetValue", key="R1") + with pytest.raises(CellDoesNotExist): + table.update_cell("R1", "INVALID_COLUMN", "New Value") + + async def test_update_cell_at_coordinate_exists(): app = DataTableApp() async with app.run_test(): @@ -688,7 +730,10 @@ async def test_update_cell_at_column_width(label, new_value, new_content_width): table.update_cell_at(Coordinate(0, 0), new_value, update_width=True) await wait_for_idle() assert first_column.content_width == new_content_width - assert first_column.render_width == new_content_width + 2 + assert ( + first_column.get_render_width(table) + == new_content_width + 2 * _DEFAULT_CELL_X_PADDING + ) async def test_coordinate_to_cell_key(): @@ -814,7 +859,7 @@ async def test_hover_mouse_leave(): await pilot.hover(DataTable, offset=Offset(1, 1)) assert table._show_hover_cursor # Move our cursor away from the DataTable, and the hover cursor is hidden - await pilot.hover(DataTable, offset=Offset(-1, -1)) + await pilot.hover(DataTable, offset=Offset(20, 20)) assert not table._show_hover_cursor @@ -1161,3 +1206,179 @@ async def test_unset_hover_highlight_when_no_table_cell_under_mouse(): # the widget, and the hover cursor is hidden await pilot.hover(DataTable, offset=Offset(42, 1)) assert not table._show_hover_cursor + + +async def test_sort_by_all_columns_no_key(): + """Test sorting a `DataTable` by all columns.""" + + app = DataTableApp() + async with app.run_test(): + table = app.query_one(DataTable) + a, b, c = table.add_columns("A", "B", "C") + table.add_row(1, 3, 8) + table.add_row(2, 9, 5) + table.add_row(1, 1, 9) + assert table.get_row_at(0) == [1, 3, 8] + assert table.get_row_at(1) == [2, 9, 5] + assert table.get_row_at(2) == [1, 1, 9] + + table.sort() + assert table.get_row_at(0) == [1, 1, 9] + assert table.get_row_at(1) == [1, 3, 8] + assert table.get_row_at(2) == [2, 9, 5] + + table.sort(reverse=True) + assert table.get_row_at(0) == [2, 9, 5] + assert table.get_row_at(1) == [1, 3, 8] + assert table.get_row_at(2) == [1, 1, 9] + + +async def test_sort_by_multiple_columns_no_key(): + """Test sorting a `DataTable` by multiple columns.""" + + app = DataTableApp() + async with app.run_test(): + table = app.query_one(DataTable) + a, b, c = table.add_columns("A", "B", "C") + table.add_row(1, 3, 8) + table.add_row(2, 9, 5) + table.add_row(1, 1, 9) + + table.sort(a, b, c) + assert table.get_row_at(0) == [1, 1, 9] + assert table.get_row_at(1) == [1, 3, 8] + assert table.get_row_at(2) == [2, 9, 5] + + table.sort(a, c, b) + assert table.get_row_at(0) == [1, 3, 8] + assert table.get_row_at(1) == [1, 1, 9] + assert table.get_row_at(2) == [2, 9, 5] + + table.sort(c, a, b, reverse=True) + assert table.get_row_at(0) == [1, 1, 9] + assert table.get_row_at(1) == [1, 3, 8] + assert table.get_row_at(2) == [2, 9, 5] + + table.sort(a, c) + assert table.get_row_at(0) == [1, 3, 8] + assert table.get_row_at(1) == [1, 1, 9] + assert table.get_row_at(2) == [2, 9, 5] + + +async def test_sort_by_function_sum(): + """Test sorting a `DataTable` using a custom sort function.""" + + def custom_sort(row_data): + return sum(row_data) + + row_data = ( + [1, 3, 8], # SUM=12 + [2, 9, 5], # SUM=16 + [1, 1, 9], # SUM=11 + ) + + app = DataTableApp() + async with app.run_test(): + table = app.query_one(DataTable) + a, b, c = table.add_columns("A", "B", "C") + for i, row in enumerate(row_data): + table.add_row(*row) + + # Sorting by all columns + table.sort(a, b, c, key=custom_sort) + sorted_row_data = sorted(row_data, key=sum) + for i, row in enumerate(sorted_row_data): + assert table.get_row_at(i) == row + + # Passing a sort function but no columns also sorts by all columns + table.sort(key=custom_sort) + sorted_row_data = sorted(row_data, key=sum) + for i, row in enumerate(sorted_row_data): + assert table.get_row_at(i) == row + + table.sort(a, b, c, key=custom_sort, reverse=True) + sorted_row_data = sorted(row_data, key=sum, reverse=True) + for i, row in enumerate(sorted_row_data): + assert table.get_row_at(i) == row + + +@pytest.mark.parametrize( + ["cell", "height"], + [ + ("hey there", 1), + (Text("hey there"), 1), + (Text("long string", overflow="fold"), 2), + (Panel.fit("Hello\nworld"), 4), + ("1\n2\n3\n4\n5\n6\n7", 7), + ], +) +async def test_add_row_auto_height(cell: RenderableType, height: int): + app = DataTableApp() + async with app.run_test() as pilot: + table = app.query_one(DataTable) + table.add_column("C", width=10) + row_key = table.add_row(cell, height=None) + row = table.rows.get(row_key) + await pilot.pause() + assert row.height == height + + +async def test_add_row_expands_column_widths(): + """Regression test for https://github.com/Textualize/textual/issues/1026.""" + app = DataTableApp() + + async with app.run_test() as pilot: + table = app.query_one(DataTable) + table.add_column("First") + table.add_column("Second", width=10) + await pilot.pause() + assert ( + table.ordered_columns[0].get_render_width(table) + == 5 + 2 * _DEFAULT_CELL_X_PADDING + ) + assert ( + table.ordered_columns[1].get_render_width(table) + == 10 + 2 * _DEFAULT_CELL_X_PADDING + ) + + table.add_row("a" * 20, "a" * 20) + await pilot.pause() + assert ( + table.ordered_columns[0].get_render_width(table) + == 20 + 2 * _DEFAULT_CELL_X_PADDING + ) + assert ( + table.ordered_columns[1].get_render_width(table) + == 10 + 2 * _DEFAULT_CELL_X_PADDING + ) + + +async def test_cell_padding_updates_virtual_size(): + app = DataTableApp() + + async with app.run_test() as pilot: + table = app.query_one(DataTable) + table.add_column("First") + table.add_column("Second", width=10) + table.add_column("Third") + + width = table.virtual_size.width + + table.cell_padding += 5 + assert width + 5 * 2 * 3 == table.virtual_size.width + + table.cell_padding -= 2 + assert width + 3 * 2 * 3 == table.virtual_size.width + + table.cell_padding += 10 + assert width + 13 * 2 * 3 == table.virtual_size.width + + +async def test_cell_padding_cannot_be_negative(): + app = DataTableApp() + async with app.run_test(): + table = app.query_one(DataTable) + table.cell_padding = -3 + assert table.cell_padding == 0 + table.cell_padding = -1234 + assert table.cell_padding == 0 diff --git a/tests/test_dom.py b/tests/test_dom.py index 1b354067ce..6f06fb8091 100644 --- a/tests/test_dom.py +++ b/tests/test_dom.py @@ -150,8 +150,8 @@ class E(D): assert c_css[0][2] == d_css[0][2] + 1 == 0 # The CSS on the stack is the correct one. - assert e_css[0][1:] == ("E", 0) - assert e_css[1][1:] == ("C", -2) + assert e_css[0][1:] == ("E", 0, "E") + assert e_css[1][1:] == ("C", -2, "C") def test_component_classes_inheritance(): diff --git a/tests/test_driver.py b/tests/test_driver.py new file mode 100644 index 0000000000..e3b5feba81 --- /dev/null +++ b/tests/test_driver.py @@ -0,0 +1,125 @@ +from textual import on +from textual.app import App +from textual.events import Click, MouseDown, MouseUp +from textual.widgets import Button + + +async def test_driver_mouse_down_up_click(): + """Mouse down and up should issue a click.""" + + class MyApp(App): + messages = [] + + @on(Click) + @on(MouseDown) + @on(MouseUp) + def handle(self, event): + self.messages.append(event) + + app = MyApp() + async with app.run_test() as pilot: + app._driver.process_event(MouseDown(0, 0, 0, 0, 1, False, False, False)) + app._driver.process_event(MouseUp(0, 0, 0, 0, 1, False, False, False)) + await pilot.pause() + assert len(app.messages) == 3 + assert isinstance(app.messages[0], MouseDown) + assert isinstance(app.messages[1], MouseUp) + assert isinstance(app.messages[2], Click) + + +async def test_driver_mouse_down_up_click_widget(): + """Mouse down and up should issue a click when they're on a widget.""" + + class MyApp(App): + messages = [] + + def compose(self): + yield Button() + + def on_button_pressed(self, event): + self.messages.append(event) + + app = MyApp() + async with app.run_test() as pilot: + app._driver.process_event(MouseDown(0, 0, 0, 0, 1, False, False, False)) + app._driver.process_event(MouseUp(0, 0, 0, 0, 1, False, False, False)) + await pilot.pause() + assert len(app.messages) == 1 + + +async def test_driver_mouse_down_drag_inside_widget_up_click(): + """Mouse down and up should issue a click, even if the mouse moves but remains + inside the same widget.""" + + class MyApp(App): + messages = [] + + def compose(self): + yield Button() + + def on_button_pressed(self, event): + self.messages.append(event) + + app = MyApp() + button_width = 16 + button_height = 3 + async with app.run_test() as pilot: + # Sanity check + width, height = app.query_one(Button).region.size + assert (width, height) == (button_width, button_height) + + # Mouse down on the button, then move the mouse inside the button, then mouse up. + app._driver.process_event(MouseDown(0, 0, 0, 0, 1, False, False, False)) + app._driver.process_event( + MouseUp( + button_width - 1, + button_height - 1, + button_width - 1, + button_height - 1, + 1, + False, + False, + False, + ) + ) + await pilot.pause() + # A click should still be triggered. + assert len(app.messages) == 1 + + +async def test_driver_mouse_down_drag_outside_widget_up_click(): + """Mouse down and up don't issue a click if the mouse moves outside of the initial widget.""" + + class MyApp(App): + messages = [] + + def compose(self): + yield Button() + + def on_button_pressed(self, event): + self.messages.append(event) + + app = MyApp() + button_width = 16 + button_height = 3 + async with app.run_test() as pilot: + # Sanity check + width, height = app.query_one(Button).region.size + assert (width, height) == (button_width, button_height) + + # Mouse down on the button, then move the mouse outside the button, then mouse up. + app._driver.process_event(MouseDown(0, 0, 0, 0, 1, False, False, False)) + app._driver.process_event( + MouseUp( + button_width + 1, + button_height + 1, + button_width + 1, + button_height + 1, + 1, + False, + False, + False, + ) + ) + await pilot.pause() + assert len(app.messages) == 0 diff --git a/tests/test_expand_tabs.py b/tests/test_expand_tabs.py new file mode 100644 index 0000000000..d1d31fb110 --- /dev/null +++ b/tests/test_expand_tabs.py @@ -0,0 +1,25 @@ +import pytest + +from textual.expand_tabs import expand_tabs_inline + + +@pytest.mark.parametrize( + "line, expanded_line", + [ + (" b ar ", " b ar "), + ("\tbar", " bar"), + ("\tbar\t", " bar "), + ("\tr\t", " r "), + ("1\tbar", "1 bar"), + ("12\tbar", "12 bar"), + ("123\tbar", "123 bar"), + ("1234\tbar", "1234 bar"), + ("💩\tbar", "💩 bar"), + ("💩💩\tbar", "💩💩 bar"), + ("💩💩💩\tbar", "💩💩💩 bar"), + ("F💩\tbar", "F💩 bar"), + ("F💩O\tbar", "F💩O bar"), + ], +) +def test_expand_tabs_inline(line, expanded_line): + assert expand_tabs_inline(line) == expanded_line diff --git a/tests/test_focus.py b/tests/test_focus.py index 489d808c26..90f3db220c 100644 --- a/tests/test_focus.py +++ b/tests/test_focus.py @@ -1,6 +1,6 @@ import pytest -from textual.app import App +from textual.app import App, ComposeResult from textual.containers import Container from textual.screen import Screen from textual.widget import Widget @@ -309,3 +309,63 @@ def compose(self): w11, w12, ] + + +async def test_mouse_down_gives_focus(): + class MyApp(App): + AUTO_FOCUS = None + + def compose(self): + yield Button() + + app = MyApp() + async with app.run_test() as pilot: + # Sanity check. + assert app.focused is None + + await pilot.mouse_down(Button) + assert isinstance(app.focused, Button) + + +async def test_mouse_up_does_not_give_focus(): + class MyApp(App): + AUTO_FOCUS = None + + def compose(self): + yield Button() + + app = MyApp() + async with app.run_test() as pilot: + # Sanity check. + assert app.focused is None + + await pilot.mouse_up(Button) + assert app.focused is None + + +async def test_focus_pseudo_class(): + """Test focus and blue pseudo classes""" + + # https://github.com/Textualize/textual/pull/3645 + class FocusApp(App): + AUTO_FOCUS = None + + def compose(self) -> ComposeResult: + yield Button("Hello") + + app = FocusApp() + async with app.run_test() as pilot: + button = app.query_one(Button) + classes = list(button.get_pseudo_classes()) + # Blurred, not focused + assert "blur" in classes + assert "focus" not in classes + + # Focus the button + button.focus() + await pilot.pause() + + # Focused, not blurred + classes = list(button.get_pseudo_classes()) + assert "blur" not in classes + assert "focus" in classes diff --git a/tests/test_lazy.py b/tests/test_lazy.py new file mode 100644 index 0000000000..a399b1af7f --- /dev/null +++ b/tests/test_lazy.py @@ -0,0 +1,26 @@ +from textual.app import App, ComposeResult +from textual.containers import Horizontal, Vertical +from textual.lazy import Lazy +from textual.widgets import Label + + +class LazyApp(App): + def compose(self) -> ComposeResult: + with Vertical(): + with Lazy(Horizontal()): + yield Label(id="foo") + with Horizontal(): + yield Label(id="bar") + + +async def test_lazy(): + app = LazyApp() + async with app.run_test() as pilot: + # No #foo on initial mount + assert len(app.query("#foo")) == 0 + assert len(app.query("#bar")) == 1 + await pilot.pause() + await pilot.pause() + # #bar mounted after refresh + assert len(app.query("#foo")) == 1 + assert len(app.query("#bar")) == 1 diff --git a/tests/test_loading.py b/tests/test_loading.py new file mode 100644 index 0000000000..262feac102 --- /dev/null +++ b/tests/test_loading.py @@ -0,0 +1,33 @@ +from textual.app import App, ComposeResult +from textual.containers import VerticalScroll +from textual.widgets import Label + + +class LoadingApp(App[None]): + CSS = """ + VerticalScroll { + height: 20; + } + """ + + BINDINGS = [("l", "loading")] + + def compose(self) -> ComposeResult: + with VerticalScroll(): + yield Label("another big label\n" * 30) # Ensure there's a scrollbar. + + def action_loading(self): + self.query_one(VerticalScroll).loading = True + + +async def test_loading_disables_and_remove_scrollbars(): + app = LoadingApp() + async with app.run_test() as pilot: + vs = app.query_one(VerticalScroll) + # Sanity checks: + assert not vs._check_disabled() + + await pilot.press("l") + await pilot.pause() + + assert vs._check_disabled() diff --git a/tests/test_markdown.py b/tests/test_markdown.py index 5002430d7d..da4c016aab 100644 --- a/tests/test_markdown.py +++ b/tests/test_markdown.py @@ -125,3 +125,18 @@ async def test_load_non_existing_file() -> None: await pilot.app.query_one(Markdown).load( Path("---this-does-not-exist---.it.is.not.a.md") ) + + +@pytest.mark.parametrize( + ("anchor", "found"), + [ + ("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.""" + document = "# Hello There\n\nGeneral.\n" + async with MarkdownApp(document).run_test() as pilot: + markdown = pilot.app.query_one(Markdown) + assert markdown.goto_anchor(anchor) is found diff --git a/tests/test_markdownviewer.py b/tests/test_markdownviewer.py index 27ccf0da99..8d94d4b946 100644 --- a/tests/test_markdownviewer.py +++ b/tests/test_markdownviewer.py @@ -1,10 +1,12 @@ from pathlib import Path import pytest +from rich.text import Text +import textual.widgets._markdown as MD from textual.app import App, ComposeResult from textual.geometry import Offset -from textual.widgets import Markdown, MarkdownViewer +from textual.widgets import Markdown, MarkdownViewer, Tree TEST_MARKDOWN = """\ * [First]({{file}}#first) @@ -45,8 +47,12 @@ async def test_markdown_file_viewer_anchor_link(tmp_path, link: int) -> None: class MarkdownStringViewerApp(App[None]): + def __init__(self, markdown_string: str) -> None: + self.markdown_string = markdown_string + super().__init__() + def compose(self) -> ComposeResult: - yield MarkdownViewer(TEST_MARKDOWN.replace("{{file}}", "")) + yield MarkdownViewer(self.markdown_string) async def on_mount(self) -> None: self.query_one(MarkdownViewer).show_table_of_contents = False @@ -57,8 +63,27 @@ async def test_markdown_string_viewer_anchor_link(link: int) -> None: """Test https://github.com/Textualize/textual/issues/3094 Also https://github.com/Textualize/textual/pull/3244#issuecomment-1710278718.""" - async with MarkdownStringViewerApp().run_test() as pilot: + async with MarkdownStringViewerApp( + TEST_MARKDOWN.replace("{{file}}", "") + ).run_test() as pilot: # There's not really anything to test *for* here, but the lack of an # exception is the win (before the fix this is testing it would have # been FileNotFoundError). await pilot.click(Markdown, Offset(2, link)) + + +@pytest.mark.parametrize("text", ["Hey [[/test]]", "[i]Hey there[/i]"]) +async def test_headings_that_look_like_they_contain_markup(text: str) -> None: + """Regression test for https://github.com/Textualize/textual/issues/3689. + + Things that look like markup are escaped in markdown headings in the table of contents. + """ + + document = f"# {text}" + async with MarkdownStringViewerApp(document).run_test() as pilot: + assert pilot.app.query_one(MD.MarkdownH1)._text == Text(text) + toc_tree = pilot.app.query_one(MD.MarkdownTableOfContents).query_one(Tree) + # The toc label looks like "I {text}" but the I is styled so we drop it. + toc_label = toc_tree.root.children[0].label + _, text_label = toc_label.divide([2]) + assert text_label == Text(text) diff --git a/tests/test_mount.py b/tests/test_mount.py new file mode 100644 index 0000000000..23567d2d06 --- /dev/null +++ b/tests/test_mount.py @@ -0,0 +1,27 @@ +"""Regression test for https://github.com/Textualize/textual/issues/2914 + +Make sure that calls to render only happen after a widget being mounted. +""" + +import asyncio + +from textual.app import App +from textual.widget import Widget + + +class W(Widget): + def render(self): + return self.renderable + + async def on_mount(self): + await asyncio.sleep(0.1) + self.renderable = "1234" + + +async def test_render_only_after_mount(): + """Regression test for https://github.com/Textualize/textual/issues/2914""" + app = App() + async with app.run_test() as pilot: + app.mount(W()) + app.mount(W()) + await pilot.pause() diff --git a/tests/test_on.py b/tests/test_on.py index 2bfaf476a6..6ca0be9fd0 100644 --- a/tests/test_on.py +++ b/tests/test_on.py @@ -134,11 +134,11 @@ def compose(self) -> ComposeResult: def on_mount(self) -> None: self.query_one(TabbedContent).add_class("tabs") - @on(TabbedContent.TabActivated, tab="#one") + @on(TabbedContent.TabActivated, pane="#one") def one(self) -> None: log.append("one") - @on(TabbedContent.TabActivated, ".tabs", tab="#two") + @on(TabbedContent.TabActivated, pane="#two") def two(self) -> None: log.append("two") @@ -189,7 +189,6 @@ def on_mount(self) -> None: self.query_one(MessageSender).post_parent() self.query_one(MessageSender).post_child() - async with InheritTestApp().run_test(): pass diff --git a/tests/test_pilot.py b/tests/test_pilot.py index d631146c77..0650c258a9 100644 --- a/tests/test_pilot.py +++ b/tests/test_pilot.py @@ -5,12 +5,44 @@ from textual import events from textual.app import App, ComposeResult from textual.binding import Binding -from textual.widgets import Label +from textual.containers import Center, Middle +from textual.events import MouseDown, MouseUp +from textual.pilot import OutOfBounds +from textual.widgets import Button, Label KEY_CHARACTERS_TO_TEST = "akTW03" + punctuation """Test some "simple" characters (letters + digits) and all punctuation.""" +class CenteredButtonApp(App): + CSS = """ # Ensure the button is 16 x 3 + Button { + min-width: 16; + max-width: 16; + width: 16; + min-height: 3; + max-height: 3; + height: 3; + } + """ + + def compose(self): + with Center(): + with Middle(): + yield Button() + + +class ManyLabelsApp(App): + """Auxiliary app with a button following many labels.""" + + AUTO_FOCUS = None # So that there's no auto-scrolling. + + def compose(self): + for idx in range(100): + yield Label(f"label {idx}", id=f"label{idx}") + yield Button() + + async def test_pilot_press_ascii_chars(): """Test that the pilot can press most ASCII characters as keys.""" keys_pressed = [] @@ -52,3 +84,271 @@ def action_beep(self) -> None: with pytest.raises(ZeroDivisionError): async with FailingApp().run_test() as pilot: await pilot.press("b") + + +async def test_pilot_click_screen(): + """Regression test for https://github.com/Textualize/textual/issues/3395. + + Check we can use `Screen` as a selector for a click.""" + + async with App().run_test() as pilot: + await pilot.click("Screen") + + +async def test_pilot_hover_screen(): + """Regression test for https://github.com/Textualize/textual/issues/3395. + + Check we can use `Screen` as a selector for a hover.""" + + async with App().run_test() as pilot: + await pilot.hover("Screen") + + +@pytest.mark.parametrize( + ["method", "screen_size", "offset"], + [ + # + ("click", (80, 24), (100, 12)), # Right of screen. + ("click", (80, 24), (100, 36)), # Bottom-right of screen. + ("click", (80, 24), (50, 36)), # Under screen. + ("click", (80, 24), (-10, 36)), # Bottom-left of screen. + ("click", (80, 24), (-10, 12)), # Left of screen. + ("click", (80, 24), (-10, -2)), # Top-left of screen. + ("click", (80, 24), (50, -2)), # Above screen. + ("click", (80, 24), (100, -2)), # Top-right of screen. + # + ("click", (5, 5), (7, 3)), # Right of screen. + ("click", (5, 5), (7, 7)), # Bottom-right of screen. + ("click", (5, 5), (3, 7)), # Under screen. + ("click", (5, 5), (-1, 7)), # Bottom-left of screen. + ("click", (5, 5), (-1, 3)), # Left of screen. + ("click", (5, 5), (-1, -1)), # Top-left of screen. + ("click", (5, 5), (3, -1)), # Above screen. + ("click", (5, 5), (7, -1)), # Top-right of screen. + # + ("hover", (80, 24), (100, 12)), # Right of screen. + ("hover", (80, 24), (100, 36)), # Bottom-right of screen. + ("hover", (80, 24), (50, 36)), # Under screen. + ("hover", (80, 24), (-10, 36)), # Bottom-left of screen. + ("hover", (80, 24), (-10, 12)), # Left of screen. + ("hover", (80, 24), (-10, -2)), # Top-left of screen. + ("hover", (80, 24), (50, -2)), # Above screen. + ("hover", (80, 24), (100, -2)), # Top-right of screen. + # + ("hover", (5, 5), (7, 3)), # Right of screen. + ("hover", (5, 5), (7, 7)), # Bottom-right of screen. + ("hover", (5, 5), (3, 7)), # Under screen. + ("hover", (5, 5), (-1, 7)), # Bottom-left of screen. + ("hover", (5, 5), (-1, 3)), # Left of screen. + ("hover", (5, 5), (-1, -1)), # Top-left of screen. + ("hover", (5, 5), (3, -1)), # Above screen. + ("hover", (5, 5), (7, -1)), # Top-right of screen. + # + ("mouse_down", (80, 24), (100, 12)), # Right of screen. + ("mouse_down", (80, 24), (100, 36)), # Bottom-right of screen. + ("mouse_down", (80, 24), (50, 36)), # Under screen. + ("mouse_down", (80, 24), (-10, 36)), # Bottom-left of screen. + ("mouse_down", (80, 24), (-10, 12)), # Left of screen. + ("mouse_down", (80, 24), (-10, -2)), # Top-left of screen. + ("mouse_down", (80, 24), (50, -2)), # Above screen. + ("mouse_down", (80, 24), (100, -2)), # Top-right of screen. + # + ("mouse_down", (5, 5), (7, 3)), # Right of screen. + ("mouse_down", (5, 5), (7, 7)), # Bottom-right of screen. + ("mouse_down", (5, 5), (3, 7)), # Under screen. + ("mouse_down", (5, 5), (-1, 7)), # Bottom-left of screen. + ("mouse_down", (5, 5), (-1, 3)), # Left of screen. + ("mouse_down", (5, 5), (-1, -1)), # Top-left of screen. + ("mouse_down", (5, 5), (3, -1)), # Above screen. + ("mouse_down", (5, 5), (7, -1)), # Top-right of screen. + # + ("mouse_up", (80, 24), (100, 12)), # Right of screen. + ("mouse_up", (80, 24), (100, 36)), # Bottom-right of screen. + ("mouse_up", (80, 24), (50, 36)), # Under screen. + ("mouse_up", (80, 24), (-10, 36)), # Bottom-left of screen. + ("mouse_up", (80, 24), (-10, 12)), # Left of screen. + ("mouse_up", (80, 24), (-10, -2)), # Top-left of screen. + ("mouse_up", (80, 24), (50, -2)), # Above screen. + ("mouse_up", (80, 24), (100, -2)), # Top-right of screen. + # + ("mouse_up", (5, 5), (7, 3)), # Right of screen. + ("mouse_up", (5, 5), (7, 7)), # Bottom-right of screen. + ("mouse_up", (5, 5), (3, 7)), # Under screen. + ("mouse_up", (5, 5), (-1, 7)), # Bottom-left of screen. + ("mouse_up", (5, 5), (-1, 3)), # Left of screen. + ("mouse_up", (5, 5), (-1, -1)), # Top-left of screen. + ("mouse_up", (5, 5), (3, -1)), # Above screen. + ("mouse_up", (5, 5), (7, -1)), # Top-right of screen. + ], +) +async def test_pilot_target_outside_screen_errors(method, screen_size, offset): + """Make sure that targeting a click/hover completely outside of the screen errors.""" + app = App() + async with app.run_test(size=screen_size) as pilot: + pilot_method = getattr(pilot, method) + with pytest.raises(OutOfBounds): + await pilot_method(offset=offset) + + +@pytest.mark.parametrize( + ["method", "offset"], + [ + ("click", (0, 0)), # Top-left corner. + ("click", (40, 0)), # Top edge. + ("click", (79, 0)), # Top-right corner. + ("click", (79, 12)), # Right edge. + ("click", (79, 23)), # Bottom-right corner. + ("click", (40, 23)), # Bottom edge. + ("click", (40, 23)), # Bottom-left corner. + ("click", (0, 12)), # Left edge. + ("click", (40, 12)), # Right in the middle. + # + ("hover", (0, 0)), # Top-left corner. + ("hover", (40, 0)), # Top edge. + ("hover", (79, 0)), # Top-right corner. + ("hover", (79, 12)), # Right edge. + ("hover", (79, 23)), # Bottom-right corner. + ("hover", (40, 23)), # Bottom edge. + ("hover", (40, 23)), # Bottom-left corner. + ("hover", (0, 12)), # Left edge. + ("hover", (40, 12)), # Right in the middle. + # + ("mouse_down", (0, 0)), # Top-left corner. + ("mouse_down", (40, 0)), # Top edge. + ("mouse_down", (79, 0)), # Top-right corner. + ("mouse_down", (79, 12)), # Right edge. + ("mouse_down", (79, 23)), # Bottom-right corner. + ("mouse_down", (40, 23)), # Bottom edge. + ("mouse_down", (40, 23)), # Bottom-left corner. + ("mouse_down", (0, 12)), # Left edge. + ("mouse_down", (40, 12)), # Right in the middle. + # + ("mouse_up", (0, 0)), # Top-left corner. + ("mouse_up", (40, 0)), # Top edge. + ("mouse_up", (79, 0)), # Top-right corner. + ("mouse_up", (79, 12)), # Right edge. + ("mouse_up", (79, 23)), # Bottom-right corner. + ("mouse_up", (40, 23)), # Bottom edge. + ("mouse_up", (40, 23)), # Bottom-left corner. + ("mouse_up", (0, 12)), # Left edge. + ("mouse_up", (40, 12)), # Right in the middle. + ], +) +async def test_pilot_target_inside_screen_is_fine_with_correct_coordinate_system( + method, offset +): + """Make sure that the coordinate system for the click is the correct one. + + Especially relevant because I kept getting confused about the way it works. + """ + app = ManyLabelsApp() + async with app.run_test(size=(80, 24)) as pilot: + app.query_one("#label99").scroll_visible(animate=False) + await pilot.pause() + + pilot_method = getattr(pilot, method) + await pilot_method(offset=offset) + + +@pytest.mark.parametrize( + ["method", "target"], + [ + ("click", "#label0"), + ("click", "#label90"), + ("click", Button), + # + ("hover", "#label0"), + ("hover", "#label90"), + ("hover", Button), + # + ("mouse_down", "#label0"), + ("mouse_down", "#label90"), + ("mouse_down", Button), + # + ("mouse_up", "#label0"), + ("mouse_up", "#label90"), + ("mouse_up", Button), + ], +) +async def test_pilot_target_on_widget_that_is_not_visible_errors(method, target): + """Make sure that clicking a widget that is not scrolled into view raises an error.""" + app = ManyLabelsApp() + async with app.run_test(size=(80, 5)) as pilot: + app.query_one("#label50").scroll_visible(animate=False) + await pilot.pause() + + pilot_method = getattr(pilot, method) + with pytest.raises(OutOfBounds): + await pilot_method(target) + + +@pytest.mark.parametrize("method", ["click", "hover", "mouse_down", "mouse_up"]) +async def test_pilot_target_widget_under_another_widget(method): + """The targeting method should return False when the targeted widget is covered.""" + + class ObscuredButton(App): + CSS = """ + Label { + width: 30; + height: 5; + } + """ + + def compose(self): + yield Button() + yield Label() + + def on_mount(self): + self.query_one(Label).styles.offset = (0, -3) + + app = ObscuredButton() + async with app.run_test() as pilot: + await pilot.pause() + pilot_method = getattr(pilot, method) + assert (await pilot_method(Button)) is False + + +@pytest.mark.parametrize("method", ["click", "hover", "mouse_down", "mouse_up"]) +async def test_pilot_target_visible_widget(method): + """The targeting method should return True when the targeted widget is hit.""" + + class ObscuredButton(App): + def compose(self): + yield Button() + + app = ObscuredButton() + async with app.run_test() as pilot: + await pilot.pause() + pilot_method = getattr(pilot, method) + assert (await pilot_method(Button)) is True + + +@pytest.mark.parametrize( + ["method", "offset"], + [ + ("click", (0, 0)), + ("click", (2, 0)), + ("click", (10, 23)), + ("click", (70, 0)), + # + ("hover", (0, 0)), + ("hover", (2, 0)), + ("hover", (10, 23)), + ("hover", (70, 0)), + # + ("mouse_down", (0, 0)), + ("mouse_down", (2, 0)), + ("mouse_down", (10, 23)), + ("mouse_down", (70, 0)), + # + ("mouse_up", (0, 0)), + ("mouse_up", (2, 0)), + ("mouse_up", (10, 23)), + ("mouse_up", (70, 0)), + ], +) +async def test_pilot_target_screen_always_true(method, offset): + app = ManyLabelsApp() + async with app.run_test(size=(80, 24)) as pilot: + pilot_method = getattr(pilot, method) + assert (await pilot_method(offset=offset)) is True diff --git a/tests/test_progress_bar.py b/tests/test_progress_bar.py index 64b034817d..bc7f799196 100644 --- a/tests/test_progress_bar.py +++ b/tests/test_progress_bar.py @@ -79,7 +79,7 @@ def test_update_total(): assert pb.total == 1000 pb.update(total=None) - assert pb.total == 1000 + assert pb.total is None pb.update(total=100) assert pb.total == 100 @@ -119,6 +119,15 @@ def test_update(): assert pb.progress == 50 +def test_go_back_to_indeterminate(): + pb = ProgressBar() + + pb.total = 100 + assert pb.percentage == 0 + pb.total = None + assert pb.percentage is None + + @pytest.mark.parametrize( ["show_bar", "show_percentage", "show_eta"], [ diff --git a/tests/test_query.py b/tests/test_query.py index 60dced9716..d09599cdf2 100644 --- a/tests/test_query.py +++ b/tests/test_query.py @@ -1,9 +1,17 @@ import pytest from textual.app import App, ComposeResult +from textual.color import Color from textual.containers import Container -from textual.css.query import InvalidQueryFormat, NoMatches, TooManyMatches, WrongType +from textual.css.query import ( + DeclarationError, + InvalidQueryFormat, + NoMatches, + TooManyMatches, + WrongType, +) from textual.widget import Widget +from textual.widgets import Label def test_query(): @@ -230,3 +238,78 @@ def compose(self) -> ComposeResult: results = list(query.results()) assert len(results) == 5 assert not any(node.id == "root-container" for node in results) + + +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;") + + +async def test_query_set_styles_kwds(): + class LabelApp(App): + def compose(self): + yield Label("Some text") + + app = LabelApp() + async with app.run_test(): + # Sanity check. + assert app.query_one(Label).styles.color != Color(255, 0, 0) + app.query(Label).set_styles(color="red") + assert app.query_one(Label).styles.color == Color(255, 0, 0) + + +async def test_query_set_styles_css_and_kwds(): + class LabelApp(App): + def compose(self): + yield Label("Some text") + + app = LabelApp() + async with app.run_test(): + # Sanity checks. + lbl = app.query_one(Label) + assert lbl.styles.color != Color(255, 0, 0) + assert lbl.styles.background != Color(255, 0, 0) + + app.query(Label).set_styles(css="background: red;", color="red") + assert app.query_one(Label).styles.color == Color(255, 0, 0) + assert app.query_one(Label).styles.background == Color(255, 0, 0) + + +async def test_query_set_styles_css(): + class LabelApp(App): + def compose(self): + yield Label("Some text") + + app = LabelApp() + async with app.run_test(): + # Sanity checks. + lbl = app.query_one(Label) + assert lbl.styles.color != Color(255, 0, 0) + assert lbl.styles.background != Color(255, 0, 0) + + app.query(Label).set_styles(css="background: red; color: red;") + assert app.query_one(Label).styles.color == Color(255, 0, 0) + assert app.query_one(Label).styles.background == Color(255, 0, 0) + + +@pytest.mark.parametrize( + "args", [(False, False), (True, False), (True, True), (False, True)] +) +async def test_query_refresh(args): + refreshes = [] + + class MyWidget(Widget): + def refresh(self, *, repaint=None, layout=None): + super().refresh(repaint=repaint, layout=layout) + refreshes.append((repaint, layout)) + + class MyApp(App): + def compose(self): + yield MyWidget() + + app = MyApp() + async with app.run_test() as pilot: + app.query(MyWidget).refresh(repaint=args[0], layout=args[1]) + assert refreshes[-1] == args diff --git a/tests/test_reactive.py b/tests/test_reactive.py index 9ab1af192c..8ee7861a2a 100644 --- a/tests/test_reactive.py +++ b/tests/test_reactive.py @@ -298,8 +298,6 @@ class Secondary(Primary): class Tertiary(Secondary): baz = reactive("baz") - from rich import print - primary = Primary() secondary = Secondary() tertiary = Tertiary() diff --git a/tests/test_screen_modes.py b/tests/test_screen_modes.py index c5f11d08f0..2c7cdcbb66 100644 --- a/tests/test_screen_modes.py +++ b/tests/test_screen_modes.py @@ -111,13 +111,13 @@ async def test_switch_unknown_mode(ModesApp: Type[App]): app = ModesApp() async with app.run_test(): with pytest.raises(UnknownModeError): - app.switch_mode("unknown mode here") + await app.switch_mode("unknown mode here") async def test_remove_mode(ModesApp: Type[App]): app = ModesApp() async with app.run_test() as pilot: - app.switch_mode("two") + await app.switch_mode("two") await pilot.pause() assert str(app.screen.query_one(Label).renderable) == "two" app.remove_mode("one") @@ -135,7 +135,7 @@ async def test_add_mode(ModesApp: Type[App]): app = ModesApp() async with app.run_test() as pilot: app.add_mode("three", BaseScreen("three")) - app.switch_mode("three") + await app.switch_mode("three") await pilot.pause() assert str(app.screen.query_one(Label).renderable) == "three" @@ -172,47 +172,6 @@ async def test_screen_stack_preserved(ModesApp: Type[App]): await pilot.press("o") -async def test_inactive_stack_is_alive(): - """This tests that timers in screens outside the active stack keep going.""" - pings = [] - - class FastCounter(Screen[None]): - def compose(self) -> ComposeResult: - yield Label("fast") - - def on_mount(self) -> None: - self.call_later(self.set_interval, 0.01, self.ping) - - def ping(self) -> None: - pings.append(str(self.app.query_one(Label).renderable)) - - def key_s(self): - self.app.switch_mode("smile") - - class SmileScreen(Screen[None]): - def compose(self) -> ComposeResult: - yield Label(":)") - - def key_s(self): - self.app.switch_mode("fast") - - class ModesApp(App[None]): - MODES = { - "fast": FastCounter, - "smile": SmileScreen, - } - - def on_mount(self) -> None: - self.switch_mode("fast") - - app = ModesApp() - async with app.run_test() as pilot: - await pilot.press("s") - assert str(app.query_one(Label).renderable) == ":)" - await pilot.press("s") - assert ":)" in pings - - async def test_multiple_mode_callbacks(): written = [] diff --git a/tests/test_screens.py b/tests/test_screens.py index 5f587fd0ee..83fcde4932 100644 --- a/tests/test_screens.py +++ b/tests/test_screens.py @@ -4,11 +4,13 @@ import pytest -from textual.app import App, ScreenStackError, ComposeResult +from textual import work +from textual.app import App, ComposeResult, ScreenStackError from textual.events import MouseMove from textual.geometry import Offset from textual.screen import Screen from textual.widgets import Button, Input, Label +from textual.worker import NoActiveWorker skip_py310 = pytest.mark.skipif( sys.version_info.minor == 10 and sys.version_info.major == 3, @@ -302,6 +304,25 @@ async def key_p(self) -> None: app.bottom.dismiss() +async def test_dismiss_action(): + class ConfirmScreen(Screen[bool]): + BINDINGS = [("y", "dismiss(True)", "Dismiss")] + + class MyApp(App[None]): + bingo = False + + def on_mount(self) -> None: + self.push_screen(ConfirmScreen(), callback=self.callback) + + def callback(self, result: bool) -> None: + self.bingo = result + + app = MyApp() + async with app.run_test() as pilot: + await pilot.press("y") + assert app.bingo + + async def test_switch_screen_no_op(): """Regression test for https://github.com/Textualize/textual/issues/2650""" @@ -407,4 +428,71 @@ def on_mount(self): assert len(MouseMoveRecordingScreen.mouse_events) == 1 mouse_event = MouseMoveRecordingScreen.mouse_events[0] - assert mouse_event.x, mouse_event.y == (label_offset.x + mouse_offset.x, label_offset.y + mouse_offset.y) + assert mouse_event.x, mouse_event.y == ( + label_offset.x + mouse_offset.x, + label_offset.y + mouse_offset.y, + ) + + +async def test_push_screen_wait_for_dismiss() -> None: + """Test push_screen returns result.""" + + class QuitScreen(Screen[bool]): + BINDINGS = [ + ("y", "quit(True)"), + ("n", "quit(False)"), + ] + + def action_quit(self, quit: bool) -> None: + self.dismiss(quit) + + results: list[bool] = [] + + class ScreensApp(App): + BINDINGS = [("x", "exit")] + + @work + async def action_exit(self) -> None: + result = await self.push_screen(QuitScreen(), wait_for_dismiss=True) + results.append(result) + + app = ScreensApp() + # Press X to exit, then Y to dismiss, expect True result + async with app.run_test() as pilot: + await pilot.press("x", "y") + assert results == [True] + + results.clear() + app = ScreensApp() + # Press X to exit, then N to dismiss, expect False result + async with app.run_test() as pilot: + await pilot.press("x", "n") + assert results == [False] + + +async def test_push_screen_wait_for_dismiss_no_worker() -> None: + """Test wait_for_dismiss raises NoActiveWorker when not using workers.""" + + class QuitScreen(Screen[bool]): + BINDINGS = [ + ("y", "quit(True)"), + ("n", "quit(False)"), + ] + + def action_quit(self, quit: bool) -> None: + self.dismiss(quit) + + results: list[bool] = [] + + class ScreensApp(App): + BINDINGS = [("x", "exit")] + + async def action_exit(self) -> None: + result = await self.push_screen(QuitScreen(), wait_for_dismiss=True) + results.append(result) + + app = ScreensApp() + # using `wait_for_dismiss` outside of a worker should raise NoActiveWorker + with pytest.raises(NoActiveWorker): + async with app.run_test() as pilot: + await pilot.press("x", "y") diff --git a/tests/test_segment_tools.py b/tests/test_segment_tools.py index befe4baae1..b3584147ea 100644 --- a/tests/test_segment_tools.py +++ b/tests/test_segment_tools.py @@ -1,7 +1,8 @@ from rich.segment import Segment from rich.style import Style -from textual._segment_tools import line_crop, line_pad, line_trim +from textual._segment_tools import align_lines, line_crop, line_pad, line_trim +from textual.geometry import Size def test_line_crop(): @@ -63,7 +64,7 @@ def test_line_crop_edge_2(): assert result == expected -def test_line_trim(): +def test_line_trim_ascii(): segments = [Segment("foo")] assert line_trim(segments, False, False) == segments @@ -110,3 +111,85 @@ def test_line_pad(): ] assert line_pad(segments, 0, 0, style) == segments + + +def test_align_lines_vertical_middle(): + """Regression test for an issue found while working on + https://github.com/Textualize/textual/issues/3628 - an extra vertical line + was being produced when aligning. If you passed in a Size of height=1 to + `align_lines`, it was producing a result containing 2 lines instead of 1.""" + lines = [[Segment(" "), Segment("hello"), Segment(" ")]] + result = align_lines( + lines, Style(), size=Size(10, 3), horizontal="center", vertical="middle" + ) + assert list(result) == [ + [Segment(" ", Style())], + [Segment(" "), Segment("hello"), Segment(" ")], + [Segment(" ", Style())], + ] + + +def test_align_lines_top_left(): + lines = [ + [Segment("hello")], + [Segment("world")], + ] + + result = align_lines( + lines, Style(), size=Size(10, 4), horizontal="left", vertical="top" + ) + + assert list(result) == [ + [Segment("hello"), Segment(" ", Style())], + [Segment("world"), Segment(" ", Style())], + [Segment(" ", Style())], + [Segment(" ", Style())], + ] + + +def test_align_lines_top_right(): + lines = [ + [Segment("hello")], + [Segment("world")], + ] + + result = align_lines( + lines, Style(), size=Size(10, 4), horizontal="right", vertical="top" + ) + + assert list(result) == [ + [Segment(" ", Style()), Segment("hello")], + [Segment(" ", Style()), Segment("world")], + [Segment(" ", Style())], + [Segment(" ", Style())], + ] + + +def test_align_lines_perfect_fit_horizontal_left(): + lines = [[Segment(" "), Segment("hello"), Segment(" ")]] # 10 cells + result = align_lines( + lines, Style(), size=Size(10, 1), horizontal="left", vertical="middle" + ) + assert list(result) == [[Segment(" "), Segment("hello"), Segment(" ")]] + + +def test_align_lines_perfect_fit_horizontal_center(): + """When the content perfectly fits the available horizontal space, + no empty segments should be produced. This is a regression test for + the issue https://github.com/Textualize/textual/issues/3628.""" + lines = [[Segment(" "), Segment("hello"), Segment(" ")]] # 10 cells of content + result = align_lines( + lines, Style(), size=Size(10, 1), horizontal="center", vertical="middle" + ) + assert list(result) == [[Segment(" "), Segment("hello"), Segment(" ")]] + + +def test_align_lines_perfect_fit_horizontal_right(): + """When the content perfectly fits the available horizontal space, + no empty segments should be produced. This is a regression test for + the issue https://github.com/Textualize/textual/issues/3628.""" + lines = [[Segment(" "), Segment("hello"), Segment(" ")]] # 10 cells of content + result = align_lines( + lines, Style(), size=Size(10, 1), horizontal="right", vertical="middle" + ) + assert list(result) == [[Segment(" "), Segment("hello"), Segment(" ")]] diff --git a/tests/test_style_inheritance.py b/tests/test_style_inheritance.py index b6e264c9d0..72b631a94a 100644 --- a/tests/test_style_inheritance.py +++ b/tests/test_style_inheritance.py @@ -1,5 +1,3 @@ -from rich.style import Style - from textual.app import App, ComposeResult from textual.widgets import Button, Static diff --git a/tests/test_tabbed_content.py b/tests/test_tabbed_content.py index f0b029a7f6..5b67cc508a 100644 --- a/tests/test_tabbed_content.py +++ b/tests/test_tabbed_content.py @@ -3,6 +3,7 @@ from textual.app import App, ComposeResult from textual.reactive import var from textual.widgets import Label, Tab, TabbedContent, TabPane, Tabs +from textual.widgets._tabbed_content import ContentTab async def test_tabbed_content_switch_via_ui(): @@ -29,7 +30,7 @@ def compose(self) -> ComposeResult: assert not app.query_one("#baz-label").region # Click second tab - await pilot.click("Tab#bar") + await pilot.click(f"Tab#{ContentTab.add_prefix('bar')}") assert tabbed_content.active == "bar" await pilot.pause() assert not app.query_one("#foo-label").region @@ -37,7 +38,7 @@ def compose(self) -> ComposeResult: assert not app.query_one("#baz-label").region # Click third tab - await pilot.click("Tab#baz") + await pilot.click(f"Tab#{ContentTab.add_prefix('baz')}") assert tabbed_content.active == "baz" await pilot.pause() assert not app.query_one("#foo-label").region @@ -169,11 +170,9 @@ def compose(self) -> ComposeResult: assert tabbed_content.active == "" assert tabbed_content.tab_count == 0 await tabbed_content.add_pane(TabPane("Test 1", id="test-1")) - await pilot.pause() assert tabbed_content.tab_count == 1 assert tabbed_content.active == "test-1" await tabbed_content.add_pane(TabPane("Test 2", id="test-2")) - await pilot.pause() assert tabbed_content.tab_count == 2 assert tabbed_content.active == "test-1" @@ -191,11 +190,9 @@ def compose(self) -> ComposeResult: assert tabbed_content.tab_count == 3 assert tabbed_content.active == "initial-1" await tabbed_content.add_pane(TabPane("Test 4", id="test-1")) - await pilot.pause() assert tabbed_content.tab_count == 4 assert tabbed_content.active == "initial-1" await tabbed_content.add_pane(TabPane("Test 5", id="test-2")) - await pilot.pause() assert tabbed_content.tab_count == 5 assert tabbed_content.active == "initial-1" @@ -211,10 +208,11 @@ def compose(self) -> ComposeResult: assert tabbed_content.tab_count == 1 assert tabbed_content.active == "initial-1" await tabbed_content.add_pane(TabPane("Added", id="new-1"), before="initial-1") - await pilot.pause() assert tabbed_content.tab_count == 2 assert tabbed_content.active == "initial-1" - assert [tab.id for tab in tabbed_content.query(Tab).results(Tab)] == [ + assert [ + ContentTab.sans_prefix(tab.id) for tab in tabbed_content.query(Tab) + ] == [ "new-1", "initial-1", ] @@ -234,10 +232,12 @@ def compose(self) -> ComposeResult: TabPane("Added", id="new-1"), before=pilot.app.query_one("TabPane#initial-1", TabPane), ) - await pilot.pause() assert tabbed_content.tab_count == 2 assert tabbed_content.active == "initial-1" - assert [tab.id for tab in tabbed_content.query(Tab).results(Tab)] == [ + assert [ + ContentTab.sans_prefix(tab.id) + for tab in tabbed_content.query(Tab).results(Tab) + ] == [ "new-1", "initial-1", ] @@ -270,10 +270,12 @@ def compose(self) -> ComposeResult: assert tabbed_content.tab_count == 1 assert tabbed_content.active == "initial-1" await tabbed_content.add_pane(TabPane("Added", id="new-1"), after="initial-1") - await pilot.pause() assert tabbed_content.tab_count == 2 assert tabbed_content.active == "initial-1" - assert [tab.id for tab in tabbed_content.query(Tab).results(Tab)] == [ + assert [ + ContentTab.sans_prefix(tab.id) + for tab in tabbed_content.query(Tab).results(Tab) + ] == [ "initial-1", "new-1", ] @@ -296,7 +298,10 @@ def compose(self) -> ComposeResult: await pilot.pause() assert tabbed_content.tab_count == 2 assert tabbed_content.active == "initial-1" - assert [tab.id for tab in tabbed_content.query(Tab).results(Tab)] == [ + assert [ + ContentTab.sans_prefix(tab.id) + for tab in tabbed_content.query(Tab).results(Tab) + ] == [ "initial-1", "new-1", ] @@ -436,11 +441,11 @@ def compose(self) -> ComposeResult: yield Label("tab-1") def on_mount(self) -> None: - self.query_one("Tab#tab-1").disabled = True + self.query_one(TabbedContent).get_tab("tab-1").disabled = True app = TabbedApp() async with app.run_test(): - assert app.query_one(Tabs).active == "tab-1" + assert app.query_one(TabbedContent).active == "tab-1" async def test_disabled_tab_cannot_be_clicked(): @@ -451,12 +456,12 @@ def compose(self) -> ComposeResult: yield Label("tab-2") def on_mount(self) -> None: - self.query_one("Tab#tab-2").disabled = True + self.query_one(TabbedContent).get_tab("tab-2").disabled = True app = TabbedApp() async with app.run_test() as pilot: - await pilot.click("Tab#tab-2") - assert app.query_one(Tabs).active == "tab-1" + await pilot.click(f"Tab#{ContentTab.add_prefix('tab-2')}") + assert app.query_one(TabbedContent).active == "tab-1" async def test_disabling_via_tabbed_content(): @@ -471,8 +476,8 @@ def on_mount(self) -> None: app = TabbedApp() async with app.run_test() as pilot: - await pilot.click("Tab#tab-2") - assert app.query_one(Tabs).active == "tab-1" + await pilot.click(f"Tab#{ContentTab.add_prefix('tab-2')}") + assert app.query_one(TabbedContent).active == "tab-1" async def test_disabling_via_tab_pane(): @@ -488,8 +493,8 @@ def on_mount(self) -> None: app = TabbedApp() async with app.run_test() as pilot: await pilot.pause() - await pilot.click("Tab#tab-2") - assert app.query_one(Tabs).active == "tab-1" + await pilot.click(f"Tab#{ContentTab.add_prefix('tab-2')}") + assert app.query_one(TabbedContent).active == "tab-1" async def test_creating_disabled_tab(): @@ -503,8 +508,8 @@ def compose(self) -> ComposeResult: app = TabbedApp() async with app.run_test() as pilot: - await pilot.click("Tab#tab-2") - assert app.query_one(Tabs).active == "tab-1" + await pilot.click(f"Tab#{ContentTab.add_prefix('tab-2')}") + assert app.query_one(TabbedContent).active == "tab-1" async def test_navigation_around_disabled_tabs(): @@ -517,21 +522,26 @@ def compose(self) -> ComposeResult: yield Label("tab-4") def on_mount(self) -> None: - self.query_one("Tab#tab-1").disabled = True - self.query_one("Tab#tab-3").disabled = True + self.query_one(TabbedContent).get_tab("tab-1").disabled = True + self.query_one(TabbedContent).get_tab("tab-3").disabled = True app = TabbedApp() - async with app.run_test(): + async with app.run_test() as pilot: + tabbed_conent = app.query_one(TabbedContent) tabs = app.query_one(Tabs) - assert tabs.active == "tab-1" + assert tabbed_conent.active == "tab-1" tabs.action_next_tab() - assert tabs.active == "tab-2" + await pilot.pause() + assert tabbed_conent.active == "tab-2" tabs.action_next_tab() - assert tabs.active == "tab-4" + await pilot.pause() + assert tabbed_conent.active == "tab-4" tabs.action_next_tab() - assert tabs.active == "tab-2" + await pilot.pause() + assert tabbed_conent.active == "tab-2" tabs.action_previous_tab() - assert tabs.active == "tab-4" + await pilot.pause() + assert tabbed_conent.active == "tab-4" async def test_reenabling_tab(): @@ -542,18 +552,18 @@ def compose(self) -> ComposeResult: yield Label("tab-2") def on_mount(self) -> None: - self.query_one("Tab#tab-2").disabled = True + self.query_one(TabbedContent).get_tab("tab-2").disabled = True def reenable(self) -> None: - app.query_one("Tab#tab-2").disabled = False + self.query_one(TabbedContent).get_tab("tab-2").disabled = False app = TabbedApp() async with app.run_test() as pilot: - await pilot.click("Tab#tab-2") - assert app.query_one(Tabs).active == "tab-1" + await pilot.click(f"Tab#{ContentTab.add_prefix('tab-2')}") + assert app.query_one(TabbedContent).active == "tab-1" app.reenable() - await pilot.click("Tab#tab-2") - assert app.query_one(Tabs).active == "tab-2" + await pilot.click(f"Tab#{ContentTab.add_prefix('tab-2')}") + assert app.query_one(TabbedContent).active == "tab-2" async def test_reenabling_via_tabbed_content(): @@ -571,11 +581,11 @@ def reenable(self) -> None: app = TabbedApp() async with app.run_test() as pilot: - await pilot.click("Tab#tab-2") - assert app.query_one(Tabs).active == "tab-1" + await pilot.click(f"Tab#{ContentTab.add_prefix('tab-2')}") + assert app.query_one(TabbedContent).active == "tab-1" app.reenable() - await pilot.click("Tab#tab-2") - assert app.query_one(Tabs).active == "tab-2" + await pilot.click(f"Tab#{ContentTab.add_prefix('tab-2')}") + assert app.query_one(TabbedContent).active == "tab-2" async def test_reenabling_via_tab_pane(): @@ -594,11 +604,11 @@ def reenable(self) -> None: app = TabbedApp() async with app.run_test() as pilot: await pilot.pause() - await pilot.click("Tab#tab-2") - assert app.query_one(Tabs).active == "tab-1" + await pilot.click(f"Tab#{ContentTab.add_prefix('tab-2')}") + assert app.query_one(TabbedContent).active == "tab-1" app.reenable() - await pilot.click("Tab#tab-2") - assert app.query_one(Tabs).active == "tab-2" + await pilot.click(f"Tab#{ContentTab.add_prefix('tab-2')}") + assert app.query_one(TabbedContent).active == "tab-2" async def test_disabling_unknown_tab(): @@ -803,3 +813,49 @@ def compose(self) -> ComposeResult: await pilot.pause() tabber.show_tab("tab-1") await pilot.pause() + + +async def test_tabs_nested_in_tabbed_content_doesnt_crash(): + """Regression test for https://github.com/Textualize/textual/issues/3412""" + + class TabsNestedInTabbedContent(App): + def compose(self) -> ComposeResult: + with TabbedContent(): + with TabPane("Outer TabPane"): + yield Tabs("Inner Tab") + + app = TabsNestedInTabbedContent() + async with app.run_test() as pilot: + await pilot.pause() + + +async def test_tabs_nested_doesnt_interfere_with_ancestor_tabbed_content(): + """When a Tabs is nested as a descendant in the DOM of a TabbedContent, + the messages posted from that Tabs should not interfere with the TabbedContent. + A TabbedContent should only handle messages from Tabs which are direct children. + + Relates to https://github.com/Textualize/textual/issues/3412 + """ + + class TabsNestedInTabbedContent(App): + def compose(self) -> ComposeResult: + with TabbedContent(): + with TabPane("OuterTab", id="outer1"): + yield Tabs( + Tab("Tab1", id="tab1"), + Tab("Tab2", id="tab2"), + id="inner-tabs", + ) + + app = TabsNestedInTabbedContent() + async with app.run_test(): + inner_tabs = app.query_one("#inner-tabs", Tabs) + tabbed_content = app.query_one(TabbedContent) + + assert inner_tabs.active_tab.id == "tab1" + assert tabbed_content.active == "outer1" + + await inner_tabs.clear() + + assert inner_tabs.active_tab is None + assert tabbed_content.active == "outer1" diff --git a/tests/test_tabs.py b/tests/test_tabs.py index 383a262d8d..1269820115 100644 --- a/tests/test_tabs.py +++ b/tests/test_tabs.py @@ -214,25 +214,21 @@ def compose(self) -> ComposeResult: assert tabs.active_tab.id == "tab-1" await tabs.remove_tab("tab-1") - await pilot.pause() assert tabs.tab_count == 3 assert tabs.active_tab is not None assert tabs.active_tab.id == "tab-2" await tabs.remove_tab(tabs.query_one("#tab-2", Tab)) - await pilot.pause() assert tabs.tab_count == 2 assert tabs.active_tab is not None assert tabs.active_tab.id == "tab-3" await tabs.remove_tab("tab-does-not-exist") - await pilot.pause() assert tabs.tab_count == 2 assert tabs.active_tab is not None assert tabs.active_tab.id == "tab-3" await tabs.remove_tab(None) - await pilot.pause() assert tabs.tab_count == 2 assert tabs.active_tab is not None assert tabs.active_tab.id == "tab-3" @@ -257,25 +253,21 @@ def compose(self) -> ComposeResult: assert tabs.active_tab.id == "tab-1" await tabs.remove_tab("tab-4") - await pilot.pause() assert tabs.tab_count == 3 assert tabs.active_tab is not None assert tabs.active_tab.id == "tab-1" await tabs.remove_tab("tab-3") - await pilot.pause() assert tabs.tab_count == 2 assert tabs.active_tab is not None assert tabs.active_tab.id == "tab-1" await tabs.remove_tab("tab-2") - await pilot.pause() assert tabs.tab_count == 1 assert tabs.active_tab is not None assert tabs.active_tab.id == "tab-1" await tabs.remove_tab("tab-1") - await pilot.pause() assert tabs.tab_count == 0 assert tabs.active_tab is None @@ -447,7 +439,8 @@ async def test_remove_tabs_messages(): tabs = pilot.app.query_one(Tabs) for n in range(4): await tabs.remove_tab(f"tab-{n+1}") - await pilot.pause() + + await pilot.pause() assert pilot.app.intended_handlers == [ "on_tabs_tab_activated", "on_tabs_tab_activated", @@ -463,7 +456,8 @@ async def test_reverse_remove_tabs_messages(): tabs = pilot.app.query_one(Tabs) for n in reversed(range(4)): await tabs.remove_tab(f"tab-{n+1}") - await pilot.pause() + + await pilot.pause() assert pilot.app.intended_handlers == [ "on_tabs_tab_activated", "on_tabs_cleared", diff --git a/tests/test_widget.py b/tests/test_widget.py index fb5fdb52e1..82524ac11f 100644 --- a/tests/test_widget.py +++ b/tests/test_widget.py @@ -10,7 +10,7 @@ from textual.geometry import Offset, Size from textual.message import Message from textual.widget import MountError, PseudoClasses, Widget -from textual.widgets import Label +from textual.widgets import Label, LoadingIndicator @pytest.mark.parametrize( @@ -194,7 +194,8 @@ def test_get_pseudo_class_state_disabled(): def test_get_pseudo_class_state_parent_disabled(): child = Widget() - _parent = Widget(child, disabled=True) + _parent = Widget(disabled=True) + child._attach(_parent) pseudo_classes = child.get_pseudo_class_state() assert pseudo_classes == PseudoClasses(enabled=False, focus=False, hover=False) @@ -355,3 +356,83 @@ def test_get_set_tooltip(): assert widget.tooltip == "This is a tooltip." +async def test_loading(): + """Test setting the loading reactive.""" + + class LoadingApp(App): + def compose(self) -> ComposeResult: + yield Label("Hello, World") + + async with LoadingApp().run_test() as pilot: + app = pilot.app + label = app.query_one(Label) + assert label.loading == False + assert len(label.query(LoadingIndicator)) == 0 + + label.loading = True + await pilot.pause() + assert len(label.query(LoadingIndicator)) == 1 + + label.loading = True # Setting to same value is a null-op + await pilot.pause() + assert len(label.query(LoadingIndicator)) == 1 + + label.loading = False + await pilot.pause() + assert len(label.query(LoadingIndicator)) == 0 + + label.loading = False # Setting to same value is a null-op + await pilot.pause() + assert len(label.query(LoadingIndicator)) == 0 + + +async def test_is_mounted_property(): + class TestWidgetIsMountedApp(App): + pass + + async with TestWidgetIsMountedApp().run_test() as pilot: + widget = Widget() + assert widget.is_mounted is False + await pilot.app.mount(widget) + assert widget.is_mounted is True + + +async def test_mount_error_not_widget(): + class NotWidgetApp(App): + def compose(self) -> ComposeResult: + yield {} + + app = NotWidgetApp() + with pytest.raises(MountError): + async with app.run_test(): + pass + + +async def test_mount_error_bad_widget(): + class DaftWidget(Widget): + def __init__(self): + # intentionally missing super() + pass + + class NotWidgetApp(App): + def compose(self) -> ComposeResult: + yield DaftWidget() + + app = NotWidgetApp() + with pytest.raises(MountError): + async with app.run_test(): + pass + + +async def test_render_returns_text(): + """Test that render processes console markup when returning a string.""" + + # Regression test for https://github.com/Textualize/textual/issues/3918 + class SimpleWidget(Widget): + def render(self) -> str: + return "Hello [b]World[/b]!" + + widget = SimpleWidget() + render_result = widget._render() + assert isinstance(render_result, Text) + assert render_result.plain == "Hello World!" diff --git a/tests/test_widget_child_moving.py b/tests/test_widget_child_moving.py index f2d40a4aae..f0ab58d303 100644 --- a/tests/test_widget_child_moving.py +++ b/tests/test_widget_child_moving.py @@ -9,7 +9,7 @@ async def test_move_child_no_direction() -> None: """Test moving a widget in a child list.""" async with App().run_test() as pilot: - child = Widget(Widget()) + child = Widget() await pilot.app.mount(child) with pytest.raises(WidgetError): pilot.app.screen.move_child(child) @@ -18,7 +18,7 @@ async def test_move_child_no_direction() -> None: async def test_move_child_both_directions() -> None: """Test calling move_child with more than one direction.""" async with App().run_test() as pilot: - child = Widget(Widget()) + child = Widget() await pilot.app.mount(child) with pytest.raises(WidgetError): pilot.app.screen.move_child(child, before=1, after=2) @@ -27,7 +27,7 @@ async def test_move_child_both_directions() -> None: async def test_move_child_not_our_child() -> None: """Test attempting to move a child that isn't ours.""" async with App().run_test() as pilot: - child = Widget(Widget()) + child = Widget() await pilot.app.mount(child) with pytest.raises(WidgetError): pilot.app.screen.move_child(Widget(), before=child) @@ -36,28 +36,82 @@ async def test_move_child_not_our_child() -> None: async def test_move_child_to_outside() -> None: """Test attempting to move relative to a widget that isn't a child.""" async with App().run_test() as pilot: - child = Widget(Widget()) + child = Widget() await pilot.app.mount(child) with pytest.raises(WidgetError): pilot.app.screen.move_child(child, before=Widget()) -async def test_move_child_before_itself() -> None: - """Test moving a widget before itself.""" +@pytest.mark.parametrize( + "reference", + [ + "before", + "after", + ], +) +async def test_move_child_index_in_relation_to_itself_index(reference: str) -> None: + """Regression test for https://github.com/Textualize/textual/issues/1743""" + widget = Widget() + child = 0 + kwargs = {reference: 0} async with App().run_test() as pilot: - child = Widget(Widget()) - await pilot.app.mount(child) - pilot.app.screen.move_child(child, before=child) - - -async def test_move_child_after_itself() -> None: - """Test moving a widget after itself.""" - # Regression test for https://github.com/Textualize/textual/issues/1743 + await pilot.app.screen.mount(widget) + pilot.app.screen.move_child(child, **kwargs) # Shouldn't raise an error. + + +@pytest.mark.parametrize( + "reference", + [ + "before", + "after", + ], +) +async def test_move_child_index_in_relation_to_itself_widget(reference: str) -> None: + """Regression test for https://github.com/Textualize/textual/issues/1743""" + + widget = Widget() + child = 0 + kwargs = {reference: widget} async with App().run_test() as pilot: - child = Widget(Widget()) - await pilot.app.mount(child) - pilot.app.screen.move_child(child, after=child) + await pilot.app.screen.mount(widget) + pilot.app.screen.move_child(child, **kwargs) # Shouldn't raise an error. + + +@pytest.mark.parametrize( + "reference", + [ + "before", + "after", + ], +) +async def test_move_child_widget_in_relation_to_itself_index(reference: str) -> None: + """Regression test for https://github.com/Textualize/textual/issues/1743""" + + widget = Widget() + child = widget + kwargs = {reference: 0} + async with App().run_test() as pilot: + await pilot.app.screen.mount(widget) + pilot.app.screen.move_child(child, **kwargs) # Shouldn't raise an error. + + +@pytest.mark.parametrize( + "reference", + [ + "before", + "after", + ], +) +async def test_move_child_widget_in_relation_to_itself_widget(reference: str) -> None: + """Regression test for https://github.com/Textualize/textual/issues/1743""" + + widget = Widget() + child = widget + kwargs = {reference: widget} + async with App().run_test() as pilot: + await pilot.app.screen.mount(widget) + pilot.app.screen.move_child(child, **kwargs) # Shouldn't raise an error. async def test_move_past_end_of_child_list() -> None: diff --git a/tests/test_xterm_parser.py b/tests/test_xterm_parser.py index afb3bfaef0..f8d0c02942 100644 --- a/tests/test_xterm_parser.py +++ b/tests/test_xterm_parser.py @@ -186,10 +186,12 @@ def test_double_escape(parser): ("\x1b[<0;50;25M", MouseDown, False, False), ("\x1b[<4;50;25M", MouseDown, True, False), ("\x1b[<8;50;25M", MouseDown, False, True), + ("\x1b[<12;50;25M", MouseDown, True, True), # Mouse up, with and without modifiers ("\x1b[<0;50;25m", MouseUp, False, False), ("\x1b[<4;50;25m", MouseUp, True, False), ("\x1b[<8;50;25m", MouseUp, False, True), + ("\x1b[<12;50;25m", MouseUp, True, True), ], ) def test_mouse_click(parser, sequence, event_type, shift, meta): diff --git a/tests/text_area/test_edit_via_api.py b/tests/text_area/test_edit_via_api.py new file mode 100644 index 0000000000..98072d35ae --- /dev/null +++ b/tests/text_area/test_edit_via_api.py @@ -0,0 +1,530 @@ +"""Tests editing the document using the API (replace etc.) + +The tests in this module directly call the edit APIs on the TextArea rather +than going via bindings. + +Note that more extensive testing for editing is done at the Document level. +""" +import pytest + +from textual.app import App, ComposeResult +from textual.widgets import TextArea +from textual.widgets.text_area import EditResult, Selection + +TEXT = """\ +I must not fear. +Fear is the mind-killer. +Fear is the little-death that brings total obliteration. +I will face my fear. +""" + +SIMPLE_TEXT = """\ +ABCDE +FGHIJ +KLMNO +PQRST +UVWXY +Z +""" + + +class TextAreaApp(App): + def compose(self) -> ComposeResult: + text_area = TextArea() + text_area.load_text(TEXT) + yield text_area + + +async def test_insert_text_start_maintain_selection_offset(): + """Ensure that we can maintain the offset between the location + an insert happens and the location of the selection.""" + app = TextAreaApp() + async with app.run_test(): + text_area = app.query_one(TextArea) + text_area.move_cursor((0, 5)) + text_area.insert("Hello", location=(0, 0)) + assert text_area.text == "Hello" + TEXT + assert text_area.selection == Selection.cursor((0, 10)) + + +async def test_insert_text_start(): + """The document is correctly updated on inserting at the start. + If we don't maintain the selection offset, the cursor jumps + to the end of the edit and the selection is empty.""" + app = TextAreaApp() + async with app.run_test(): + text_area = app.query_one(TextArea) + text_area.move_cursor((0, 5)) + text_area.insert("Hello", location=(0, 0), maintain_selection_offset=False) + assert text_area.text == "Hello" + TEXT + assert text_area.selection == Selection.cursor((0, 5)) + + +async def test_insert_empty_string(): + app = TextAreaApp() + async with app.run_test(): + text_area = app.query_one(TextArea) + text_area.load_text("0123456789") + + text_area.insert("", location=(0, 3)) + + assert text_area.text == "0123456789" + + +async def test_replace_empty_string(): + app = TextAreaApp() + async with app.run_test(): + text_area = app.query_one(TextArea) + text_area.load_text("0123456789") + + text_area.replace("", start=(0, 3), end=(0, 7)) + + assert text_area.text == "012789" + + +@pytest.mark.parametrize( + "cursor_location,insert_location,cursor_destination", + [ + ((0, 3), (0, 2), (0, 4)), # API insert just before cursor + ((0, 3), (0, 3), (0, 4)), # API insert at cursor location + ((0, 3), (0, 4), (0, 3)), # API insert just after cursor + ((0, 3), (0, 5), (0, 3)), # API insert just after cursor + ], +) +async def test_insert_character_near_cursor_maintain_selection_offset( + cursor_location, + insert_location, + cursor_destination, +): + app = TextAreaApp() + async with app.run_test(): + text_area = app.query_one(TextArea) + text_area.load_text("012345") + text_area.move_cursor(cursor_location) + text_area.insert("X", location=insert_location) + assert text_area.selection == Selection.cursor(cursor_destination) + + +async def test_insert_newlines_start(): + app = TextAreaApp() + async with app.run_test(): + text_area = app.query_one(TextArea) + text_area.insert("\n\n\n") + assert text_area.text == "\n\n\n" + TEXT + assert text_area.selection == Selection.cursor((3, 0)) + + +async def test_insert_newlines_end(): + app = TextAreaApp() + async with app.run_test(): + text_area = app.query_one(TextArea) + text_area.insert("\n\n\n", location=(4, 0)) + assert text_area.text == TEXT + "\n\n\n" + + +async def test_insert_windows_newlines(): + app = TextAreaApp() + async with app.run_test(): + text_area = app.query_one(TextArea) + # Although we're inserting windows newlines, the configured newline on + # the Document inside the TextArea will be "\n", so when we check TextArea.text + # we expect to see "\n". + text_area.insert("\r\n\r\n\r\n") + assert text_area.text == "\n\n\n" + TEXT + + +async def test_insert_old_mac_newlines(): + app = TextAreaApp() + async with app.run_test(): + text_area = app.query_one(TextArea) + text_area.insert("\r\r\r") + assert text_area.text == "\n\n\n" + TEXT + + +async def test_insert_text_non_cursor_location(): + app = TextAreaApp() + async with app.run_test(): + text_area = app.query_one(TextArea) + text_area.insert("Hello", location=(4, 0)) + assert text_area.text == TEXT + "Hello" + assert text_area.selection == Selection.cursor((0, 0)) + + +async def test_insert_text_non_cursor_location_dont_maintain_offset(): + app = TextAreaApp() + async with app.run_test(): + text_area = app.query_one(TextArea) + text_area.selection = Selection((2, 3), (3, 5)) + + result = text_area.insert( + "Hello", + location=(4, 0), + maintain_selection_offset=False, + ) + + assert result == EditResult( + end_location=(4, 5), + replaced_text="", + ) + assert text_area.text == TEXT + "Hello" + + # Since maintain_selection_offset is False, the selection + # is reset to a cursor and goes to the end of the insert. + assert text_area.selection == Selection.cursor((4, 5)) + + +async def test_insert_multiline_text(): + app = TextAreaApp() + async with app.run_test(): + text_area = app.query_one(TextArea) + text_area.move_cursor((2, 5)) + text_area.insert("Hello,\nworld!", maintain_selection_offset=False) + expected_content = """\ +I must not fear. +Fear is the mind-killer. +Fear Hello, +world!is the little-death that brings total obliteration. +I will face my fear. +""" + assert text_area.cursor_location == (3, 6) # Cursor moved to end of insert + assert text_area.text == expected_content + + +async def test_insert_multiline_text_maintain_offset(): + app = TextAreaApp() + async with app.run_test(): + text_area = app.query_one(TextArea) + text_area.move_cursor((2, 5)) + result = text_area.insert("Hello,\nworld!") + + assert result == EditResult( + end_location=(3, 6), + replaced_text="", + ) + + # The insert happens at the cursor (default location) + # Offset is maintained - we inserted 1 line so cursor shifts + # down 1 line, and along by the length of the last insert line. + assert text_area.cursor_location == (3, 6) + expected_content = """\ +I must not fear. +Fear is the mind-killer. +Fear Hello, +world!is the little-death that brings total obliteration. +I will face my fear. +""" + assert text_area.text == expected_content + + +async def test_replace_multiline_text(): + app = TextAreaApp() + async with app.run_test(): + text_area = app.query_one(TextArea) + # replace "Fear is the mind-killer\nFear is the little death...\n" + # with "Hello,\nworld!\n" + result = text_area.replace("Hello,\nworld!\n", start=(1, 0), end=(3, 0)) + expected_replaced_text = """\ +Fear is the mind-killer. +Fear is the little-death that brings total obliteration. +""" + assert result == EditResult( + end_location=(3, 0), + replaced_text=expected_replaced_text, + ) + + expected_content = """\ +I must not fear. +Hello, +world! +I will face my fear. +""" + assert text_area.selection == Selection.cursor((0, 0)) # cursor didnt move + assert text_area.text == expected_content + + +async def test_replace_multiline_text_maintain_selection(): + app = TextAreaApp() + async with app.run_test(): + text_area = app.query_one(TextArea) + + # To begin with, the user selects the word "face" + text_area.selection = Selection((3, 7), (3, 11)) + assert text_area.selected_text == "face" + + # Text is inserted via the API in a way that shifts + # the start and end locations of the word "face" in + # both the horizontal and vertical directions. + text_area.replace( + "Hello,\nworld!\n123\n456", + start=(1, 0), + end=(3, 0), + ) + expected_content = """\ +I must not fear. +Hello, +world! +123 +456I will face my fear. +""" + # Despite this insert, the selection locations are updated + # and the word face is still highlighted. This ensures that + # if text is insert programmatically, a user that is typing + # won't lose their place - the cursor will maintain the same + # relative position in the document as before. + assert text_area.selected_text == "face" + assert text_area.selection == Selection((4, 10), (4, 14)) + assert text_area.text == expected_content + + +async def test_delete_within_line(): + app = TextAreaApp() + async with app.run_test(): + text_area = app.query_one(TextArea) + text_area.selection = Selection((0, 11), (0, 15)) + assert text_area.selected_text == "fear" + + # Delete some text before the selection location. + result = text_area.delete((0, 6), (0, 10)) + + # Even though the word has 'shifted' left, it's still selected. + assert text_area.selection == Selection((0, 7), (0, 11)) + assert text_area.selected_text == "fear" + + # We've recorded exactly what text was replaced in the EditResult + assert result == EditResult( + end_location=(0, 6), + replaced_text=" not", + ) + + expected_text = """\ +I must fear. +Fear is the mind-killer. +Fear is the little-death that brings total obliteration. +I will face my fear. +""" + assert text_area.text == expected_text + + +async def test_delete_within_line_dont_maintain_offset(): + app = TextAreaApp() + async with app.run_test(): + text_area = app.query_one(TextArea) + text_area.delete((0, 6), (0, 10), maintain_selection_offset=False) + expected_text = """\ +I must fear. +Fear is the mind-killer. +Fear is the little-death that brings total obliteration. +I will face my fear. +""" + assert text_area.selection == Selection.cursor((0, 6)) # cursor moved + assert text_area.text == expected_text + + +async def test_delete_multiple_lines_selection_above(): + app = TextAreaApp() + async with app.run_test(): + text_area = app.query_one(TextArea) + + # User has selected text on the first line... + text_area.selection = Selection((0, 2), (0, 6)) + assert text_area.selected_text == "must" + + # Some lines below are deleted... + result = text_area.delete((1, 0), (3, 0)) + + # The selection is not affected at all. + assert text_area.selection == Selection((0, 2), (0, 6)) + + # We've recorded the text that was deleted in the ReplaceResult. + # Lines of index 1 and 2 were deleted. Since the end + # location of the selection is (3, 0), the newline + # marker is included in the deletion. + expected_replaced_text = """\ +Fear is the mind-killer. +Fear is the little-death that brings total obliteration. +""" + assert result == EditResult( + end_location=(1, 0), + replaced_text=expected_replaced_text, + ) + assert ( + text_area.text + == """\ +I must not fear. +I will face my fear. +""" + ) + + +async def test_delete_empty_document(): + app = TextAreaApp() + async with app.run_test(): + text_area = app.query_one(TextArea) + text_area.load_text("") + result = text_area.delete((0, 0), (1, 0)) + assert result.replaced_text == "" + assert text_area.text == "" + + +async def test_clear(): + app = TextAreaApp() + async with app.run_test(): + text_area = app.query_one(TextArea) + text_area.clear() + + +async def test_clear_empty_document(): + app = TextAreaApp() + async with app.run_test(): + text_area = app.query_one(TextArea) + text_area.load_text("") + text_area.clear() + + +@pytest.mark.parametrize( + "select_from,select_to", + [ + [(0, 3), (2, 1)], + [(2, 1), (0, 3)], # Ensuring independence from selection direction. + ], +) +async def test_insert_text_multiline_selection_top(select_from, select_to): + """ + An example to attempt to explain what we're testing here... + + X = edit range, * = character in TextArea, S = selection + + *********XX + XXXXX***SSS + SSSSSSSSSSS + SSSS******* + + If an edit happens at XXXX, we need to ensure that the SSS on the + same line is adjusted appropriately so that it's still highlighting + the same characters as before. + """ + app = TextAreaApp() + async with app.run_test(): + # ABCDE + # FGHIJ + # KLMNO + # PQRST + # UVWXY + # Z + text_area = app.query_one(TextArea) + text_area.load_text(SIMPLE_TEXT) + text_area.selection = Selection(select_from, select_to) + + # Check what text is selected. + expected_selected_text = "DE\nFGHIJ\nK" + assert text_area.selected_text == expected_selected_text + + result = text_area.replace( + "Hello", + start=(0, 0), + end=(0, 2), + ) + + assert result == EditResult(end_location=(0, 5), replaced_text="AB") + + # The edit range has grown from width 2 to width 5, so the + # top line of the selection was adjusted (column+=3) such that the + # same characters are highlighted: + # ... the selection is not changed after programmatic insert + # ... the same text is selected as before. + assert text_area.selected_text == expected_selected_text + + # The resulting text in the TextArea is correct. + assert text_area.text == "HelloCDE\nFGHIJ\nKLMNO\nPQRST\nUVWXY\nZ\n" + + +@pytest.mark.parametrize( + "select_from,select_to", + [ + [(0, 3), (2, 5)], + [(2, 5), (0, 3)], # Ensuring independence from selection direction. + ], +) +async def test_insert_text_multiline_selection_bottom(select_from, select_to): + """ + The edited text is within the selected text on the bottom line + of the selection. The bottom of the selection should be adjusted + such that any text that was previously selected is still selected. + """ + app = TextAreaApp() + async with app.run_test(): + # ABCDE + # FGHIJ + # KLMNO + # PQRST + # UVWXY + # Z + + text_area = app.query_one(TextArea) + text_area.load_text(SIMPLE_TEXT) + text_area.selection = Selection(select_from, select_to) + + # Check what text is selected. + assert text_area.selected_text == "DE\nFGHIJ\nKLMNO" + + result = text_area.replace( + "*", + start=(2, 0), + end=(2, 3), + ) + assert result == EditResult(end_location=(2, 1), replaced_text="KLM") + + # The 'NO' from the selection is still available on the + # bottom selection line, however the 'KLM' is replaced + # with '*'. Since 'NO' is still available, it's maintained + # within the selection. + assert text_area.selected_text == "DE\nFGHIJ\n*NO" + + # The resulting text in the TextArea is correct. + # 'KLM' replaced with '*' + assert text_area.text == "ABCDE\nFGHIJ\n*NO\nPQRST\nUVWXY\nZ\n" + + +async def test_delete_fully_within_selection(): + """User-facing selection should be best-effort adjusted when a programmatic + replacement is made to the document.""" + app = TextAreaApp() + async with app.run_test(): + text_area = app.query_one(TextArea) + text_area.load_text("0123456789") + text_area.selection = Selection((0, 2), (0, 7)) + assert text_area.selected_text == "23456" + + result = text_area.delete((0, 4), (0, 6)) + assert result == EditResult( + replaced_text="45", + end_location=(0, 4), + ) + # We deleted 45, but the other characters are still available + assert text_area.selected_text == "236" + assert text_area.text == "01236789" + + +async def test_replace_fully_within_selection(): + """Adjust the selection when a replacement happens inside it.""" + app = TextAreaApp() + async with app.run_test(): + text_area = app.query_one(TextArea) + text_area.load_text("0123456789") + text_area.selection = Selection((0, 2), (0, 7)) + assert text_area.selected_text == "23456" + + result = text_area.replace("XX", start=(0, 2), end=(0, 5)) + assert result == EditResult( + replaced_text="234", + end_location=(0, 4), + ) + 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 diff --git a/tests/text_area/test_edit_via_bindings.py b/tests/text_area/test_edit_via_bindings.py new file mode 100644 index 0000000000..aa99a63ad9 --- /dev/null +++ b/tests/text_area/test_edit_via_bindings.py @@ -0,0 +1,418 @@ +"""Tests some edits using the keyboard. + +All tests in this module should press keys on the keyboard which edit the document, +and check that the document content is updated as expected, as well as the cursor +location. + +Note that more extensive testing for editing is done at the Document level. +""" +import pytest + +from textual.app import App, ComposeResult +from textual.widgets import TextArea +from textual.widgets.text_area import Selection + +TEXT = """I must not fear. +Fear is the mind-killer. +Fear is the little-death that brings total obliteration. +I will face my fear. +""" + +SIMPLE_TEXT = """\ +ABCDE +FGHIJ +KLMNO +PQRST +UVWXY +Z""" + + +class TextAreaApp(App): + def compose(self) -> ComposeResult: + text_area = TextArea() + text_area.load_text(TEXT) + yield text_area + + +async def test_single_keypress_printable_character(): + app = TextAreaApp() + async with app.run_test() as pilot: + text_area = app.query_one(TextArea) + await pilot.press("x") + assert text_area.text == "x" + TEXT + + +async def test_single_keypress_enter(): + app = TextAreaApp() + async with app.run_test() as pilot: + text_area = app.query_one(TextArea) + await pilot.press("enter") + assert text_area.text == "\n" + TEXT + + +@pytest.mark.parametrize( + "content,cursor_column,cursor_destination", + [ + ("", 0, 4), + ("x", 0, 4), + ("x", 1, 4), + ("xxx", 3, 4), + ("xxxx", 4, 8), + ("xxxxx", 5, 8), + ("xxxxxx", 6, 8), + ("💩", 1, 3), + ("💩💩", 2, 6), + ], +) +async def test_tab_with_spaces_goes_to_tab_stop( + content, cursor_column, cursor_destination +): + app = TextAreaApp() + async with app.run_test() as pilot: + text_area = app.query_one(TextArea) + text_area.indent_width = 4 + text_area.load_text(content) + text_area.cursor_location = (0, cursor_column) + + await pilot.press("tab") + + assert text_area.cursor_location[1] == cursor_destination + + +async def test_delete_left(): + app = TextAreaApp() + async with app.run_test() as pilot: + text_area = app.query_one(TextArea) + text_area.load_text("Hello, world!") + text_area.move_cursor((0, 6)) + await pilot.press("backspace") + assert text_area.text == "Hello world!" + assert text_area.selection == Selection.cursor((0, 5)) + + +async def test_delete_left_start(): + app = TextAreaApp() + async with app.run_test() as pilot: + text_area = app.query_one(TextArea) + text_area.load_text("Hello, world!") + await pilot.press("backspace") + assert text_area.text == "Hello, world!" + assert text_area.selection == Selection.cursor((0, 0)) + + +async def test_delete_left_end(): + app = TextAreaApp() + async with app.run_test() as pilot: + text_area = app.query_one(TextArea) + text_area.load_text("Hello, world!") + text_area.move_cursor((0, 13)) + await pilot.press("backspace") + assert text_area.text == "Hello, world" + assert text_area.selection == Selection.cursor((0, 12)) + + +@pytest.mark.parametrize( + "key,selection", + [ + ("delete", Selection((1, 2), (3, 4))), + ("delete", Selection((3, 4), (1, 2))), + ("backspace", Selection((1, 2), (3, 4))), + ("backspace", Selection((3, 4), (1, 2))), + ], +) +async def test_deletion_with_non_empty_selection(key, selection): + """When there's a selection, pressing backspace or delete should delete everything + that is selected and reset the selection to a cursor at the appropriate location.""" + app = TextAreaApp() + async with app.run_test() as pilot: + text_area = app.query_one(TextArea) + text_area.load_text(SIMPLE_TEXT) + text_area.selection = selection + await pilot.press(key) + assert text_area.selection == Selection.cursor((1, 2)) + assert ( + text_area.text + == """\ +ABCDE +FGT +UVWXY +Z""" + ) + + +async def test_delete_right(): + """Pressing 'delete' deletes the character to the right of the cursor.""" + app = TextAreaApp() + async with app.run_test() as pilot: + text_area = app.query_one(TextArea) + text_area.load_text("Hello, world!") + text_area.move_cursor((0, 13)) + await pilot.press("delete") + assert text_area.text == "Hello, world!" + assert text_area.selection == Selection.cursor((0, 13)) + + +async def test_delete_right_end_of_line(): + """Pressing 'delete' at the end of the line merges this line with the line below.""" + app = TextAreaApp() + async with app.run_test() as pilot: + text_area = app.query_one(TextArea) + text_area.load_text("hello\nworld!") + end_of_line = text_area.get_cursor_line_end_location() + text_area.move_cursor(end_of_line) + await pilot.press("delete") + assert text_area.selection == Selection.cursor((0, 5)) + assert text_area.text == "helloworld!" + + +@pytest.mark.parametrize( + "selection,expected_result", + [ + (Selection.cursor((0, 0)), ""), + (Selection.cursor((0, 4)), ""), + (Selection.cursor((0, 10)), ""), + (Selection((0, 2), (0, 4)), ""), + (Selection((0, 4), (0, 2)), ""), + ], +) +async def test_delete_line(selection, expected_result): + app = TextAreaApp() + async with app.run_test() as pilot: + text_area = app.query_one(TextArea) + text_area.load_text("0123456789") + text_area.selection = selection + + await pilot.press("ctrl+x") + + assert text_area.selection == Selection.cursor((0, 0)) + assert text_area.text == expected_result + + +@pytest.mark.parametrize( + "selection,expected_result", + [ + # Cursors + (Selection.cursor((0, 0)), "345\n678\n9\n"), + (Selection.cursor((0, 2)), "345\n678\n9\n"), + (Selection.cursor((3, 1)), "012\n345\n678\n"), + (Selection.cursor((4, 0)), "012\n345\n678\n9\n"), + # Selections + (Selection((1, 1), (1, 2)), "012\n678\n9\n"), # non-empty single line selection + (Selection((1, 2), (2, 1)), "012\n9\n"), # delete lines selection touches + ( + Selection((1, 2), (3, 0)), + "012\n9\n", + ), # cursor at column 0 of line 3, should not be deleted! + ( + Selection((3, 0), (1, 2)), + "012\n9\n", + ), # opposite direction + (Selection((0, 0), (4, 0)), ""), # delete all lines + ], +) +async def test_delete_line_multiline_document(selection, expected_result): + app = TextAreaApp() + async with app.run_test() as pilot: + text_area = app.query_one(TextArea) + text_area.load_text("012\n345\n678\n9\n") + text_area.selection = selection + + await pilot.press("ctrl+x") + + cursor_row, _ = text_area.cursor_location + assert text_area.selection == Selection.cursor((cursor_row, 0)) + assert text_area.text == expected_result + + +@pytest.mark.parametrize( + "selection,expected_result", + [ + # Cursors + (Selection.cursor((0, 0)), ""), + (Selection.cursor((0, 5)), "01234"), + (Selection.cursor((0, 9)), "012345678"), + (Selection.cursor((0, 10)), "0123456789"), + # Selections + (Selection((0, 0), (0, 9)), "012345678"), + (Selection((0, 0), (0, 10)), "0123456789"), + (Selection((0, 2), (0, 5)), "01234"), + (Selection((0, 5), (0, 2)), "01"), + ], +) +async def test_delete_to_end_of_line(selection, expected_result): + app = TextAreaApp() + async with app.run_test() as pilot: + text_area = app.query_one(TextArea) + text_area.load_text("0123456789") + text_area.selection = selection + + await pilot.press("ctrl+k") + + assert text_area.selection == Selection.cursor(selection.end) + assert text_area.text == expected_result + + +@pytest.mark.parametrize( + "selection,expected_result", + [ + # Cursors + (Selection.cursor((0, 0)), "0123456789"), + (Selection.cursor((0, 5)), "56789"), + (Selection.cursor((0, 9)), "9"), + (Selection.cursor((0, 10)), ""), + # Selections + (Selection((0, 0), (0, 9)), "9"), + (Selection((0, 0), (0, 10)), ""), + (Selection((0, 2), (0, 5)), "56789"), + (Selection((0, 5), (0, 2)), "23456789"), + ], +) +async def test_delete_to_start_of_line(selection, expected_result): + app = TextAreaApp() + async with app.run_test() as pilot: + text_area = app.query_one(TextArea) + text_area.load_text("0123456789") + text_area.selection = selection + + await pilot.press("ctrl+u") + + assert text_area.selection == Selection.cursor((0, 0)) + assert text_area.text == expected_result + + +@pytest.mark.parametrize( + "selection,expected_result,final_selection", + [ + (Selection.cursor((0, 0)), " 012 345 6789", Selection.cursor((0, 0))), + (Selection.cursor((0, 4)), " 2 345 6789", Selection.cursor((0, 2))), + (Selection.cursor((0, 5)), " 345 6789", Selection.cursor((0, 2))), + ( + Selection.cursor((0, 6)), + " 345 6789", + Selection.cursor((0, 2)), + ), + (Selection.cursor((0, 14)), " 012 345 ", Selection.cursor((0, 10))), + # When there's a selection and you "delete word left", it just deletes the selection + (Selection((0, 4), (0, 11)), " 01789", Selection.cursor((0, 4))), + ], +) +async def test_delete_word_left(selection, expected_result, final_selection): + app = TextAreaApp() + async with app.run_test() as pilot: + text_area = app.query_one(TextArea) + text_area.load_text(" 012 345 6789") + text_area.selection = selection + + await pilot.press("ctrl+w") + + assert text_area.text == expected_result + assert text_area.selection == final_selection + + +@pytest.mark.parametrize( + "selection,expected_result,final_selection", + [ + (Selection.cursor((0, 0)), "\t012 \t 345\t6789", Selection.cursor((0, 0))), + (Selection.cursor((0, 4)), "\t \t 345\t6789", Selection.cursor((0, 1))), + (Selection.cursor((0, 5)), "\t\t 345\t6789", Selection.cursor((0, 1))), + ( + Selection.cursor((0, 6)), + "\t 345\t6789", + Selection.cursor((0, 1)), + ), + (Selection.cursor((0, 15)), "\t012 \t 345\t", Selection.cursor((0, 11))), + # When there's a selection and you "delete word left", it just deletes the selection + (Selection((0, 4), (0, 11)), "\t0126789", Selection.cursor((0, 4))), + ], +) +async def test_delete_word_left_with_tabs(selection, expected_result, final_selection): + app = TextAreaApp() + async with app.run_test() as pilot: + text_area = app.query_one(TextArea) + text_area.load_text("\t012 \t 345\t6789") + text_area.selection = selection + + await pilot.press("ctrl+w") + + assert text_area.text == expected_result + assert text_area.selection == final_selection + + +async def test_delete_word_left_to_start_of_line(): + """If no word boundary found when we 'delete word left', then + the deletion happens to the start of the line.""" + app = TextAreaApp() + async with app.run_test() as pilot: + text_area = app.query_one(TextArea) + text_area.load_text("0123\n 456789") + text_area.selection = Selection.cursor((1, 3)) + + await pilot.press("ctrl+w") + + assert text_area.text == "0123\n456789" + assert text_area.selection == Selection.cursor((1, 0)) + + +async def test_delete_word_left_at_line_start(): + """If we're at the start of a line and we 'delete word left', the + line merges with the line above (if possible).""" + app = TextAreaApp() + async with app.run_test() as pilot: + text_area = app.query_one(TextArea) + text_area.load_text("0123\n 456789") + text_area.selection = Selection.cursor((1, 0)) + + await pilot.press("ctrl+w") + + assert text_area.text == "0123 456789" + assert text_area.selection == Selection.cursor((0, 4)) + + +@pytest.mark.parametrize( + "selection,expected_result,final_selection", + [ + (Selection.cursor((0, 0)), "012 345 6789", Selection.cursor((0, 0))), + (Selection.cursor((0, 4)), " 01 345 6789", Selection.cursor((0, 4))), + (Selection.cursor((0, 5)), " 012345 6789", Selection.cursor((0, 5))), + (Selection.cursor((0, 14)), " 012 345 6789", Selection.cursor((0, 14))), + # When non-empty selection, "delete word right" just deletes the selection + (Selection((0, 4), (0, 11)), " 01789", Selection.cursor((0, 4))), + ], +) +async def test_delete_word_right(selection, expected_result, final_selection): + app = TextAreaApp() + async with app.run_test() as pilot: + text_area = app.query_one(TextArea) + text_area.load_text(" 012 345 6789") + text_area.selection = selection + + await pilot.press("ctrl+f") + + assert text_area.text == expected_result + assert text_area.selection == final_selection + + +async def test_delete_word_right_delete_to_end_of_line(): + app = TextAreaApp() + async with app.run_test() as pilot: + text_area = app.query_one(TextArea) + text_area.load_text("01234\n56789") + text_area.selection = Selection.cursor((0, 3)) + + await pilot.press("ctrl+f") + + assert text_area.text == "012\n56789" + assert text_area.selection == Selection.cursor((0, 3)) + + +async def test_delete_word_right_at_end_of_line(): + app = TextAreaApp() + async with app.run_test() as pilot: + text_area = app.query_one(TextArea) + text_area.load_text("01234\n56789") + text_area.selection = Selection.cursor((0, 5)) + + await pilot.press("ctrl+f") + + assert text_area.text == "0123456789" + assert text_area.selection == Selection.cursor((0, 5)) diff --git a/tests/text_area/test_languages.py b/tests/text_area/test_languages.py new file mode 100644 index 0000000000..7e33fcf728 --- /dev/null +++ b/tests/text_area/test_languages.py @@ -0,0 +1,98 @@ +import pytest + +from textual.app import App, ComposeResult +from textual.widgets import TextArea +from textual.widgets.text_area import LanguageDoesNotExist + + +class TextAreaApp(App): + def compose(self) -> ComposeResult: + yield TextArea("print('hello')", language="python") + + +async def test_setting_builtin_language_via_constructor(): + class MyTextAreaApp(App): + def compose(self) -> ComposeResult: + yield TextArea("print('hello')", language="python") + + app = MyTextAreaApp() + + async with app.run_test(): + text_area = app.query_one(TextArea) + assert text_area.language == "python" + + text_area.language = "markdown" + assert text_area.language == "markdown" + + +async def test_setting_builtin_language_via_attribute(): + class MyTextAreaApp(App): + def compose(self) -> ComposeResult: + text_area = TextArea("print('hello')") + text_area.language = "python" + yield text_area + + app = MyTextAreaApp() + + async with app.run_test(): + text_area = app.query_one(TextArea) + assert text_area.language == "python" + + text_area.language = "markdown" + assert text_area.language == "markdown" + + +async def test_setting_language_to_none(): + app = TextAreaApp() + async with app.run_test(): + text_area = app.query_one(TextArea) + text_area.language = None + assert text_area.language is None + + +async def test_setting_unknown_language(): + app = TextAreaApp() + async with app.run_test(): + text_area = app.query_one(TextArea) + + with pytest.raises(LanguageDoesNotExist): + text_area.language = "this-language-doesnt-exist" + + +@pytest.mark.syntax +async def test_register_language(): + app = TextAreaApp() + + async with app.run_test(): + text_area = app.query_one(TextArea) + + from tree_sitter_languages import get_language + + language = get_language("elm") + + # ...and register it with no highlights + text_area.register_language(language, "") + + # Ensure that registered language is now available. + assert "elm" in text_area.available_languages + + # Switch to the newly registered language + text_area.language = "elm" + + assert text_area.language == "elm" + + +@pytest.mark.syntax +async def test_register_language_existing_language(): + app = TextAreaApp() + async with app.run_test(): + text_area = app.query_one(TextArea) + + # Before registering the language, we have highlights as expected. + assert len(text_area._highlights) > 0 + + # Overwriting the highlight query for Python... + text_area.register_language("python", "") + + # We've overridden the highlight query with a blank one, so there are no highlights. + assert text_area._highlights == {} diff --git a/tests/text_area/test_messages.py b/tests/text_area/test_messages.py new file mode 100644 index 0000000000..c6ddbe5a4d --- /dev/null +++ b/tests/text_area/test_messages.py @@ -0,0 +1,91 @@ +from typing import List + +from textual import on +from textual.app import App, ComposeResult +from textual.events import Event +from textual.message import Message +from textual.widgets import TextArea + + +class TextAreaApp(App): + def __init__(self): + super().__init__() + self.messages = [] + + @on(TextArea.Changed) + @on(TextArea.SelectionChanged) + def message_received(self, message: Message): + self.messages.append(message) + + def compose(self) -> ComposeResult: + yield TextArea("123") + + +def get_changed_messages(messages: List[Event]) -> List[TextArea.Changed]: + return [message for message in messages if isinstance(message, TextArea.Changed)] + + +def get_selection_changed_messages( + messages: List[Event], +) -> List[TextArea.SelectionChanged]: + return [ + message + for message in messages + if isinstance(message, TextArea.SelectionChanged) + ] + + +async def test_changed_message_edit_via_api(): + app = TextAreaApp() + async with app.run_test() as pilot: + text_area = app.query_one(TextArea) + assert get_changed_messages(app.messages) == [] + + text_area.insert("A") + await pilot.pause() + + assert get_changed_messages(app.messages) == [TextArea.Changed(text_area)] + assert get_selection_changed_messages(app.messages) == [ + TextArea.SelectionChanged(text_area.selection, text_area) + ] + + +async def test_changed_message_via_typing(): + app = TextAreaApp() + async with app.run_test() as pilot: + text_area = app.query_one(TextArea) + assert get_changed_messages(app.messages) == [] + + await pilot.press("a") + + assert get_changed_messages(app.messages) == [TextArea.Changed(text_area)] + assert get_selection_changed_messages(app.messages) == [ + TextArea.SelectionChanged(text_area.selection, text_area) + ] + + +async def test_selection_changed_via_api(): + app = TextAreaApp() + async with app.run_test() as pilot: + text_area = app.query_one(TextArea) + assert get_selection_changed_messages(app.messages) == [] + + text_area.cursor_location = (0, 1) + await pilot.pause() + + assert get_selection_changed_messages(app.messages) == [ + TextArea.SelectionChanged(text_area.selection, text_area) + ] + + +async def test_selection_changed_via_typing(): + app = TextAreaApp() + async with app.run_test() as pilot: + text_area = app.query_one(TextArea) + assert get_selection_changed_messages(app.messages) == [] + + await pilot.press("a") + + assert get_selection_changed_messages(app.messages) == [ + TextArea.SelectionChanged(text_area.selection, text_area) + ] diff --git a/tests/text_area/test_selection.py b/tests/text_area/test_selection.py new file mode 100644 index 0000000000..bbc70e476e --- /dev/null +++ b/tests/text_area/test_selection.py @@ -0,0 +1,336 @@ +import pytest + +from textual.app import App, ComposeResult +from textual.widgets import TextArea +from textual.widgets.text_area import Selection + +TEXT = """I must not fear. +Fear is the mind-killer. +Fear is the little-death that brings total obliteration. +I will face my fear. +""" + + +class TextAreaApp(App): + def compose(self) -> ComposeResult: + text_area = TextArea() + text_area.load_text(TEXT) + yield text_area + + +async def test_default_selection(): + """The cursor starts at (0, 0) in the document.""" + app = TextAreaApp() + async with app.run_test(): + text_area = app.query_one(TextArea) + assert text_area.selection == Selection.cursor((0, 0)) + + +async def test_cursor_location_get(): + app = TextAreaApp() + async with app.run_test(): + text_area = app.query_one(TextArea) + text_area.selection = Selection((1, 1), (2, 2)) + assert text_area.cursor_location == (2, 2) + + +async def test_cursor_location_set(): + app = TextAreaApp() + async with app.run_test(): + text_area = app.query_one(TextArea) + target = (1, 2) + text_area.cursor_location = target + assert text_area.selection == Selection.cursor(target) + + +async def test_cursor_location_set_while_selecting(): + """If you set the cursor_location while a selection is in progress, + the start/anchor point of the selection will remain where it is.""" + app = TextAreaApp() + async with app.run_test(): + text_area = app.query_one(TextArea) + text_area.selection = Selection((0, 0), (0, 2)) + target = (1, 2) + text_area.cursor_location = target + assert text_area.selection == Selection((0, 0), target) + + +async def test_move_cursor_select(): + app = TextAreaApp() + async with app.run_test(): + text_area = app.query_one(TextArea) + text_area.selection = Selection((1, 1), (2, 2)) + text_area.move_cursor((2, 3), select=True) + assert text_area.selection == Selection((1, 1), (2, 3)) + + +async def test_move_cursor_relative(): + app = TextAreaApp() + async with app.run_test(): + text_area = app.query_one(TextArea) + + text_area.move_cursor_relative(rows=1, columns=2) + assert text_area.selection == Selection.cursor((1, 2)) + + text_area.move_cursor_relative(rows=-1, columns=-2) + assert text_area.selection == Selection.cursor((0, 0)) + + text_area.move_cursor_relative(rows=1000, columns=1000) + assert text_area.selection == Selection.cursor((4, 0)) + + +async def test_selected_text_forward(): + """Selecting text from top to bottom results in the correct selected_text.""" + app = TextAreaApp() + async with app.run_test(): + text_area = app.query_one(TextArea) + text_area.selection = Selection((0, 0), (2, 0)) + assert ( + text_area.selected_text + == """\ +I must not fear. +Fear is the mind-killer. +""" + ) + + +async def test_selected_text_backward(): + """Selecting text from bottom to top results in the correct selected_text.""" + app = TextAreaApp() + async with app.run_test(): + text_area = app.query_one(TextArea) + text_area.selection = Selection((2, 0), (0, 0)) + assert ( + text_area.selected_text + == """\ +I must not fear. +Fear is the mind-killer. +""" + ) + + +async def test_selected_text_multibyte(): + app = TextAreaApp() + async with app.run_test(): + text_area = app.query_one(TextArea) + text_area.load_text("こんにちは") + text_area.selection = Selection((0, 1), (0, 3)) + assert text_area.selected_text == "んに" + + +async def test_selection_clamp(): + """When you set the selection reactive, it's clamped to within the document bounds.""" + app = TextAreaApp() + async with app.run_test(): + text_area = app.query_one(TextArea) + text_area.selection = Selection((99, 99), (100, 100)) + assert text_area.selection == Selection(start=(4, 0), end=(4, 0)) + + +@pytest.mark.parametrize( + "start,end", + [ + ((0, 0), (0, 0)), + ((0, 4), (0, 3)), + ((1, 0), (0, 16)), + ], +) +async def test_get_cursor_left_location(start, end): + app = TextAreaApp() + async with app.run_test(): + text_area = app.query_one(TextArea) + text_area.move_cursor(start) + assert text_area.get_cursor_left_location() == end + + +@pytest.mark.parametrize( + "start,end", + [ + ((0, 0), (0, 1)), + ((0, 16), (1, 0)), + ((3, 20), (4, 0)), + ((4, 0), (4, 0)), + ], +) +async def test_get_cursor_right_location(start, end): + app = TextAreaApp() + async with app.run_test(): + text_area = app.query_one(TextArea) + text_area.move_cursor(start) + assert text_area.get_cursor_right_location() == end + + +@pytest.mark.parametrize( + "start,end", + [ + ((0, 4), (0, 0)), # jump to start + ((1, 2), (0, 2)), # go to column above + ((2, 56), (1, 24)), # snap to end of row above + ], +) +async def test_get_cursor_up_location(start, end): + app = TextAreaApp() + async with app.run_test(): + text_area = app.query_one(TextArea) + text_area.move_cursor(start) + # This is required otherwise the cursor will snap back to the + # last location navigated to (0, 0) + text_area.record_cursor_width() + assert text_area.get_cursor_up_location() == end + + +@pytest.mark.parametrize( + "start,end", + [ + ((3, 4), (4, 0)), # jump to end + ((1, 2), (2, 2)), # go to column above + ((2, 56), (3, 20)), # snap to end of row below + ], +) +async def test_get_cursor_down_location(start, end): + app = TextAreaApp() + async with app.run_test(): + text_area = app.query_one(TextArea) + text_area.move_cursor(start) + # This is required otherwise the cursor will snap back to the + # last location navigated to (0, 0) + text_area.record_cursor_width() + assert text_area.get_cursor_down_location() == end + + +@pytest.mark.parametrize( + "start,end", + [ + ((0, 0), (0, 0)), + ((0, 1), (0, 0)), + ((0, 2), (0, 0)), + ((0, 3), (0, 0)), + ((0, 4), (0, 3)), + ((0, 5), (0, 3)), + ((0, 6), (0, 3)), + ((0, 7), (0, 3)), + ((0, 10), (0, 7)), + ((1, 0), (0, 10)), + ((1, 2), (1, 0)), + ((1, 4), (1, 0)), + ((1, 7), (1, 4)), + ((1, 8), (1, 7)), + ((1, 13), (1, 11)), + ((1, 14), (1, 11)), + ], +) +async def test_cursor_word_left_location(start, end): + app = TextAreaApp() + async with app.run_test(): + text_area = app.query_one(TextArea) + text_area.load_text("AB CD EFG\n HI\tJK LM ") + text_area.move_cursor(start) + assert text_area.get_cursor_word_left_location() == end + + +@pytest.mark.parametrize( + "start,end", + [ + ((0, 0), (0, 2)), + ((0, 1), (0, 2)), + ((0, 2), (0, 5)), + ((0, 3), (0, 5)), + ((0, 4), (0, 5)), + ((0, 5), (0, 10)), + ((0, 6), (0, 10)), + ((0, 7), (0, 10)), + ((0, 10), (1, 0)), + ((1, 0), (1, 6)), + ((1, 2), (1, 6)), + ((1, 4), (1, 6)), + ((1, 7), (1, 9)), + ((1, 8), (1, 9)), + ((1, 13), (1, 14)), + ((1, 14), (1, 14)), + ], +) +async def test_cursor_word_right_location(start, end): + app = TextAreaApp() + async with app.run_test(): + text_area = app.query_one(TextArea) + text_area.load_text("AB CD EFG\n HI\tJK LM ") + text_area.move_cursor(start) + assert text_area.get_cursor_word_right_location() == end + + +@pytest.mark.parametrize( + "content,expected_selection", + [ + ("123\n456\n789", Selection((0, 0), (2, 3))), + ("123\n456\n789\n", Selection((0, 0), (3, 0))), + ("", Selection((0, 0), (0, 0))), + ], +) +async def test_select_all(content, expected_selection): + app = TextAreaApp() + async with app.run_test(): + text_area = app.query_one(TextArea) + text_area.load_text(content) + + text_area.select_all() + + assert text_area.selection == expected_selection + + +@pytest.mark.parametrize( + "index,content,expected_selection", + [ + (1, "123\n456\n789\n", Selection((1, 0), (1, 3))), + (2, "123\n456\n789\n", Selection((2, 0), (2, 3))), + (3, "123\n456\n789\n", Selection((3, 0), (3, 0))), + (1000, "123\n456\n789\n", Selection.cursor((0, 0))), + (0, "", Selection((0, 0), (0, 0))), + ], +) +async def test_select_line(index, content, expected_selection): + app = TextAreaApp() + async with app.run_test(): + text_area = app.query_one(TextArea) + text_area.load_text(content) + + text_area.select_line(index) + + assert text_area.selection == expected_selection + + +async def test_cursor_screen_offset_and_terminal_cursor_position_update(): + class TextAreaCursorScreenOffset(App): + def compose(self) -> ComposeResult: + yield TextArea("abc\ndef") + + app = TextAreaCursorScreenOffset() + async with app.run_test(): + text_area = app.query_one(TextArea) + + assert app.cursor_position == (3, 0) + + text_area.cursor_location = (1, 1) + + assert text_area.cursor_screen_offset == (4, 1) + + # 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) + + +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") + + app = TextAreaCursorScreenOffset() + async with app.run_test(size=(80, 2)) as pilot: + text_area = app.query_one(TextArea) + + assert app.cursor_position == (3, 0) + + text_area.cursor_location = (5, 0) + await pilot.pause() + + assert text_area.cursor_screen_offset == (3, 1) + assert app.cursor_position == (3, 1) diff --git a/tests/text_area/test_selection_bindings.py b/tests/text_area/test_selection_bindings.py new file mode 100644 index 0000000000..76d4586df4 --- /dev/null +++ b/tests/text_area/test_selection_bindings.py @@ -0,0 +1,318 @@ +import pytest + +from textual.app import App, ComposeResult +from textual.geometry import Offset +from textual.widgets import TextArea +from textual.widgets.text_area import Document, Selection + +TEXT = """I must not fear. +Fear is the mind-killer. +Fear is the little-death that brings total obliteration. +I will face my fear. +""" + + +class TextAreaApp(App): + def compose(self) -> ComposeResult: + text_area = TextArea() + text_area.load_text(TEXT) + yield text_area + + +async def test_mouse_click(): + """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)) + + +async def test_mouse_click_clamp_from_right(): + """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(): + """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)) + + +async def test_cursor_movement_basic(): + app = TextAreaApp() + async with app.run_test() as pilot: + text_area = app.query_one(TextArea) + text_area.load_text("01234567\n012345\n0123456789") + + await pilot.press("right") + assert text_area.selection == Selection.cursor((0, 1)) + + await pilot.press("down") + assert text_area.selection == Selection.cursor((1, 1)) + + await pilot.press("left") + assert text_area.selection == Selection.cursor((1, 0)) + + await pilot.press("up") + assert text_area.selection == Selection.cursor((0, 0)) + + +async def test_cursor_selection_right(): + """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(): + """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)) + await pilot.press(*["shift+right"] * 4) + assert text_area.selection == Selection((0, 15), (1, 2)) + + +async def test_cursor_selection_left(): + """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)) + await pilot.press(*["shift+left"] * 3) + assert text_area.selection == Selection((2, 5), (2, 2)) + + +async def test_cursor_selection_left_to_previous_line(): + """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)) + await pilot.press(*["shift+left"] * 3) + + # The cursor jumps up to the end of the line above. + end_of_previous_line = len(TEXT.splitlines()[1]) + assert text_area.selection == Selection((2, 2), (1, end_of_previous_line)) + + +async def test_cursor_selection_up(): + """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)) + + await pilot.press("shift+up") + assert text_area.selection == Selection((2, 3), (1, 3)) + + +async def test_cursor_selection_up_when_cursor_on_first_line(): + """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)) + + await pilot.press("shift+up") + assert text_area.selection == Selection((0, 4), (0, 0)) + await pilot.press("shift+up") + assert text_area.selection == Selection((0, 4), (0, 0)) + + +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)) + + await pilot.press("shift+down") + assert text_area.selection == Selection((2, 5), (3, 5)) + + +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") + text_area.move_cursor((1, 2)) + + await pilot.press("shift+down") + assert text_area.selection == Selection((1, 2), (1, 5)) + await pilot.press("shift+down") + assert text_area.selection == Selection((1, 2), (1, 5)) + + +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") + + await pilot.press("ctrl+right") + + assert text_area.selection == Selection.cursor((0, 3)) + + +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") + + await pilot.press("ctrl+shift+right") + + assert text_area.selection == Selection((0, 0), (0, 3)) + + +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") + text_area.move_cursor((0, 7)) + + await pilot.press("ctrl+left") + + assert text_area.selection == Selection.cursor((0, 4)) + + +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") + text_area.move_cursor((0, 7)) + + await pilot.press("ctrl+shift+left") + + assert text_area.selection == Selection((0, 7), (0, 4)) + + +@pytest.mark.parametrize("key", ["end", "ctrl+e"]) +async def test_cursor_to_line_end(key): + """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)) + await pilot.press(key) + eol_index = len(TEXT.splitlines()[2]) + assert text_area.cursor_location == (2, eol_index) + assert text_area.selection.is_empty + + +@pytest.mark.parametrize("key", ["home", "ctrl+a"]) +async def test_cursor_to_line_home_basic_behaviour(key): + """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)) + await pilot.press(key) + assert text_area.cursor_location == (2, 0) + assert text_area.selection.is_empty + + +@pytest.mark.parametrize( + "cursor_start,cursor_destination", + [ + ((0, 0), (0, 4)), + ((0, 2), (0, 0)), + ((0, 4), (0, 0)), + ((0, 5), (0, 4)), + ((0, 9), (0, 4)), + ((0, 15), (0, 4)), + ], +) +async def test_cursor_line_home_smart_home(cursor_start, cursor_destination): + """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") + text_area.move_cursor(cursor_start) + await pilot.press("home") + assert text_area.selection == Selection.cursor(cursor_destination) + + +async def test_cursor_page_down(): + """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)) + + +async def test_cursor_page_up(): + """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") + assert text_area.selection == Selection.cursor( + (100 - app.console.height + 1, 1) + ) + + +async def test_cursor_vertical_movement_visual_alignment_snapping(): + """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.move_cursor((1, 3), record_width=True) + + # The '3' is aligned with ん at (0, 1) + # こんにちは + # 012345 + # Pressing `up` takes us from (1, 3) to (0, 1) because record_width=True. + await pilot.press("up") + assert text_area.selection == Selection.cursor((0, 1)) + + # Pressing `down` takes us from (0, 1) to (1, 3) + await pilot.press("down") + assert text_area.selection == Selection.cursor((1, 3)) + + +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)) + + await pilot.press("f6") + + assert text_area.selection == Selection((2, 0), (2, 56)) + + +async def test_select_all_binding(): + app = TextAreaApp() + async with app.run_test() as pilot: + text_area = app.query_one(TextArea) + + await pilot.press("f7") + + assert text_area.selection == Selection((0, 0), (4, 0)) diff --git a/tests/text_area/test_setting_themes.py b/tests/text_area/test_setting_themes.py new file mode 100644 index 0000000000..8d165a98a9 --- /dev/null +++ b/tests/text_area/test_setting_themes.py @@ -0,0 +1,67 @@ +import pytest + +from textual._text_area_theme import TextAreaTheme +from textual.app import App, ComposeResult +from textual.widgets import TextArea +from textual.widgets._text_area import ThemeDoesNotExist + + +class TextAreaApp(App): + def compose(self) -> ComposeResult: + yield TextArea("print('hello')", language="python") + + +async def test_default_theme(): + app = TextAreaApp() + + async with app.run_test(): + text_area = app.query_one(TextArea) + assert text_area.theme is None + + +async def test_setting_builtin_themes(): + class MyTextAreaApp(App): + def compose(self) -> ComposeResult: + yield TextArea("print('hello')", language="python", theme="vscode_dark") + + app = MyTextAreaApp() + + async with app.run_test(): + text_area = app.query_one(TextArea) + assert text_area.theme == "vscode_dark" + + text_area.theme = "monokai" + 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(): + text_area = app.query_one(TextArea) + with pytest.raises(ThemeDoesNotExist): + text_area.theme = "this-theme-doesnt-exist" + + +async def test_registering_and_setting_theme(): + app = TextAreaApp() + + async with app.run_test(): + text_area = app.query_one(TextArea) + text_area.register_theme(TextAreaTheme("my-theme")) + + assert "my-theme" in text_area.available_themes + + text_area.theme = "my-theme" + + assert text_area.theme == "my-theme" diff --git a/tests/toggles/test_labels.py b/tests/toggles/test_labels.py new file mode 100644 index 0000000000..a879351d0d --- /dev/null +++ b/tests/toggles/test_labels.py @@ -0,0 +1,36 @@ +"""Test that setting a toggle button's label has the desired effect.""" + +from rich.text import Text + +from textual.app import App, ComposeResult +from textual.widgets import Checkbox, RadioButton, RadioSet + + +class LabelChangeApp(App[None]): + def compose(self) -> ComposeResult: + yield Checkbox("Before") + yield RadioButton("Before") + yield RadioSet("Before") + + +async def test_change_labels() -> None: + """It should be possible to change the labels of toggle buttons.""" + async with LabelChangeApp().run_test() as pilot: + assert pilot.app.query_one(Checkbox).label == Text("Before") + assert pilot.app.query_one("Screen > RadioButton", RadioButton).label == Text( + "Before" + ) + assert pilot.app.query_one("RadioSet > RadioButton", RadioButton).label == Text( + "Before" + ) + pilot.app.query_one(Checkbox).label = "After" + pilot.app.query_one("Screen > RadioButton", RadioButton).label = "After" + pilot.app.query_one("RadioSet > RadioButton", RadioButton).label = "After" + await pilot.pause() + assert pilot.app.query_one(Checkbox).label == Text("After") + assert pilot.app.query_one("Screen > RadioButton", RadioButton).label == Text( + "After" + ) + assert pilot.app.query_one("RadioSet > RadioButton", RadioButton).label == Text( + "After" + ) diff --git a/tests/toggles/test_radioset.py b/tests/toggles/test_radioset.py index 4a3ab34bbe..457dbd2331 100644 --- a/tests/toggles/test_radioset.py +++ b/tests/toggles/test_radioset.py @@ -135,3 +135,49 @@ async def test_there_can_only_be_one(): async with BadRadioSetApp().run_test() as pilot: assert len(pilot.app.query("RadioButton.-on")) == 1 assert pilot.app.query_one(RadioSet).pressed_index == 0 + + +class RadioSetDisabledButtonsApp(App[None]): + def compose(self) -> ComposeResult: + self.selected = [] + with RadioSet(): + yield RadioButton("0", disabled=True) + yield RadioButton("1") + yield RadioButton("2", disabled=True) + yield RadioButton("3", disabled=True) + yield RadioButton("4") + yield RadioButton("5") + yield RadioButton("6", disabled=True) + yield RadioButton("7") + yield RadioButton("8", disabled=True) + + def on_radio_set_changed(self, radio_set: RadioSet.Changed) -> None: + self.selected.append(str(radio_set.pressed.label)) + + +async def test_keyboard_navigation_with_disabled_buttons(): + """Regression test for https://github.com/Textualize/textual/issues/3839.""" + + app = RadioSetDisabledButtonsApp() + async with app.run_test() as pilot: + await pilot.press("enter") + for _ in range(5): + await pilot.press("down") + await pilot.press("enter") + for _ in range(5): + await pilot.press("up") + await pilot.press("enter") + + assert app.selected == [ + "1", + "4", + "5", + "7", + "1", + "4", + "1", + "7", + "5", + "4", + "1", + ] diff --git a/tests/tree/test_node_refresh.py b/tests/tree/test_node_refresh.py new file mode 100644 index 0000000000..53e98b7387 --- /dev/null +++ b/tests/tree/test_node_refresh.py @@ -0,0 +1,64 @@ +from rich.style import Style +from rich.text import Text + +from textual.app import App, ComposeResult +from textual.widgets import Tree +from textual.widgets.tree import TreeNode + +class HistoryTree(Tree): + + def __init__(self) -> None: + super().__init__("Root") + self.counter = 0 + self.render_hits: set[tuple[int, int]] = set() + + def on_mount(self) -> None: + self.root.add("Child").add_leaf("Grandchild") + + def render_label(self, node: TreeNode, base_style: Style, style: Style) -> Text: + self.render_hits.add((node.id, self.counter)) + return super().render_label(node, base_style, style) + + +class RefreshApp(App[None]): + + def compose(self) -> ComposeResult: + yield HistoryTree() + + def on_mount(self) -> None: + self.query_one(HistoryTree).root.expand_all() + + +async def test_initial_state() -> None: + """Initially all the visible nodes should have had a render call.""" + app = RefreshApp() + async with app.run_test(): + assert app.query_one(HistoryTree).render_hits == {(0,0), (1,0), (2,0)} + + +async def test_root_refresh() -> None: + """A refresh of the root node should cause a subsequent render call.""" + async with RefreshApp().run_test() as pilot: + assert (0, 1) not in pilot.app.query_one(HistoryTree).render_hits + pilot.app.query_one(HistoryTree).counter += 1 + pilot.app.query_one(HistoryTree).root.refresh() + await pilot.pause() + assert (0, 1) in pilot.app.query_one(HistoryTree).render_hits + +async def test_child_refresh() -> None: + """A refresh of the child node should cause a subsequent render call.""" + async with RefreshApp().run_test() as pilot: + assert (1, 1) not in pilot.app.query_one(HistoryTree).render_hits + pilot.app.query_one(HistoryTree).counter += 1 + pilot.app.query_one(HistoryTree).root.children[0].refresh() + await pilot.pause() + assert (1, 1) in pilot.app.query_one(HistoryTree).render_hits + +async def test_grandchild_refresh() -> None: + """A refresh of the grandchild node should cause a subsequent render call.""" + async with RefreshApp().run_test() as pilot: + assert (2, 1) not in pilot.app.query_one(HistoryTree).render_hits + pilot.app.query_one(HistoryTree).counter += 1 + pilot.app.query_one(HistoryTree).root.children[0].children[0].refresh() + await pilot.pause() + assert (2, 1) in pilot.app.query_one(HistoryTree).render_hits diff --git a/tests/tree/test_tree_availability.py b/tests/tree/test_tree_availability.py new file mode 100644 index 0000000000..c3f509446e --- /dev/null +++ b/tests/tree/test_tree_availability.py @@ -0,0 +1,118 @@ +from __future__ import annotations + +from typing import Any + +from textual import on +from textual.app import App, ComposeResult +from textual.widgets import Tree + + +class TreeApp(App[None]): + """Test tree app.""" + + def __init__(self, disabled: bool, *args: Any, **kwargs: Any) -> None: + super().__init__(*args, **kwargs) + self.disabled = disabled + self.messages: list[tuple[str, str]] = [] + + def compose(self) -> ComposeResult: + """Compose the child widgets.""" + yield Tree("Root", disabled=self.disabled, id="test-tree") + + def on_mount(self) -> None: + self.query_one(Tree).root.add("Child") + self.query_one(Tree).focus() + + def record( + self, + 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") + ) + + @on(Tree.NodeSelected) + def node_selected(self, event: Tree.NodeSelected[None]) -> None: + self.record(event) + + @on(Tree.NodeExpanded) + def node_expanded(self, event: Tree.NodeExpanded[None]) -> None: + self.record(event) + + @on(Tree.NodeCollapsed) + def node_collapsed(self, event: Tree.NodeCollapsed[None]) -> None: + self.record(event) + + @on(Tree.NodeHighlighted) + def node_highlighted(self, event: Tree.NodeHighlighted[None]) -> None: + self.record(event) + + +async def test_creating_disabled_tree(): + """Mounting a disabled `Tree` should result in the base `Widget` + having a `disabled` property equal to `True`""" + app = TreeApp(disabled=True) + async with app.run_test() as pilot: + tree = app.query_one(Tree) + assert not tree.focusable + assert tree.disabled + assert tree.cursor_line == 0 + await pilot.click("#test-tree") + await pilot.pause() + await pilot.press("down") + await pilot.pause() + assert tree.cursor_line == 0 + + +async def test_creating_enabled_tree(): + """Mounting an enabled `Tree` should result in the base `Widget` + having a `disabled` property equal to `False`""" + app = TreeApp(disabled=False) + async with app.run_test() as pilot: + tree = app.query_one(Tree) + assert tree.focusable + assert not tree.disabled + assert tree.cursor_line == 0 + await pilot.click("#test-tree") + await pilot.pause() + await pilot.press("down") + await pilot.pause() + assert tree.cursor_line == 1 + + +async def test_disabled_tree_node_selected_message() -> None: + """Clicking the root node disclosure triangle on a disabled tree + should result in no messages being emitted.""" + app = TreeApp(disabled=True) + async with app.run_test() as pilot: + tree = app.query_one(Tree) + # try clicking on a disabled tree + await pilot.click("#test-tree") + await pilot.pause() + assert not pilot.app.messages + # make sure messages DO flow after enabling a disabled tree + tree.disabled = False + await pilot.click("#test-tree") + await pilot.pause() + assert pilot.app.messages == [("NodeExpanded", "test-tree")] + + +async def test_enabled_tree_node_selected_message() -> None: + """Clicking the root node disclosure triangle on an enabled tree + should result in an `NodeExpanded` message being emitted.""" + app = TreeApp(disabled=False) + async with app.run_test() as pilot: + tree = app.query_one(Tree) + # try clicking on an enabled tree + await pilot.click("#test-tree") + await pilot.pause() + assert pilot.app.messages == [("NodeExpanded", "test-tree")] + tree.disabled = True + # make sure messages DO NOT flow after disabling an enabled tree + app.messages = [] + await pilot.click("#test-tree") + await pilot.pause() + assert not pilot.app.messages diff --git a/tests/tree/test_tree_clearing.py b/tests/tree/test_tree_clearing.py index bd868ee6de..02a2ce711d 100644 --- a/tests/tree/test_tree_clearing.py +++ b/tests/tree/test_tree_clearing.py @@ -4,7 +4,7 @@ from textual.app import App, ComposeResult from textual.widgets import Tree -from textual.widgets.tree import TreeNode +from textual.widgets.tree import RemoveRootError class VerseBody: @@ -106,5 +106,5 @@ async def test_tree_remove_children_of_root(): async def test_attempt_to_remove_root(): """Attempting to remove the root should be an error.""" async with TreeClearApp().run_test() as pilot: - with pytest.raises(TreeNode.RemoveRootError): + with pytest.raises(RemoveRootError): pilot.app.query_one(VerseTree).root.remove() diff --git a/tests/tree/test_tree_get_node_by_id.py b/tests/tree/test_tree_get_node_by_id.py index 62f481aa98..60f73f1864 100644 --- a/tests/tree/test_tree_get_node_by_id.py +++ b/tests/tree/test_tree_get_node_by_id.py @@ -3,7 +3,7 @@ import pytest from textual.widgets import Tree -from textual.widgets._tree import NodeID +from textual.widgets.tree import NodeID, UnknownNodeID def test_get_tree_node_by_id() -> None: @@ -14,5 +14,5 @@ def test_get_tree_node_by_id() -> None: assert tree.get_node_by_id(tree.root.id).id == tree.root.id assert tree.get_node_by_id(child.id).id == child.id assert tree.get_node_by_id(grandchild.id).id == grandchild.id - with pytest.raises(Tree.UnknownNodeID): + with pytest.raises(UnknownNodeID): tree.get_node_by_id(cast(NodeID, grandchild.id + 1000)) diff --git a/tests/workers/test_work_decorator.py b/tests/workers/test_work_decorator.py index ba5ceaaa27..a7d01fa187 100644 --- a/tests/workers/test_work_decorator.py +++ b/tests/workers/test_work_decorator.py @@ -1,6 +1,6 @@ import asyncio from time import sleep -from typing import Callable +from typing import Callable, List, Tuple import pytest @@ -88,3 +88,99 @@ class _(App[None]): @work(thread=False) def foo(self) -> None: pass + + +class NestedWorkersApp(App[None]): + def __init__(self, call_stack: List[str]): + self.call_stack = call_stack + super().__init__() + + def call_from_stack(self): + if self.call_stack: + call_now = self.call_stack.pop() + getattr(self, call_now)() + + @work(thread=False) + async def async_no_thread(self): + self.call_from_stack() + + @work(thread=True) + async def async_thread(self): + self.call_from_stack() + + @work(thread=True) + def thread(self): + self.call_from_stack() + + +@pytest.mark.parametrize( + "call_stack", + [ # from itertools import product; list(product("async_no_thread async_thread thread".split(), repeat=3)) + ("async_no_thread", "async_no_thread", "async_no_thread"), + ("async_no_thread", "async_no_thread", "async_thread"), + ("async_no_thread", "async_no_thread", "thread"), + ("async_no_thread", "async_thread", "async_no_thread"), + ("async_no_thread", "async_thread", "async_thread"), + ("async_no_thread", "async_thread", "thread"), + ("async_no_thread", "thread", "async_no_thread"), + ("async_no_thread", "thread", "async_thread"), + ("async_no_thread", "thread", "thread"), + ("async_thread", "async_no_thread", "async_no_thread"), + ("async_thread", "async_no_thread", "async_thread"), + ("async_thread", "async_no_thread", "thread"), + ("async_thread", "async_thread", "async_no_thread"), + ("async_thread", "async_thread", "async_thread"), + ("async_thread", "async_thread", "thread"), + ("async_thread", "thread", "async_no_thread"), + ("async_thread", "thread", "async_thread"), + ("async_thread", "thread", "thread"), + ("thread", "async_no_thread", "async_no_thread"), + ("thread", "async_no_thread", "async_thread"), + ("thread", "async_no_thread", "thread"), + ("thread", "async_thread", "async_no_thread"), + ("thread", "async_thread", "async_thread"), + ("thread", "async_thread", "thread"), + ("thread", "thread", "async_no_thread"), + ("thread", "thread", "async_thread"), + ("thread", "thread", "thread"), + ( # Plus a longer chain to stress test this mechanism. + "async_no_thread", + "async_no_thread", + "thread", + "thread", + "async_thread", + "async_thread", + "async_no_thread", + "async_thread", + "async_no_thread", + "async_thread", + "thread", + "async_thread", + "async_thread", + "async_no_thread", + "async_no_thread", + "thread", + "thread", + "async_no_thread", + "async_no_thread", + "thread", + "async_no_thread", + "thread", + "thread", + ), + ], +) +async def test_calling_workers_from_within_workers(call_stack: Tuple[str]): + """Regression test for https://github.com/Textualize/textual/issues/3472. + + This makes sure we can nest worker calls without a problem. + """ + app = NestedWorkersApp(list(call_stack)) + async with app.run_test(): + app.call_from_stack() + # We need multiple awaits because we're creating a chain of workers that may + # have multiple async workers, each of which may need the await to have enough + # time to call the next one in the chain. + for _ in range(len(call_stack)): + await app.workers.wait_for_complete() + assert app.call_stack == []
NameTypeDescriptionParameter DefaultDescription
{{ parameter.name }} + {{ parameter.name }} +
{% if parameter.annotation %} {% with expression = parameter.annotation %} {% include "expression.html" with context %} {% endwith %} {% endif %}
{{ parameter.description|convert_markdown(heading_level, html_id) }} {% if parameter.default %} - {% with expression = parameter.default %} - {% include "expression.html" with context %} - {% endwith %} + {% with expression = parameter.default %} + {% include "expression.html" with context %} + {% endwith %} {% else %} - required + required {% endif %} {{ parameter.description|convert_markdown(heading_level, html_id) }}