diff options
| author | YDBot <[email protected]> | 2025-10-20 12:59:43 +0000 |
|---|---|---|
| committer | YDBot <[email protected]> | 2025-10-20 12:59:43 +0000 |
| commit | 7874efeb56a4beed3df6a5a5a413dc6583d23e7a (patch) | |
| tree | ddd3dc43ad00a7ea058cf35070b98e5d6475fdbb /contrib/python | |
| parent | b0dc87f6d3013af373dfd9a5c670acd18ea1dbe6 (diff) | |
| parent | 14e27f7771734f0b5d08cb303cc469bf64d6c772 (diff) | |
Merge pull request #26940 from ydb-platform/merge-rightlib-251016-0050
Diffstat (limited to 'contrib/python')
65 files changed, 2218 insertions, 1117 deletions
diff --git a/contrib/python/google-auth/py3/.dist-info/METADATA b/contrib/python/google-auth/py3/.dist-info/METADATA index da8612e97b4..3017ab4ef0d 100644 --- a/contrib/python/google-auth/py3/.dist-info/METADATA +++ b/contrib/python/google-auth/py3/.dist-info/METADATA @@ -1,6 +1,6 @@ Metadata-Version: 2.4 Name: google-auth -Version: 2.41.0 +Version: 2.41.1 Summary: Google Authentication Library Home-page: https://github.com/googleapis/google-auth-library-python Author: Google Cloud Platform diff --git a/contrib/python/google-auth/py3/google/auth/_default.py b/contrib/python/google-auth/py3/google/auth/_default.py index 2df2b4e02b6..a96f7108be2 100644 --- a/contrib/python/google-auth/py3/google/auth/_default.py +++ b/contrib/python/google-auth/py3/google/auth/_default.py @@ -305,15 +305,17 @@ def _get_gcloud_sdk_credentials(quota_project_id=None): _LOGGER.debug("Cloud SDK credentials not found on disk; not using them") return None, None - credentials, project_id = load_credentials_from_file( - credentials_filename, quota_project_id=quota_project_id - ) - credentials._cred_file_path = credentials_filename + with warnings.catch_warnings(): + warnings.simplefilter("ignore", DeprecationWarning) + credentials, project_id = load_credentials_from_file( + credentials_filename, quota_project_id=quota_project_id + ) + credentials._cred_file_path = credentials_filename - if not project_id: - project_id = _cloud_sdk.get_project_id() + if not project_id: + project_id = _cloud_sdk.get_project_id() - return credentials, project_id + return credentials, project_id def _get_explicit_environ_credentials(quota_project_id=None): @@ -339,12 +341,15 @@ def _get_explicit_environ_credentials(quota_project_id=None): return _get_gcloud_sdk_credentials(quota_project_id=quota_project_id) if explicit_file is not None: - credentials, project_id = load_credentials_from_file( - os.environ[environment_vars.CREDENTIALS], quota_project_id=quota_project_id - ) - credentials._cred_file_path = f"{explicit_file} file via the GOOGLE_APPLICATION_CREDENTIALS environment variable" + with warnings.catch_warnings(): + warnings.simplefilter("ignore", DeprecationWarning) + credentials, project_id = load_credentials_from_file( + os.environ[environment_vars.CREDENTIALS], + quota_project_id=quota_project_id, + ) + credentials._cred_file_path = f"{explicit_file} file via the GOOGLE_APPLICATION_CREDENTIALS environment variable" - return credentials, project_id + return credentials, project_id else: return None, None diff --git a/contrib/python/google-auth/py3/google/auth/_default_async.py b/contrib/python/google-auth/py3/google/auth/_default_async.py index 2e53e208875..44bc6719f97 100644 --- a/contrib/python/google-auth/py3/google/auth/_default_async.py +++ b/contrib/python/google-auth/py3/google/auth/_default_async.py @@ -20,6 +20,7 @@ Implements application default credentials and project ID detection. import io import json import os +import warnings from google.auth import _default from google.auth import environment_vars @@ -116,14 +117,16 @@ def _get_gcloud_sdk_credentials(quota_project_id=None): if not os.path.isfile(credentials_filename): return None, None - credentials, project_id = load_credentials_from_file( - credentials_filename, quota_project_id=quota_project_id - ) + with warnings.catch_warnings(): + warnings.simplefilter("ignore", DeprecationWarning) + credentials, project_id = load_credentials_from_file( + credentials_filename, quota_project_id=quota_project_id + ) - if not project_id: - project_id = _cloud_sdk.get_project_id() + if not project_id: + project_id = _cloud_sdk.get_project_id() - return credentials, project_id + return credentials, project_id def _get_explicit_environ_credentials(quota_project_id=None): @@ -141,11 +144,14 @@ def _get_explicit_environ_credentials(quota_project_id=None): return _get_gcloud_sdk_credentials(quota_project_id=quota_project_id) if explicit_file is not None: - credentials, project_id = load_credentials_from_file( - os.environ[environment_vars.CREDENTIALS], quota_project_id=quota_project_id - ) + with warnings.catch_warnings(): + warnings.simplefilter("ignore", DeprecationWarning) + credentials, project_id = load_credentials_from_file( + os.environ[environment_vars.CREDENTIALS], + quota_project_id=quota_project_id, + ) - return credentials, project_id + return credentials, project_id else: return None, None diff --git a/contrib/python/google-auth/py3/google/auth/version.py b/contrib/python/google-auth/py3/google/auth/version.py index f309c8d48f3..6f67e6b34ee 100644 --- a/contrib/python/google-auth/py3/google/auth/version.py +++ b/contrib/python/google-auth/py3/google/auth/version.py @@ -12,4 +12,4 @@ # See the License for the specific language governing permissions and # limitations under the License. -__version__ = "2.41.0" +__version__ = "2.41.1" diff --git a/contrib/python/google-auth/py3/ya.make b/contrib/python/google-auth/py3/ya.make index f4ad9bdb5f6..3394fdb9d5b 100644 --- a/contrib/python/google-auth/py3/ya.make +++ b/contrib/python/google-auth/py3/ya.make @@ -2,7 +2,7 @@ PY3_LIBRARY() -VERSION(2.41.0) +VERSION(2.41.1) LICENSE(Apache-2.0) diff --git a/contrib/python/textual/.dist-info/METADATA b/contrib/python/textual/.dist-info/METADATA index 850f9446219..92fb26a5660 100644 --- a/contrib/python/textual/.dist-info/METADATA +++ b/contrib/python/textual/.dist-info/METADATA @@ -1,13 +1,13 @@ Metadata-Version: 2.1 Name: textual -Version: 0.86.3 +Version: 0.89.1 Summary: Modern Text User Interface framework Home-page: https://github.com/Textualize/textual License: MIT Author: Will McGugan Author-email: [email protected] Requires-Python: >=3.8.1,<4.0.0 -Classifier: Development Status :: 4 - Beta +Classifier: Development Status :: 5 - Production/Stable Classifier: Environment :: Console Classifier: Intended Audience :: Developers Classifier: License :: OSI Approved :: MIT License @@ -20,14 +20,29 @@ Classifier: Programming Language :: Python :: 3.9 Classifier: Programming Language :: Python :: 3.10 Classifier: Programming Language :: Python :: 3.11 Classifier: Programming Language :: Python :: 3.12 +Classifier: Programming Language :: Python :: 3.13 Classifier: Programming Language :: Python :: 3.8 Classifier: Typing :: Typed Provides-Extra: syntax Requires-Dist: markdown-it-py[linkify,plugins] (>=2.1.0) Requires-Dist: platformdirs (>=3.6.0,<5) Requires-Dist: rich (>=13.3.3) -Requires-Dist: tree-sitter (>=0.20.1,<0.21.0) ; extra == "syntax" -Requires-Dist: tree-sitter-languages (==1.10.2) ; extra == "syntax" +Requires-Dist: tree-sitter (>=0.23.0) ; (python_version >= "3.9") and (extra == "syntax") +Requires-Dist: tree-sitter-bash (>=0.23.0) ; (python_version >= "3.9") and (extra == "syntax") +Requires-Dist: tree-sitter-css (>=0.23.0) ; (python_version >= "3.9") and (extra == "syntax") +Requires-Dist: tree-sitter-go (>=0.23.0) ; (python_version >= "3.9") and (extra == "syntax") +Requires-Dist: tree-sitter-html (>=0.23.0) ; (python_version >= "3.9") and (extra == "syntax") +Requires-Dist: tree-sitter-java (>=0.23.0) ; (python_version >= "3.9") and (extra == "syntax") +Requires-Dist: tree-sitter-javascript (>=0.23.0) ; (python_version >= "3.9") and (extra == "syntax") +Requires-Dist: tree-sitter-json (>=0.24.0) ; (python_version >= "3.9") and (extra == "syntax") +Requires-Dist: tree-sitter-markdown (>=0.3.0) ; (python_version >= "3.9") and (extra == "syntax") +Requires-Dist: tree-sitter-python (>=0.23.0) ; (python_version >= "3.9") and (extra == "syntax") +Requires-Dist: tree-sitter-regex (>=0.24.0) ; (python_version >= "3.9") and (extra == "syntax") +Requires-Dist: tree-sitter-rust (>=0.23.0) ; (python_version >= "3.9") and (extra == "syntax") +Requires-Dist: tree-sitter-sql (>=0.3.0) ; (python_version >= "3.9") and (extra == "syntax") +Requires-Dist: tree-sitter-toml (>=0.6.0) ; (python_version >= "3.9") and (extra == "syntax") +Requires-Dist: tree-sitter-xml (>=0.7.0) ; (python_version >= "3.9") and (extra == "syntax") +Requires-Dist: tree-sitter-yaml (>=0.6.0) ; (python_version >= "3.9") and (extra == "syntax") Requires-Dist: typing-extensions (>=4.4.0,<5.0.0) Project-URL: Bug Tracker, https://github.com/Textualize/textual/issues Project-URL: Documentation, https://textual.textualize.io/ @@ -36,173 +51,139 @@ Description-Content-Type: text/markdown +[](https://discord.gg/Enf6Z3qhVr) +[](https://pypi.org/project/textual/) +[](https://badge.fury.io/py/textual) + - -[](https://discord.gg/Enf6Z3qhVr) + # Textual -Textual is a *Rapid Application Development* framework for Python. +Build cross-platform user interfaces with a simple Python API. -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)! +Run your apps in the terminal *or* a web browser. -<details> - <summary> π¬ Demonstration </summary> - <hr> +## Widgets -A quick run through of some Textual features. +Textual's library of [widgets](https://textual.textualize.io/widget_gallery/) covers everything from buttons, tree controls, data tables, inputs, text areas, and moreβ¦ +Combined with a flexible [layout](https://textual.textualize.io/how-to/design-a-layout/) system, you can realize any User Interface you need. +Predefined themes ensure your apps will look good out of the box. -https://user-images.githubusercontent.com/554369/197355913-65d3c125-493d-4c05-a590-5311f16c40ff.mov +<table> +<tr> + <td> + +  + + </td> - </details> + <td> + + + + </td> + +</tr> -## About +<tr> -Textual adds interactivity to [Rich](https://github.com/Textualize/rich) with an API inspired by modern web development. + <td> + +  + + </td> -On modern terminal software (installed by default on most systems), Textual apps can use **16.7 million** colors with mouse support and smooth flicker-free animation. A powerful layout engine and re-usable components makes it possible to build apps that rival the desktop and web experience. + <td> + + + + </td> + +</tr> +<tr> -## Compatibility +<td> -Textual runs on Linux, macOS, and Windows. Textual requires Python 3.8 or above. + -## Installing +</td> -Install Textual via pip: +<td> -``` -pip install textual -``` + + +</td> -If you plan on developing Textual apps, you should also install the development tools with the following command: + +</tr> -``` -pip install textual-dev -``` +</table> -See the [docs](https://textual.textualize.io/getting_started/) if you need help getting started. -## Demo -Run the following command to see a little of what Textual can do: +## Installing + +Install Textual via pip: ``` -python -m textual +pip install textual textual-dev ``` - - -## Documentation - -Head over to the [Textual documentation](http://textual.textualize.io/) to start building! - -## Join us on Discord - -Join the Textual developers and community on our [Discord Server](https://discord.gg/Enf6Z3qhVr). - -## Examples - -The Textual repository comes with a number of examples you can experiment with or use as a template for your own projects. - - -<details> - <summary> π¬ Code browser </summary> - <hr> - - This is the [code_browser.py](https://github.com/Textualize/textual/blob/main/examples/code_browser.py) example which clocks in at 61 lines (*including* docstrings and blank lines). - -https://user-images.githubusercontent.com/554369/197188237-88d3f7e4-4e5f-40b5-b996-c47b19ee2f49.mov - - </details> - +See [getting started](https://textual.textualize.io/getting_started/) for details. -<details> - <summary> π· Calculator </summary> - <hr> -This is [calculator.py](https://github.com/Textualize/textual/blob/main/examples/calculator.py) which demonstrates Textual grid layouts. - - -</details> - - -<details> - <summary> π¬ Stopwatch </summary> - <hr> - - This is the Stopwatch example from the [tutorial](https://textual.textualize.io/tutorial/). - - - -https://user-images.githubusercontent.com/554369/197360718-0c834ef5-6285-4d37-85cf-23eed4aa56c5.mov - - - -</details> - - - -## Reference commands - -The `textual` command has a few sub-commands to preview Textual styles. +## Demo -<details> - <summary> π¬ Easing reference </summary> - <hr> -This is the *easing* reference which demonstrates the easing parameter on animation, with both movement and opacity. You can run it with the following command: +Run the following command to see a little of what Textual can do: -```bash -textual easing +``` +python -m textual ``` - -https://user-images.githubusercontent.com/554369/196157100-352852a6-2b09-4dc8-a888-55b53570aff9.mov - - - </details> - -<details> - <summary> π¬ Borders reference </summary> - <hr> - -This is the borders reference which demonstrates some of the borders styles in Textual. You can run it with the following command: +Or try the [textual demo](https://github.com/textualize/textual-demo) *without* installing (requires [uv](https://docs.astral.sh/uv/)): ```bash -textual borders +uvx --python 3.12 textual-demo ``` -https://user-images.githubusercontent.com/554369/196158235-4b45fb78-053d-4fd5-b285-e09b4f1c67a8.mov +## Textual β€οΈ Web +<img align="right" width="40%" alt="textual-serve" src="https://github.com/user-attachments/assets/a25820fb-87ae-433a-858b-ac3940169242"> -</details> +Textual apps are equally at home in the browser as they are the terminal. -<details> - <summary> π¬ Colors reference </summary> - <hr> +Any Textual app may be served with `textual serve` — so you can share your creations on the web. -This is a reference for Textual's color design system. +Here's how to serve the demo app: -```bash -textual colors +``` +textual serve "python -m textual" ``` +In addition to serving your apps locally, you can serve apps with [Textual-Web](https://github.com/Textualize/textual-web). +Textual Web's firewall-busting technology can serve an unlimited number of applications. -https://user-images.githubusercontent.com/554369/197357417-2d407aac-8969-44d3-8250-eea45df79d57.mov +Since Textual apps have low system requirements, you can install them anywhere Python also runs. Turning any device in to a connected device. +No desktop required! +## Documentation +Head over to the [Textual documentation](http://textual.textualize.io/) to start building. +## Join us on Discord -</details> +Join the Textual developers and community on our [Discord Server](https://discord.gg/Enf6Z3qhVr). diff --git a/contrib/python/textual/README.md b/contrib/python/textual/README.md index e000b74c6c1..ea086dc588f 100644 --- a/contrib/python/textual/README.md +++ b/contrib/python/textual/README.md @@ -1,171 +1,137 @@ +[](https://discord.gg/Enf6Z3qhVr) +[](https://pypi.org/project/textual/) +[](https://badge.fury.io/py/textual) + - -[](https://discord.gg/Enf6Z3qhVr) + # Textual -Textual is a *Rapid Application Development* framework for Python. +Build cross-platform user interfaces with a simple Python API. -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)! +Run your apps in the terminal *or* a web browser. -<details> - <summary> π¬ Demonstration </summary> - <hr> +## Widgets -A quick run through of some Textual features. +Textual's library of [widgets](https://textual.textualize.io/widget_gallery/) covers everything from buttons, tree controls, data tables, inputs, text areas, and moreβ¦ +Combined with a flexible [layout](https://textual.textualize.io/how-to/design-a-layout/) system, you can realize any User Interface you need. +Predefined themes ensure your apps will look good out of the box. -https://user-images.githubusercontent.com/554369/197355913-65d3c125-493d-4c05-a590-5311f16c40ff.mov +<table> +<tr> + <td> + +  + + </td> - </details> + <td> + + + + </td> + +</tr> -## About +<tr> -Textual adds interactivity to [Rich](https://github.com/Textualize/rich) with an API inspired by modern web development. + <td> + +  + + </td> -On modern terminal software (installed by default on most systems), Textual apps can use **16.7 million** colors with mouse support and smooth flicker-free animation. A powerful layout engine and re-usable components makes it possible to build apps that rival the desktop and web experience. + <td> + + + + </td> + +</tr> +<tr> -## Compatibility +<td> -Textual runs on Linux, macOS, and Windows. Textual requires Python 3.8 or above. + -## Installing +</td> -Install Textual via pip: +<td> -``` -pip install textual -``` + + +</td> -If you plan on developing Textual apps, you should also install the development tools with the following command: + +</tr> -``` -pip install textual-dev -``` +</table> -See the [docs](https://textual.textualize.io/getting_started/) if you need help getting started. -## Demo -Run the following command to see a little of what Textual can do: +## Installing + +Install Textual via pip: ``` -python -m textual +pip install textual textual-dev ``` - - -## Documentation - -Head over to the [Textual documentation](http://textual.textualize.io/) to start building! - -## Join us on Discord - -Join the Textual developers and community on our [Discord Server](https://discord.gg/Enf6Z3qhVr). - -## Examples - -The Textual repository comes with a number of examples you can experiment with or use as a template for your own projects. - - -<details> - <summary> π¬ Code browser </summary> - <hr> - - This is the [code_browser.py](https://github.com/Textualize/textual/blob/main/examples/code_browser.py) example which clocks in at 61 lines (*including* docstrings and blank lines). - -https://user-images.githubusercontent.com/554369/197188237-88d3f7e4-4e5f-40b5-b996-c47b19ee2f49.mov - - </details> - +See [getting started](https://textual.textualize.io/getting_started/) for details. -<details> - <summary> π· Calculator </summary> - <hr> -This is [calculator.py](https://github.com/Textualize/textual/blob/main/examples/calculator.py) which demonstrates Textual grid layouts. - - -</details> - - -<details> - <summary> π¬ Stopwatch </summary> - <hr> - - This is the Stopwatch example from the [tutorial](https://textual.textualize.io/tutorial/). - - - -https://user-images.githubusercontent.com/554369/197360718-0c834ef5-6285-4d37-85cf-23eed4aa56c5.mov - - - -</details> - - - -## Reference commands - -The `textual` command has a few sub-commands to preview Textual styles. +## Demo -<details> - <summary> π¬ Easing reference </summary> - <hr> -This is the *easing* reference which demonstrates the easing parameter on animation, with both movement and opacity. You can run it with the following command: +Run the following command to see a little of what Textual can do: -```bash -textual easing +``` +python -m textual ``` - -https://user-images.githubusercontent.com/554369/196157100-352852a6-2b09-4dc8-a888-55b53570aff9.mov - - - </details> - -<details> - <summary> π¬ Borders reference </summary> - <hr> - -This is the borders reference which demonstrates some of the borders styles in Textual. You can run it with the following command: +Or try the [textual demo](https://github.com/textualize/textual-demo) *without* installing (requires [uv](https://docs.astral.sh/uv/)): ```bash -textual borders +uvx --python 3.12 textual-demo ``` -https://user-images.githubusercontent.com/554369/196158235-4b45fb78-053d-4fd5-b285-e09b4f1c67a8.mov +## Textual β€οΈ Web +<img align="right" width="40%" alt="textual-serve" src="https://github.com/user-attachments/assets/a25820fb-87ae-433a-858b-ac3940169242"> -</details> +Textual apps are equally at home in the browser as they are the terminal. -<details> - <summary> π¬ Colors reference </summary> - <hr> +Any Textual app may be served with `textual serve` — so you can share your creations on the web. -This is a reference for Textual's color design system. +Here's how to serve the demo app: -```bash -textual colors +``` +textual serve "python -m textual" ``` +In addition to serving your apps locally, you can serve apps with [Textual-Web](https://github.com/Textualize/textual-web). +Textual Web's firewall-busting technology can serve an unlimited number of applications. -https://user-images.githubusercontent.com/554369/197357417-2d407aac-8969-44d3-8250-eea45df79d57.mov +Since Textual apps have low system requirements, you can install them anywhere Python also runs. Turning any device in to a connected device. +No desktop required! +## Documentation +Head over to the [Textual documentation](http://textual.textualize.io/) to start building. +## Join us on Discord -</details> +Join the Textual developers and community on our [Discord Server](https://discord.gg/Enf6Z3qhVr). diff --git a/contrib/python/textual/textual/_arrange.py b/contrib/python/textual/textual/_arrange.py index 8c2469670f1..f6dcbf8baf4 100644 --- a/contrib/python/textual/textual/_arrange.py +++ b/contrib/python/textual/textual/_arrange.py @@ -50,11 +50,12 @@ def arrange( get_dock = attrgetter("styles.is_docked") get_split = attrgetter("styles.is_split") + get_display = attrgetter("styles.display") styles = widget.styles # Widgets which will be displayed - display_widgets = [child for child in children if child.styles.display != "none"] + display_widgets = [child for child in children if get_display(child) != "none"] # Widgets organized into layers layers = _build_layers(display_widgets) @@ -91,9 +92,7 @@ def arrange( if layout_widgets: # Arrange layout widgets (i.e. not docked) layout_placements = widget.layout.arrange( - widget, - layout_widgets, - dock_region.size, + widget, layout_widgets, dock_region.size ) scroll_spacing = scroll_spacing.grow_maximum(dock_spacing) placement_offset = dock_region.offset @@ -110,6 +109,8 @@ def arrange( layout_placements, placement_offset ) + WidgetPlacement.apply_absolute(layout_placements) + placements.extend(layout_placements) return DockArrangeResult(placements, set(display_widgets), scroll_spacing) @@ -134,7 +135,6 @@ def _arrange_dock_widgets( size = region.size width, height = size null_spacing = NULL_SPACING - null_offset = NULL_OFFSET top = right = bottom = left = 0 @@ -167,20 +167,28 @@ def _arrange_dock_widgets( # Should not occur, mainly to keep Mypy happy raise AssertionError("invalid value for dock edge") # pragma: no-cover - align_offset = dock_widget.styles._align_size( - (widget_width, widget_height), size + dock_region = dock_region.shrink(margin) + styles = dock_widget.styles + offset = ( + styles.offset.resolve( + size, + viewport, + ) + if styles.has_rule("offset") + else NULL_OFFSET ) - dock_region = dock_region.shrink(margin).translate(align_offset) append_placement( _WidgetPlacement( dock_region.translate(region_offset), - null_offset, + offset, null_spacing, dock_widget, top_z, True, + False, ) ) + dock_spacing = Spacing(top, right, bottom, left) return (placements, dock_spacing) @@ -230,7 +238,7 @@ def _arrange_split_widgets( append_placement( _WidgetPlacement( - split_region, null_offset, null_spacing, split_widget, 1, True + split_region, null_offset, null_spacing, split_widget, 1, True, False ) ) diff --git a/contrib/python/textual/textual/_border.py b/contrib/python/textual/textual/_border.py index e551ec7a610..7e35d7fc54e 100644 --- a/contrib/python/textual/textual/_border.py +++ b/contrib/python/textual/textual/_border.py @@ -107,6 +107,11 @@ BORDER_CHARS: dict[ ("β", " ", "β"), ("β", "β", "β"), ), + "tab": ( + ("β", "β", "β"), + ("β", " ", "β"), + ("β", "β", "β"), + ), "wide": ( ("β", "β", "β"), ("β", " ", "β"), @@ -205,6 +210,11 @@ BORDER_LOCATIONS: dict[ (2, 0, 1), (2, 0, 1), ), + "tab": ( + (1, 1, 1), + (0, 1, 3), + (1, 1, 1), + ), "wide": ( (1, 1, 1), (0, 1, 3), @@ -215,7 +225,10 @@ BORDER_LOCATIONS: dict[ # Some borders (such as panel) require that the title (and subtitle) be draw in reverse. # This is a mapping of the border type on to a tuple for the top and bottom borders, to indicate # reverse colors is required. -BORDER_TITLE_FLIP: dict[str, tuple[bool, bool]] = {"panel": (True, False)} +BORDER_TITLE_FLIP: dict[str, tuple[bool, bool]] = { + "panel": (True, False), + "tab": (True, True), +} # In a similar fashion, we extract the border _label_ locations for easier access when # rendering a border label. @@ -343,6 +356,7 @@ def render_border_label( cells_reserved = 2 * corners_needed text_label, label_style = label + if not text_label.cell_len or width <= cells_reserved: return diff --git a/contrib/python/textual/textual/_compositor.py b/contrib/python/textual/textual/_compositor.py index 7aac04393c4..f35e22f1c4c 100644 --- a/contrib/python/textual/textual/_compositor.py +++ b/contrib/python/textual/textual/_compositor.py @@ -598,6 +598,7 @@ class Compositor: if widget.is_container: # Arrange the layout arrange_result = widget._arrange(child_region.size) + arranged_widgets = arrange_result.widgets widgets.update(arranged_widgets) @@ -616,6 +617,11 @@ class Compositor: placement_offset = container_region.offset placement_scroll_offset = placement_offset - widget.scroll_offset + placements = [ + placement.process_offset(size.region, placement_scroll_offset) + for placement in placements + ] + layers_to_index = { layer_name: index for index, layer_name in enumerate(widget.layers) @@ -643,6 +649,7 @@ class Compositor: z, fixed, overlay, + absolute, ) in reversed(placements): layer_index = get_layer_index(sub_widget.layer, 0) # Combine regions with children to calculate the "virtual size" @@ -657,17 +664,6 @@ class Compositor: widget_order = order + ((layer_index, z, layer_order),) - if overlay: - styles = sub_widget.styles - has_rule = styles.has_rule - if has_rule("constrain_x") or has_rule("constrain_y"): - widget_region = widget_region.constrain( - styles.constrain_x, - styles.constrain_y, - styles.margin, - no_clip, - ) - if widget._cover_widget is None: add_widget( sub_widget, @@ -712,22 +708,6 @@ class Compositor: elif visible: # Add the widget to the map - - if widget.absolute_offset is not None: - margin = styles.margin - region = region.at_offset(widget.absolute_offset + margin.top_left) - region = region.translate( - styles.offset.resolve(region.grow(margin).size, size) - ) - has_rule = styles.has_rule - if has_rule("constrain_x") or has_rule("constrain_y"): - region = region.constrain( - styles.constrain_x, - styles.constrain_y, - styles.margin, - size.region, - ) - map[widget._render_widget] = _MapGeometry( region, order, diff --git a/contrib/python/textual/textual/_text_area_theme.py b/contrib/python/textual/textual/_text_area_theme.py index e315716f30a..0585bf81b27 100644 --- a/contrib/python/textual/textual/_text_area_theme.py +++ b/contrib/python/textual/textual/_text_area_theme.py @@ -148,7 +148,7 @@ class TextAreaTheme: self.selection_style = selection_style else: selection_background_color = background_color.blend( - app_theme.primary, factor=0.75 + app_theme.primary, factor=0.5 ) self.selection_style = Style.from_color( bgcolor=selection_background_color.rich_color @@ -204,8 +204,9 @@ _MONOKAI = TextAreaTheme( "string": Style(color="#E6DB74"), "string.documentation": Style(color="#E6DB74"), "comment": Style(color="#75715E"), + "heading.marker": Style(color="#90908a"), "keyword": Style(color="#F92672"), - "operator": Style(color="#F92672"), + "operator": Style(color="#f8f8f2"), "repeat": Style(color="#F92672"), "exception": Style(color="#F92672"), "include": Style(color="#F92672"), @@ -216,7 +217,10 @@ _MONOKAI = TextAreaTheme( "number": Style(color="#AE81FF"), "float": Style(color="#AE81FF"), "class": Style(color="#A6E22E"), + "type": Style(color="#A6E22E"), "type.class": Style(color="#A6E22E"), + "type.builtin": Style(color="#F92672"), + "variable.builtin": Style(color="#f8f8f2"), "function": Style(color="#A6E22E"), "function.call": Style(color="#A6E22E"), "method": Style(color="#A6E22E"), @@ -232,12 +236,18 @@ _MONOKAI = TextAreaTheme( "json.label": Style(color="#F92672", bold=True), "toml.type": Style(color="#F92672"), "toml.datetime": Style(color="#AE81FF"), + "css.property": 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), + "link.label": Style(color="#F92672"), + "link.uri": Style(color="#66D9EF", underline=True), + "list.marker": Style(color="#90908a"), "inline_code": Style(color="#E6DB74"), + "punctuation.bracket": Style(color="#f8f8f2"), + "punctuation.delimiter": Style(color="#f8f8f2"), + "punctuation.special": Style(color="#f8f8f2"), }, ) @@ -254,8 +264,9 @@ _DRACULA = TextAreaTheme( "string": Style(color="#f1fa8c"), "string.documentation": Style(color="#f1fa8c"), "comment": Style(color="#6272a4"), + "heading.marker": Style(color="#6272a4"), "keyword": Style(color="#ff79c6"), - "operator": Style(color="#ff79c6"), + "operator": Style(color="#f8f8f2"), "repeat": Style(color="#ff79c6"), "exception": Style(color="#ff79c6"), "include": Style(color="#ff79c6"), @@ -266,12 +277,15 @@ _DRACULA = TextAreaTheme( "number": Style(color="#bd93f9"), "float": Style(color="#bd93f9"), "class": Style(color="#50fa7b"), + "type": Style(color="#ff79c6"), "type.class": Style(color="#50fa7b"), + "type.builtin": Style(color="#bd93f9"), + "variable.builtin": Style(color="#f8f8f2"), "function": Style(color="#50fa7b"), "function.call": Style(color="#50fa7b"), "method": Style(color="#50fa7b"), "method.call": Style(color="#50fa7b"), - "boolean": Style(color="#bd93f9"), + "boolean": Style(color="#50fa7b"), "constant.builtin": Style(color="#bd93f9"), "json.null": Style(color="#bd93f9"), "regex.punctuation.bracket": Style(color="#ff79c6"), @@ -282,12 +296,18 @@ _DRACULA = TextAreaTheme( "json.label": Style(color="#ff79c6", bold=True), "toml.type": Style(color="#ff79c6"), "toml.datetime": Style(color="#bd93f9"), + "css.property": 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), + "link.label": Style(color="#ff79c6"), + "link.uri": Style(color="#bd93f9", underline=True), + "list.marker": Style(color="#6272a4"), "inline_code": Style(color="#f1fa8c"), + "punctuation.bracket": Style(color="#f8f8f2"), + "punctuation.delimiter": Style(color="#f8f8f2"), + "punctuation.special": Style(color="#f8f8f2"), }, ) @@ -304,8 +324,9 @@ _DARK_VS = TextAreaTheme( "string": Style(color="#ce9178"), "string.documentation": Style(color="#ce9178"), "comment": Style(color="#6A9955"), - "keyword": Style(color="#569cd6"), - "operator": Style(color="#569cd6"), + "heading.marker": Style(color="#6E7681"), + "keyword": Style(color="#C586C0"), + "operator": Style(color="#CCCCCC"), "conditional": Style(color="#569cd6"), "keyword.function": Style(color="#569cd6"), "keyword.return": Style(color="#569cd6"), @@ -316,11 +337,14 @@ _DARK_VS = TextAreaTheme( "number": Style(color="#b5cea8"), "float": Style(color="#b5cea8"), "class": Style(color="#4EC9B0"), + "type": Style(color="#EFCB43"), "type.class": Style(color="#4EC9B0"), - "function": Style(color="#4EC9B0"), - "function.call": Style(color="#4EC9B0"), + "type.builtin": Style(color="#9CDCFE"), + "function": Style(color="#DCDCAA"), + "function.call": Style(color="#DCDCAA"), "method": Style(color="#4EC9B0"), "method.call": Style(color="#4EC9B0"), + "constructor": Style(color="#4EC9B0"), "boolean": Style(color="#7DAF9C"), "constant.builtin": Style(color="#7DAF9C"), "json.null": Style(color="#7DAF9C"), @@ -328,13 +352,20 @@ _DARK_VS = TextAreaTheme( "yaml.field": Style(color="#569cd6", bold=True), "json.label": Style(color="#569cd6", bold=True), "toml.type": Style(color="#569cd6"), + "toml.datetime": Style(color="#C586C0", italic=True), + "css.property": 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), + "link.uri": Style(color="#40A6FF", underline=True), + "link.label": Style(color="#569cd6"), + "list.marker": Style(color="#6E7681"), "inline_code": Style(color="#ce9178"), "info_string": Style(color="#ce9178", bold=True, italic=True), + "punctuation.bracket": Style(color="#CCCCCC"), + "punctuation.delimiter": Style(color="#CCCCCC"), + "punctuation.special": Style(color="#CCCCCC"), }, ) @@ -351,6 +382,10 @@ _GITHUB_LIGHT = TextAreaTheme( "string": Style(color="#093069"), "string.documentation": Style(color="#093069"), "comment": Style(color="#6a737d"), + "heading.marker": Style(color="#A4A4A4"), + "type": Style(color="#A4A4A4"), + "type.class": Style(color="#A4A4A4"), + "type.builtin": Style(color="#7DAF9C"), "keyword": Style(color="#d73a49"), "operator": Style(color="#0450AE"), "conditional": Style(color="#CF222E"), @@ -373,12 +408,18 @@ _GITHUB_LIGHT = TextAreaTheme( "yaml.field": Style(color="#6639BB"), "json.label": Style(color="#6639BB"), "toml.type": Style(color="#6639BB"), + "css.property": 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), + "link.uri": Style(color="#40A6FF", underline=True), + "link.label": Style(color="#6639BB"), + "list.marker": Style(color="#A4A4A4"), "inline_code": Style(color="#093069"), + "punctuation.bracket": Style(color="#24292e"), + "punctuation.delimiter": Style(color="#24292e"), + "punctuation.special": Style(color="#24292e"), }, ) diff --git a/contrib/python/textual/textual/_tree_sitter.py b/contrib/python/textual/textual/_tree_sitter.py index 01e300115cf..193bf16fd41 100644 --- a/contrib/python/textual/textual/_tree_sitter.py +++ b/contrib/python/textual/textual/_tree_sitter.py @@ -1,10 +1,49 @@ 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 + import tree_sitter_bash + import tree_sitter_css + import tree_sitter_go + import tree_sitter_html + import tree_sitter_java + import tree_sitter_javascript + import tree_sitter_json + import tree_sitter_markdown + import tree_sitter_python + import tree_sitter_regex + import tree_sitter_rust + import tree_sitter_sql + import tree_sitter_toml + import tree_sitter_xml + import tree_sitter_yaml + from tree_sitter import Language + + _tree_sitter = True + + _languages = { + "python": Language(tree_sitter_python.language()), + "json": Language(tree_sitter_json.language()), + "markdown": Language(tree_sitter_markdown.language()), + "yaml": Language(tree_sitter_yaml.language()), + "toml": Language(tree_sitter_toml.language()), + "rust": Language(tree_sitter_rust.language()), + "html": Language(tree_sitter_html.language()), + "css": Language(tree_sitter_css.language()), + "xml": Language(tree_sitter_xml.language_xml()), + "regex": Language(tree_sitter_regex.language()), + "sql": Language(tree_sitter_sql.language()), + "javascript": Language(tree_sitter_javascript.language()), + "java": Language(tree_sitter_java.language()), + "bash": Language(tree_sitter_bash.language()), + "go": Language(tree_sitter_go.language()), + } + + def get_language(language_name: str) -> Language | None: + return _languages.get(language_name) - TREE_SITTER = True except ImportError: - TREE_SITTER = False + _tree_sitter = False + _languages = {} + +TREE_SITTER = _tree_sitter +BUILTIN_LANGUAGES: dict[str, "Language"] = _languages diff --git a/contrib/python/textual/textual/app.py b/contrib/python/textual/textual/app.py index e94c9595b08..d54f5f49f74 100644 --- a/contrib/python/textual/textual/app.py +++ b/contrib/python/textual/textual/app.py @@ -123,6 +123,7 @@ from textual.screen import ( from textual.signal import Signal from textual.theme import BUILTIN_THEMES, Theme, ThemeProvider from textual.timer import Timer +from textual.visual import SupportsVisual, Visual from textual.widget import AwaitMount, Widget from textual.widgets._toast import ToastRack from textual.worker import NoActiveWorker, get_current_worker @@ -152,7 +153,7 @@ if constants.DEBUG: _ASYNCIO_GET_EVENT_LOOP_IS_DEPRECATED = sys.version_info >= (3, 10, 0) ComposeResult = Iterable[Widget] -RenderResult = "RenderableType | Visual | SupportsTextualize" +RenderResult: TypeAlias = "RenderableType | Visual | SupportsVisual" """Result of Widget.render()""" AutopilotCallbackType: TypeAlias = ( @@ -632,8 +633,6 @@ class App(Generic[ReturnType], DOMNode): self._logger = Logger(self._log) - self._refresh_required = False - self._css_has_errors = False self.theme_variables: dict[str, str] = {} @@ -3068,6 +3067,9 @@ class App(Generic[ReturnType], DOMNode): try: try: await self._dispatch_message(events.Compose()) + await self._dispatch_message( + events.Resize.from_dimensions(self.size, None) + ) default_screen = self.screen self.stylesheet.apply(self) await self._dispatch_message(events.Mount()) diff --git a/contrib/python/textual/textual/color.py b/contrib/python/textual/textual/color.py index c5f5abcf3f4..e0ddf3b944e 100644 --- a/contrib/python/textual/textual/color.py +++ b/contrib/python/textual/textual/color.py @@ -40,6 +40,7 @@ import rich.repr from rich.color import Color as RichColor from rich.color import ColorType from rich.color_triplet import ColorTriplet +from rich.terminal_theme import TerminalTheme from typing_extensions import Final from textual._color_constants import ANSI_COLORS, COLOR_NAME_TO_RGB @@ -176,18 +177,21 @@ class Color(NamedTuple): return cls(0, 0, 0, alpha_percentage / 100.0, auto=True) @classmethod - def from_rich_color(cls, rich_color: RichColor | None) -> Color: + def from_rich_color( + cls, rich_color: RichColor | None, theme: TerminalTheme | None = None + ) -> Color: """Create a new color from Rich's Color class. Args: rich_color: An instance of [Rich color][rich.color.Color]. + theme: Optional Rich [terminal theme][rich.terminal_theme.TerminalTheme]. Returns: A new Color instance. """ if rich_color is None: return TRANSPARENT - r, g, b = rich_color.get_truecolor() + r, g, b = rich_color.get_truecolor(theme) return cls(r, g, b) @classmethod diff --git a/contrib/python/textual/textual/command.py b/contrib/python/textual/textual/command.py index 68677ba0018..9e28eddb318 100644 --- a/contrib/python/textual/textual/command.py +++ b/contrib/python/textual/textual/command.py @@ -1120,7 +1120,7 @@ class CommandPalette(SystemModalScreen[None]): help_style = VisualStyle.from_styles( self.get_component_styles("command-palette--help-text") ) - yield Content.styled(hit.help, help_style) + yield Content.from_rich_text(hit.help).stylize_before(help_style) prompt = Content("\n").join(build_prompt()) diff --git a/contrib/python/textual/textual/content.py b/contrib/python/textual/textual/content.py index 6df51e99cee..7b402de45e7 100644 --- a/contrib/python/textual/textual/content.py +++ b/contrib/python/textual/textual/content.py @@ -19,9 +19,11 @@ from rich._wrap import divide_line from rich.cells import set_cell_size from rich.console import OverflowMethod from rich.segment import Segment, Segments +from rich.terminal_theme import TerminalTheme from rich.text import Text from textual._cells import cell_len +from textual._context import active_app from textual._loop import loop_last from textual.color import Color from textual.css.types import TextAlign @@ -180,15 +182,28 @@ class Content(Visual): New Content. """ if isinstance(text, str): - return cls(text, align=align, no_wrap=no_wrap, ellipsis=ellipsis) - spans = [ - Span( - start, - end, - style if isinstance(style, str) else Style.from_rich_style(style), - ) - for start, end, style in text._spans - ] + text = Text.from_markup(text) + if text._spans: + ansi_theme: TerminalTheme | None + try: + ansi_theme = active_app.get().ansi_theme + except LookupError: + ansi_theme = None + spans = [ + Span( + start, + end, + ( + style + if isinstance(style, str) + else Style.from_rich_style(style, ansi_theme) + ), + ) + for start, end, style in text._spans + ] + else: + spans = [] + return cls( text.plain, spans, @@ -681,9 +696,15 @@ class Content(Visual): return if parse_style is None: + app = active_app.get() + # TODO: Update when we add Content.from_markup def get_style(style: str, /) -> Style: - return TRANSPARENT_STYLE if isinstance(style, str) else style + return ( + Style.from_rich_style(app.console.get_style(style), app.ansi_theme) + if isinstance(style, str) + else style + ) else: get_style = parse_style diff --git a/contrib/python/textual/textual/css/_help_text.py b/contrib/python/textual/textual/css/_help_text.py index 1bed8bdce1e..2e925e0de68 100644 --- a/contrib/python/textual/textual/css/_help_text.py +++ b/contrib/python/textual/textual/css/_help_text.py @@ -14,6 +14,7 @@ from textual.css.constants import ( VALID_BORDER, VALID_KEYLINE, VALID_LAYOUT, + VALID_POSITION, VALID_STYLE_FLAGS, VALID_TEXT_ALIGN, ) @@ -305,6 +306,7 @@ def color_property_help_text( context: StylingContext, *, error: Exception | None = None, + value: str | None = None, ) -> HelpText: """Help text to show when the user supplies an invalid value for a color property. For example, an unparsable color string. @@ -318,7 +320,10 @@ def color_property_help_text( Renderable for displaying the help text for this property. """ property_name = _contextualize_property_name(property_name, context) - summary = f"Invalid value for the [i]{property_name}[/] property" + if value is None: + summary = f"Invalid value for the [i]{property_name}[/] property" + else: + summary = f"Invalid value ({value!r}) for the [i]{property_name}[/] property" suggested_color = ( error.suggested_color if error and isinstance(error, ColorParseError) else None ) @@ -766,6 +771,23 @@ def offset_single_axis_help_text(property_name: str) -> HelpText: ) +def position_help_text(property_name: str) -> HelpText: + """Help text to show when the user supplies the wrong value for position. + + Args: + property_name: The name of the property. + + Returns: + Renderable for displaying the help text for this property. + """ + return HelpText( + summary=f"Invalid value for [i]{property_name}[/]", + bullets=[ + Bullet(f"Valid values are {friendly_list(VALID_POSITION)}"), + ], + ) + + def style_flags_property_help_text( property_name: str, value: str, context: StylingContext ) -> HelpText: diff --git a/contrib/python/textual/textual/css/_style_properties.py b/contrib/python/textual/textual/css/_style_properties.py index d7f8f8a70bb..f4fc9199b21 100644 --- a/contrib/python/textual/textual/css/_style_properties.py +++ b/contrib/python/textual/textual/css/_style_properties.py @@ -745,10 +745,10 @@ class OffsetProperty: _rich_traceback_omit = True if offset is None: if obj.clear_rule(self.name): - obj.refresh(layout=True) + obj.refresh(layout=True, repaint=False) elif isinstance(offset, ScalarOffset): if obj.set_rule(self.name, offset): - obj.refresh(layout=True) + obj.refresh(layout=True, repaint=False) else: x, y = offset @@ -771,7 +771,7 @@ class OffsetProperty: _offset = ScalarOffset(scalar_x, scalar_y) if obj.set_rule(self.name, _offset): - obj.refresh(layout=True) + obj.refresh(layout=True, repaint=False) class StringEnumProperty(Generic[EnumType]): @@ -988,7 +988,7 @@ class ColorProperty: raise StyleValueError( f"Invalid color value '{token}'", help_text=color_property_help_text( - self.name, context="inline", error=error + self.name, context="inline", error=error, value=token ), ) parsed_color = parsed_color.with_alpha(alpha) diff --git a/contrib/python/textual/textual/css/_styles_builder.py b/contrib/python/textual/textual/css/_styles_builder.py index 11ff7e370ce..b5ccc4802d3 100644 --- a/contrib/python/textual/textual/css/_styles_builder.py +++ b/contrib/python/textual/textual/css/_styles_builder.py @@ -22,6 +22,7 @@ from textual.css._help_text import ( layout_property_help_text, offset_property_help_text, offset_single_axis_help_text, + position_help_text, property_invalid_value_help_text, scalar_help_text, scrollbar_size_property_help_text, @@ -47,6 +48,7 @@ from textual.css.constants import ( VALID_KEYLINE, VALID_OVERFLOW, VALID_OVERLAY, + VALID_POSITION, VALID_SCROLLBAR_GUTTER, VALID_STYLE_FLAGS, VALID_TEXT_ALIGN, @@ -553,7 +555,9 @@ class StylesBuilder: self.error( name, token, - color_property_help_text(name, context="css", error=error), + color_property_help_text( + name, context="css", error=error, value=token.value + ), ) elif token.name == "token": try: @@ -620,6 +624,17 @@ class StylesBuilder: x = self.styles.offset.x self.styles._rules["offset"] = ScalarOffset(x, y) + def process_position(self, name: str, tokens: list[Token]): + if not tokens: + return + if len(tokens) != 1: + self.error(name, tokens[0], offset_single_axis_help_text(name)) + else: + token = tokens[0] + if token.value not in VALID_POSITION: + self.error(name, tokens[0], position_help_text(name)) + self.styles._rules["position"] = token.value + def process_layout(self, name: str, tokens: list[Token]) -> None: from textual.layouts.factory import MissingLayout, get_layout @@ -668,10 +683,16 @@ class StylesBuilder: self.error( name, token, - color_property_help_text(name, context="css", error=error), + color_property_help_text( + name, context="css", error=error, value=token.value + ), ) else: - self.error(name, token, color_property_help_text(name, context="css")) + self.error( + name, + token, + color_property_help_text(name, context="css", value=token.value), + ) if color is not None or alpha is not None: if alpha is not None: @@ -1149,7 +1170,9 @@ class StylesBuilder: self.error( name, color_token, - color_property_help_text(name, context="css", error=error), + color_property_help_text( + name, context="css", error=error, value=color_token.value + ), ) else: self.error( diff --git a/contrib/python/textual/textual/css/constants.py b/contrib/python/textual/textual/css/constants.py index 82af4e49127..160ff033309 100644 --- a/contrib/python/textual/textual/css/constants.py +++ b/contrib/python/textual/textual/css/constants.py @@ -22,6 +22,7 @@ VALID_BORDER: Final = { "round", "solid", "tall", + "tab", "thick", "vkey", "wide", @@ -33,6 +34,7 @@ VALID_BOX_SIZING: Final = {"border-box", "content-box"} VALID_OVERFLOW: Final = {"scroll", "hidden", "auto"} VALID_ALIGN_HORIZONTAL: Final = {"left", "center", "right"} VALID_ALIGN_VERTICAL: Final = {"top", "middle", "bottom"} +VALID_POSITION: Final = {"relative", "absolute"} VALID_TEXT_ALIGN: Final = { "start", "end", diff --git a/contrib/python/textual/textual/css/styles.py b/contrib/python/textual/textual/css/styles.py index 244c2d26165..0a08b42dd3e 100644 --- a/contrib/python/textual/textual/css/styles.py +++ b/contrib/python/textual/textual/css/styles.py @@ -45,6 +45,7 @@ from textual.css.constants import ( VALID_DISPLAY, VALID_OVERFLOW, VALID_OVERLAY, + VALID_POSITION, VALID_SCROLLBAR_GUTTER, VALID_TEXT_ALIGN, VALID_VISIBILITY, @@ -99,6 +100,7 @@ class RulesMap(TypedDict, total=False): padding: Spacing margin: Spacing offset: ScalarOffset + position: str border_top: tuple[str, Color] border_right: tuple[str, Color] @@ -219,6 +221,7 @@ class StylesBase: "background", "background_tint", "opacity", + "position", "text_opacity", "tint", "scrollbar_color", @@ -235,9 +238,7 @@ class StylesBase: node: DOMNode | None = None - display = StringEnumProperty( - VALID_DISPLAY, "block", layout=True, refresh_parent=True, refresh_children=True - ) + display = StringEnumProperty(VALID_DISPLAY, "block", layout=True) """Set the display of the widget, defining how it's rendered. Valid values are "block" or "none". @@ -250,9 +251,7 @@ class StylesBase: StyleValueError: If an invalid display is specified. """ - visibility = StringEnumProperty( - VALID_VISIBILITY, "visible", layout=True, refresh_parent=True - ) + visibility = StringEnumProperty(VALID_VISIBILITY, "visible", layout=True) """Set the visibility of the widget. Valid values are "visible" or "hidden". @@ -278,6 +277,7 @@ class StylesBase: """ auto_color = BooleanProperty(default=False) + """Enable automatic picking of best contrasting color.""" color = ColorProperty(Color(255, 255, 255)) """Set the foreground (text) color of the widget. Supports `Color` objects but also strings e.g. "red" or "#ff0000". @@ -307,6 +307,9 @@ class StylesBase: """Set the margin (spacing outside the border) of the widget.""" offset = OffsetProperty() """Set the offset of the widget relative to where it would have been otherwise.""" + position = StringEnumProperty(VALID_POSITION, "relative") + """If `relative` offset is applied to widgets current position, if `absolute` it is applied to (0, 0).""" + border = BorderProperty(layout=True) """Set the border of the widget e.g. ("rounded", "green") or "none".""" @@ -320,7 +323,9 @@ class StylesBase: """Set the left border of the widget e.g. ("rounded", "green") or "none".""" border_title_align = StringEnumProperty(VALID_ALIGN_HORIZONTAL, "left") + """The alignment of the border title text.""" border_subtitle_align = StringEnumProperty(VALID_ALIGN_HORIZONTAL, "right") + """The alignment of the border subtitle text.""" outline = BorderProperty(layout=False) """Set the outline of the widget e.g. ("rounded", "green") or "none". @@ -336,8 +341,10 @@ class StylesBase: """Set the left outline of the widget e.g. ("rounded", "green") or "none".""" keyline = KeylineProperty() + """Keyline parameters.""" box_sizing = StringEnumProperty(VALID_BOX_SIZING, "border-box", layout=True) + """Box sizing method ("border-box" or "conetnt-box")""" width = ScalarProperty(percent_unit=Unit.WIDTH) """Set the width of the widget.""" height = ScalarProperty(percent_unit=Unit.HEIGHT) @@ -626,7 +633,12 @@ class StylesBase: raise NotImplementedError() def refresh( - self, *, layout: bool = False, children: bool = False, parent: bool = False + self, + *, + layout: bool = False, + children: bool = False, + parent: bool = False, + repaint: bool = True, ) -> None: """Mark the styles as requiring a refresh. @@ -634,6 +646,7 @@ class StylesBase: layout: Also require a layout. children: Also refresh children. parent: Also refresh the parent. + repaint: Repaint the widgets. """ def reset(self) -> None: @@ -722,6 +735,7 @@ class StylesBase: offset_x = (parent_width - width) // 2 else: offset_x = parent_width - width + return offset_x def _align_height(self, height: int, parent_height: int) -> int: @@ -844,17 +858,22 @@ class Styles(StylesBase): return changed def refresh( - self, *, layout: bool = False, children: bool = False, parent: bool = False + self, + *, + layout: bool = False, + children: bool = False, + parent: bool = False, + repaint=True, ) -> None: 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._parent.refresh(repaint=repaint) node.refresh(layout=layout) if children: for child in node.walk_children(with_self=False, reverse=True): - child.refresh(layout=layout) + child.refresh(layout=layout, repaint=repaint) def reset(self) -> None: """Reset the rules to initial state.""" @@ -1003,6 +1022,8 @@ class Styles(StylesBase): if "offset" in rules: x, y = self.offset append_declaration("offset", f"{x} {y}") + if "position" in rules: + append_declaration("position", self.position) if "dock" in rules: append_declaration("dock", rules["dock"]) if "split" in rules: @@ -1326,9 +1347,16 @@ class RenderStyles(StylesBase): yield rule_name, getattr(self, rule_name) def refresh( - self, *, layout: bool = False, children: bool = False, parent: bool = False + self, + *, + layout: bool = False, + children: bool = False, + parent: bool = False, + repaint: bool = True, ) -> None: - self._inline_styles.refresh(layout=layout, children=children, parent=parent) + self._inline_styles.refresh( + layout=layout, children=children, parent=parent, repaint=repaint + ) def merge(self, other: StylesBase) -> None: """Merge values from another Styles. @@ -1353,6 +1381,19 @@ class RenderStyles(StylesBase): rule_name ) + def has_any_rules(self, *rule_names: str) -> bool: + """Check if any of the supplied rules have been set. + + Args: + rule_names: Number of rules. + + Returns: + `True` if any of the supplied rules have been set, `False` if none have. + """ + inline_has_rule = self._inline_styles.has_rule + base_has_rule = self._base_styles.has_rule + return any(inline_has_rule(name) or base_has_rule(name) for name in rule_names) + def set_rule(self, rule_name: str, value: object | None) -> bool: self._updates += 1 return self._inline_styles.set_rule(rule_name, value) diff --git a/contrib/python/textual/textual/css/stylesheet.py b/contrib/python/textual/textual/css/stylesheet.py index fe3e3fac6ea..82f4ffae778 100644 --- a/contrib/python/textual/textual/css/stylesheet.py +++ b/contrib/python/textual/textual/css/stylesheet.py @@ -276,7 +276,7 @@ class Stylesheet: """ filename = os.path.expanduser(filename) try: - with open(filename, "rt") as css_file: + with open(filename, "rt", encoding="utf-8") as css_file: css = css_file.read() path = os.path.abspath(filename) except Exception: diff --git a/contrib/python/textual/textual/css/types.py b/contrib/python/textual/textual/css/types.py index f02ff80ed05..a8f6b1f7998 100644 --- a/contrib/python/textual/textual/css/types.py +++ b/contrib/python/textual/textual/css/types.py @@ -24,6 +24,7 @@ EdgeType = Literal[ "hkey", "vkey", "tall", + "tab", "panel", "wide", ] @@ -38,6 +39,7 @@ EdgeStyle = Tuple[EdgeType, Color] TextAlign = Literal["left", "start", "center", "right", "end", "justify"] Constrain = Literal["none", "inflect", "inside"] Overlay = Literal["none", "screen"] +Position = Literal["relative", "absolute"] Specificity3 = Tuple[int, int, int] Specificity6 = Tuple[int, int, int, int, int, int] diff --git a/contrib/python/textual/textual/demo/demo_app.py b/contrib/python/textual/textual/demo/demo_app.py index 66b4d5837eb..9fc391f96ef 100644 --- a/contrib/python/textual/textual/demo/demo_app.py +++ b/contrib/python/textual/textual/demo/demo_app.py @@ -2,6 +2,7 @@ from __future__ import annotations from textual.app import App from textual.binding import Binding +from textual.demo.game import GameScreen from textual.demo.home import HomeScreen from textual.demo.projects import ProjectsScreen from textual.demo.widgets import WidgetsScreen @@ -12,8 +13,8 @@ class DemoApp(App): CSS = """ .column { - align: center top; - &>*{ max-width: 100; } + align: center top; + &>*{ max-width: 100; } } Screen .-maximized { margin: 1 2; @@ -26,6 +27,7 @@ class DemoApp(App): """ MODES = { + "game": GameScreen, "home": HomeScreen, "projects": ProjectsScreen, "widgets": WidgetsScreen, @@ -35,19 +37,25 @@ class DemoApp(App): Binding( "h", "app.switch_mode('home')", - "home", + "Home", tooltip="Show the home screen", ), Binding( + "g", + "app.switch_mode('game')", + "Game", + tooltip="Unwind with a Textual game", + ), + Binding( "p", "app.switch_mode('projects')", - "projects", + "Projects", tooltip="A selection of Textual projects", ), Binding( "w", "app.switch_mode('widgets')", - "widgets", + "Widgets", tooltip="Test the builtin widgets", ), Binding( @@ -60,7 +68,7 @@ class DemoApp(App): "ctrl+a", "app.maximize", "Maximize", - tooltip="Maximized the focused widget (if possible)", + tooltip="Maximize the focused widget (if possible)", ), ] @@ -85,3 +93,13 @@ class DemoApp(App): title="Maximize", severity="warning", ) + + def check_action(self, action: str, parameters: tuple[object, ...]) -> bool | None: + """Disable switching to a mode we are already on.""" + if ( + action == "switch_mode" + and parameters + and self.current_mode == parameters[0] + ): + return None + return True diff --git a/contrib/python/textual/textual/demo/game.py b/contrib/python/textual/textual/demo/game.py new file mode 100644 index 00000000000..116f076f71f --- /dev/null +++ b/contrib/python/textual/textual/demo/game.py @@ -0,0 +1,586 @@ +""" +An implementation of the "Sliding Tile" puzzle. + +Textual isn't a game engine exactly, but it wasn't hard to build this. + +""" + +from __future__ import annotations + +from asyncio import sleep +from collections import defaultdict +from dataclasses import dataclass +from itertools import product +from random import choice +from time import monotonic + +from rich.console import ConsoleRenderable +from rich.syntax import Syntax + +from textual import containers, events, on, work +from textual._loop import loop_last +from textual.app import ComposeResult +from textual.binding import Binding +from textual.demo.page import PageScreen +from textual.geometry import Offset, Size +from textual.reactive import reactive +from textual.screen import ModalScreen, Screen +from textual.timer import Timer +from textual.widgets import Button, Digits, Footer, Markdown, Select, Static + + +@dataclass +class NewGame: + """A dataclass to report the desired game type.""" + + language: str + code: str + size: tuple[int, int] + + +PYTHON_CODE = '''\ +class SpatialMap(Generic[ValueType]): + """A spatial map allows for data to be associated with rectangular regions + in Euclidean space, and efficiently queried. + + When the SpatialMap is populated, a reference to each value is placed into one or + more buckets associated with a regular grid that covers 2D space. + + The SpatialMap is able to quickly retrieve the values under a given "window" region + by combining the values in the grid squares under the visible area. + """ + + def __init__(self, grid_width: int = 100, grid_height: int = 20) -> None: + """Create a spatial map with the given grid size. + + Args: + grid_width: Width of a grid square. + grid_height: Height of a grid square. + """ + self._grid_size = (grid_width, grid_height) + self.total_region = Region() + self._map: defaultdict[GridCoordinate, list[ValueType]] = defaultdict(list) + self._fixed: list[ValueType] = [] + + def _region_to_grid_coordinates(self, region: Region) -> Iterable[GridCoordinate]: + """Get the grid squares under a region. + + Args: + region: A region. + + Returns: + Iterable of grid coordinates (tuple of 2 values). + """ + # (x1, y1) is the coordinate of the top left cell + # (x2, y2) is the coordinate of the bottom right cell + x1, y1, width, height = region + x2 = x1 + width - 1 + y2 = y1 + height - 1 + grid_width, grid_height = self._grid_size + + return product( + range(x1 // grid_width, x2 // grid_width + 1), + range(y1 // grid_height, y2 // grid_height + 1), + ) +''' + +XML_CODE = """\ +<?xml version="1.0" encoding="UTF-8"?> +<movies> + <movie> + <title>Back to the Future</title> <year>1985</year> <director>Robert Zemeckis</director> + <genre>Science Fiction</genre> <rating>PG</rating> + <cast> + <actor> <name>Michael J. Fox</name> <role>Marty McFly</role> </actor> + <actor> <name>Christopher Lloyd</name> <role>Dr. Emmett Brown</role> </actor> + </cast> + </movie> + <movie> + <title>The Breakfast Club</title> <year>1985</year> <director>John Hughes</director> + <genre>Drama</genre> <rating>R</rating> + <cast> + <actor> <name>Emilio Estevez</name> <role>Andrew Clark</role> </actor> + <actor> <name>Molly Ringwald</name> <role>Claire Standish</role> </actor> + </cast> + </movie> + <movie> + <title>Ghostbusters</title> <year>1984</year> <director>Ivan Reitman</director> + <genre>Comedy</genre> <rating>PG</rating> + <cast> + <actor> <name>Bill Murray</name> <role>Dr. Peter Venkman</role> </actor> + <actor> <name>Dan Aykroyd</name> <role>Dr. Raymond Stantz</role> </actor> + </cast> + </movie> + <movie> + <title>Die Hard</title> <year>1988</year> <director>John McTiernan</director> + <genre>Action</genre> <rating>R</rating> + <cast> + <actor> <name>Bruce Willis</name> <role>John McClane</role> </actor> + <actor> <name>Alan Rickman</name> <role>Hans Gruber</role> </actor> + </cast> + </movie> + <movie> + <title>E.T. the Extra-Terrestrial</title> <year>1982</year> <director>Steven Spielberg</director> + <genre>Science Fiction</genre> <rating>PG</rating> + <cast> + <actor> <name>Henry Thomas</name> <role>Elliott</role> </actor> + <actor> <name>Drew Barrymore</name> <role>Gertie</role> </actor> + </cast> + </movie> +</movies>""" + +BF_CODE = """\ +[life.b -- John Horton Conway's Game of Life +(c) 2021 Daniel B. Cristofani +] + +>>>->+>+++++>(++++++++++)[[>>>+<<<-]>+++++>+>>+[<<+>>>>>+<<<-]<-]>>>>[ + [>>>+>+<<<<-]+++>>+[<+>>>+>+<<<-]>>[>[[>>>+<<<-]<]<<++>+>>>>>>-]<- +]+++>+>[[-]<+<[>+++++++++++++++++<-]<+]>>[ + [+++++++++.-------->>>]+[-<<<]>>>[>>,----------[>]<]<<[ + <<<[ + >--[<->>+>-<<-]<[[>>>]+>-[+>>+>-]+[<<<]<-]>++>[<+>-] + >[[>>>]+[<<<]>>>-]+[->>>]<-[++>]>[------<]>+++[<<<]> + ]< + ]>[ + -[+>>+>-]+>>+>>>+>[<<<]>->+>[ + >[->+>+++>>++[>>>]+++<<<++<<<++[>>>]>>>]<<<[>[>>>]+>>>] + <<<<<<<[<<++<+[-<<<+]->++>>>++>>>++<<<<]<<<+[-<<<+]+>->>->> + ]<<+<<+<<<+<<-[+<+<<-]+<+[ + ->+>[-<-<<[<<<]>[>>[>>>]<<+<[<<<]>-]] + <[<[<[<<<]>+>>[>>>]<<-]<[<<<]]>>>->>>[>>>]+> + ]>+[-<<[-]<]-[ + [>>>]<[<<[<<<]>>>>>+>[>>>]<-]>>>[>[>>>]<<<<+>[<<<]>>-]> + ]<<<<<<[---<-----[-[-[<->>+++<+++++++[-]]]]<+<+]> + ]>> +] + +[This program simulates the Game of Life cellular automaton. + +Type e.g. "be" to toggle the fifth cell in the second row, "q" to quit, +or a bare linefeed to advance one generation. + +Grid wraps toroidally. Board size in parentheses in first line (2-166 work). + +This program is licensed under a Creative Commons Attribution-ShareAlike 4.0 +International License (http://creativecommons.org/licenses/by-sa/4.0/).] +""" + + +LEVELS = {"Python": PYTHON_CODE, "XML": XML_CODE, "BF": BF_CODE} + + +class Tile(containers.Vertical): + """An individual tile in the puzzle. + + A Tile is a container with a static inside it. + The static contains the code (as a Rich Syntax object), scrolled so the + relevant portion is visible. + """ + + DEFAULT_CSS = """ + Tile { + position: absolute; + Static { + width: auto; + height: auto; + &:hover { tint: $primary 30%; } + } + &#blank { visibility: hidden; } + } + """ + + position: reactive[Offset] = reactive(Offset) + + def __init__( + self, + renderable: ConsoleRenderable, + tile: int | None, + size: Size, + position: Offset, + ) -> None: + self.renderable = renderable + self.tile = tile + self.tile_size = size + self.start_position = position + + super().__init__(id="blank" if tile is None else f"tile{self.tile}") + self.set_reactive(Tile.position, position) + + def compose(self) -> ComposeResult: + static = Static( + self.renderable, + classes="tile", + name="blank" if self.tile is None else str(self.tile), + ) + assert self.parent is not None + static.styles.width = self.parent.styles.width + static.styles.height = self.parent.styles.height + yield static + + def on_mount(self) -> None: + if self.tile is not None: + width, height = self.tile_size + self.styles.width = width + self.styles.height = height + column, row = self.position + self.set_scroll(column * width, row * height) + self.offset = self.position * self.tile_size + + def watch_position(self, position: Offset) -> None: + """The 'position' is in tile coordinate. + When it changes we animate it to the cell coordinates.""" + self.animate("offset", position * self.tile_size, duration=0.2) + + +class GameDialog(containers.VerticalGroup): + """A dialog to ask the user for the initial game parameters.""" + + DEFAULT_CSS = """ + GameDialog { + background: $boost; + border: thick $primary-muted; + padding: 0 2; + width: 50; + #values { + width: 1fr; + Select { margin: 1 0;} + } + Button { + margin: 0 1 1 1; + width: 1fr; + } + } + """ + + def compose(self) -> ComposeResult: + with containers.VerticalGroup(id="values"): + yield Select.from_values( + LEVELS.keys(), + prompt="Language", + value="Python", + id="language", + allow_blank=False, + ) + yield Select( + [ + ("Easy (3x3)", (3, 3)), + ("Medium (4x4)", (4, 4)), + ("Hard (5x5)", (5, 5)), + ], + prompt="Level", + value=(4, 4), + id="level", + allow_blank=False, + ) + yield Button("Start", variant="primary") + + @on(Button.Pressed) + def on_button_pressed(self) -> None: + language = self.query_one("#language", Select).selection + level = self.query_one("#level", Select).selection + assert language is not None and level is not None + self.screen.dismiss(NewGame(language, LEVELS[language], level)) + + +class GameDialogScreen(ModalScreen): + """Modal screen containing the dialog.""" + + CSS = """ + GameDialogScreen { + align: center middle; + } + """ + + BINDINGS = [("escape", "dismiss")] + + def compose(self) -> ComposeResult: + yield GameDialog() + + +class Game(containers.Vertical, can_focus=True): + """Widget for the game board.""" + + ALLOW_MAXIMIZE = False + DEFAULT_CSS = """ + Game { + visibility: hidden; + align: center middle; + hatch: right $panel; + border: heavy transparent; + &:focus { + border: heavy $success; + } + #grid { + border: heavy $primary; + hatch: right $panel; + box-sizing: content-box; + } + Digits { + width: auto; + color: $foreground; + } + } + """ + + BINDINGS = [ + Binding("up", "move('up')", "up", priority=True), + Binding("down", "move('down')", "down", priority=True), + Binding("left", "move('left')", "left", priority=True), + Binding("right", "move('right')", "right", priority=True), + ] + + state = reactive("waiting") + play_start_time: reactive[float] = reactive(monotonic) + play_time = reactive(0.0, init=False) + code = reactive("") + dimensions = reactive(Size(3, 3)) + code = reactive("") + language = reactive("") + + def __init__( + self, + code: str, + language: str, + dimensions: tuple[int, int], + tile_size: tuple[int, int], + ) -> None: + self.set_reactive(Game.code, code) + self.set_reactive(Game.language, language) + self.locations: defaultdict[Offset, int | None] = defaultdict(None) + super().__init__() + self.dimensions = Size(*dimensions) + self.tile_size = Size(*tile_size) + self.play_timer: Timer | None = None + + def check_win(self) -> bool: + return all(tile.start_position == tile.position for tile in self.query(Tile)) + + def watch_dimensions(self, dimensions: Size) -> None: + self.locations.clear() + tile_width, tile_height = dimensions + for last, tile_no in loop_last(range(0, tile_width * tile_height)): + position = Offset(*divmod(tile_no, tile_width)) + self.locations[position] = None if last else tile_no + + def compose(self) -> ComposeResult: + syntax = Syntax( + self.code, + self.language.lower(), + indent_guides=True, + line_numbers=True, + theme="material", + ) + tile_width, tile_height = self.dimensions + self.state = "waiting" + yield Digits("") + with containers.HorizontalGroup(id="grid") as grid: + grid.styles.width = tile_width * self.tile_size[0] + grid.styles.height = tile_height * self.tile_size[1] + for row, column in product(range(tile_width), range(tile_height)): + position = Offset(row, column) + tile_no = self.locations[position] + yield Tile(syntax, tile_no, self.tile_size, position) + if self.language: + self.call_after_refresh(self.shuffle) + + def update_clock(self) -> None: + if self.state == "playing": + elapsed = monotonic() - self.play_start_time + self.play_time = elapsed + + def watch_play_time(self, play_time: float) -> None: + minutes, seconds = divmod(play_time, 60) + hours, minutes = divmod(minutes, 60) + self.query_one(Digits).update(f"{hours:02,.0f}:{minutes:02.0f}:{seconds:04.1f}") + + def watch_state(self, old_state: str, new_state: str) -> None: + if self.play_timer is not None: + self.play_timer.stop() + + if new_state == "playing": + self.play_start_time = monotonic() + self.play_timer = self.set_interval(1 / 10, self.update_clock) + + def get_tile(self, tile: int | None) -> Tile: + """Get a tile (int) or the blank (None).""" + return self.query_one("#blank" if tile is None else f"#tile{tile}", Tile) + + def get_tile_at(self, position: Offset) -> Tile: + """Get a tile at the given position, or raise an IndexError.""" + if position not in self.locations: + raise IndexError("No tile") + return self.get_tile(self.locations[position]) + + def move_tile(self, tile_no: int | None) -> None: + """Move a tile to the blank. + Note: this doesn't do any validation of legal moves. + """ + tile = self.get_tile(tile_no) + blank = self.get_tile(None) + blank_position = blank.position + + self.locations[tile.position] = None + blank.position = tile.position + + self.locations[blank_position] = tile_no + tile.position = blank_position + + if self.state == "playing" and self.check_win(): + self.state = "won" + self.notify("You won!", title="Sliding Tile Puzzle") + + def can_move(self, tile: int) -> bool: + """Check if a tile may move.""" + blank_position = self.get_tile(None).position + tile_position = self.get_tile(tile).position + return blank_position in ( + tile_position + (1, 0), + tile_position - (1, 0), + tile_position + (0, 1), + tile_position - (0, 1), + ) + + def action_move(self, direction: str) -> None: + if self.state != "playing": + self.app.bell() + return + blank = self.get_tile(None).position + if direction == "up": + position = blank + (0, +1) + elif direction == "down": + position = blank + (0, -1) + elif direction == "left": + position = blank + (+1, 0) + elif direction == "right": + position = blank + (-1, 0) + try: + tile = self.get_tile_at(position) + except IndexError: + return + self.move_tile(tile.tile) + + def get_legal_moves(self) -> set[Offset]: + """Get the positions of all tiles that can move.""" + blank = self.get_tile(None).position + moves: list[Offset] = [] + + DIRECTIONS = [(-1, 0), (+1, -0), (0, -1), (0, +1)] + moves = [ + blank + direction + for direction in DIRECTIONS + if (blank + direction) in self.locations + ] + return {self.get_tile_at(position).position for position in moves} + + @work(exclusive=True) + async def shuffle(self, shuffles: int = 150) -> None: + """A worker to do the shuffling.""" + self.visible = True + if self.play_timer is not None: + self.play_timer.stop() + self.query_one("#grid").border_title = "[reverse bold] SHUFFLING - Please Wait " + self.state = "shuffling" + previous_move: Offset = Offset(-1, -1) + for _ in range(shuffles): + legal_moves = self.get_legal_moves() + legal_moves.discard(previous_move) + previous_move = self.get_tile(None).position + move_position = choice(list(legal_moves)) + move_tile = self.get_tile_at(move_position) + self.move_tile(move_tile.tile) + await sleep(0.05) + self.query_one("#grid").border_title = "" + self.state = "playing" + + @on(events.Click, ".tile") + def on_tile_clicked(self, event: events.Click) -> None: + assert event.widget is not None + tile = int(event.widget.name or 0) + if self.state != "playing" or not self.can_move(tile): + self.app.bell() + return + self.move_tile(tile) + + +class GameInstructions(containers.VerticalGroup): + DEFAULT_CSS = """\ + GameInstructions { + layer: instructions; + width: 60; + background: $panel; + border: thick $primary-darken-2; + Markdown { + background: $panel; + } + + } + +""" + INSTRUCTIONS = """\ +# Instructions + +This is an implementation of the *sliding tile puzzle*. + +The board consists of a number of tiles and a blank space. +After shuffling, the goal is to restore the original "image" by moving a square either horizontally or vertically into the blank space. + +This version is like the physical game, but rather than an image, you need to restore code. + """ + + def compose(self) -> ComposeResult: + yield Markdown(self.INSTRUCTIONS) + with containers.Center(): + yield Button("New Game", action="screen.new_game", variant="success") + + +class GameScreen(PageScreen): + """The screen containing the game.""" + + DEFAULT_CSS = """ + GameScreen{ + align: center middle; + layers: instructions game; + } + """ + + BINDINGS = [("n", "new_game", "New Game")] + + def compose(self) -> ComposeResult: + yield GameInstructions() + yield Game("\n" * 100, "", dimensions=(4, 4), tile_size=(16, 8)) + yield Footer() + + def action_shuffle(self) -> None: + self.query_one(Game).shuffle() + + def action_new_game(self) -> None: + self.app.push_screen(GameDialogScreen(), callback=self.new_game) + + async def new_game(self, new_game: NewGame | None) -> None: + if new_game is None: + return + self.query_one(GameInstructions).display = False + game = self.query_one(Game) + game.state = "waiting" + game.code = new_game.code + game.language = new_game.language + game.dimensions = Size(*new_game.size) + await game.recompose() + game.focus() + + def check_action(self, action: str, parameters: tuple[object, ...]) -> bool | None: + if action == "shuffle" and self.query_one(Game).state == "waiting": + return None + return True + + +if __name__ == "__main__": + from textual.app import App + + class GameApp(App): + def get_default_screen(self) -> Screen: + return GameScreen() + + app = GameApp() + app.run() diff --git a/contrib/python/textual/textual/demo/page.py b/contrib/python/textual/textual/demo/page.py index 35a98df3876..a176403712e 100644 --- a/contrib/python/textual/textual/demo/page.py +++ b/contrib/python/textual/textual/demo/page.py @@ -59,7 +59,7 @@ class PageScreen(Screen): Binding( "c", "show_code", - "show code", + "Code", tooltip="Show the code used to generate this screen", ) ] diff --git a/contrib/python/textual/textual/demo/projects.py b/contrib/python/textual/textual/demo/projects.py index a8e5c4406be..e264fd71434 100644 --- a/contrib/python/textual/textual/demo/projects.py +++ b/contrib/python/textual/textual/demo/projects.py @@ -222,3 +222,14 @@ class ProjectsScreen(PageScreen): for project in PROJECTS: yield Project(project) yield Footer() + + +if __name__ == "__main__": + from textual.app import App + + class GameApp(App): + def get_default_screen(self) -> Screen: + return ProjectsScreen() + + app = GameApp() + app.run() diff --git a/contrib/python/textual/textual/demo/widgets.py b/contrib/python/textual/textual/demo/widgets.py index 11e4b0419dc..16eb437a368 100644 --- a/contrib/python/textual/textual/demo/widgets.py +++ b/contrib/python/textual/textual/demo/widgets.py @@ -166,7 +166,7 @@ class Datatables(containers.VerticalGroup): A fully-featured DataTable, with cell, row, and columns cursors. Cells may be individually styled, and may include Rich renderables. -**Tip:** Focus the table and press `ctrl+m` +**Tip:** Focus the table and press `ctrl+a` """ DEFAULT_CSS = """ @@ -300,12 +300,12 @@ And a RichLog widget to display Rich renderables. Logs { Log, RichLog { width: 1fr; - height: 20; - border: blank; - padding: 0; + height: 20; + padding: 1; overflow-x: auto; + border: wide transparent; &:focus { - border: heavy $accent; + border: wide $border; } } TabPane { padding: 0; } @@ -674,11 +674,13 @@ There is also the Tree widget's cousin, DirectoryTree, to navigate folders and f Trees { Tree { height: 16; - &.-maximized { height: 1fr; } + padding: 1; + &.-maximized { height: 1fr; } + border: wide transparent; + &:focus { border: wide $border; } } VerticalGroup { - border: heavy transparent; - &:focus-within { border: heavy $border; } + } } @@ -729,7 +731,6 @@ from textual import App, ComposeResult "Java", "Javascript", "JSON", - "Kotlin", "Markdown", "Python", "Rust", @@ -806,3 +807,14 @@ class WidgetsScreen(PageScreen): yield Trees() yield YourWidgets() yield Footer() + + +if __name__ == "__main__": + from textual.app import App + + class GameApp(App): + def get_default_screen(self) -> Screen: + return WidgetsScreen() + + app = GameApp() + app.run() diff --git a/contrib/python/textual/textual/document/_document.py b/contrib/python/textual/textual/document/_document.py index 386a6a7713f..47e87eb09b3 100644 --- a/contrib/python/textual/textual/document/_document.py +++ b/contrib/python/textual/textual/document/_document.py @@ -8,8 +8,7 @@ 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 tree_sitter import Node, Query from textual._cells import cell_len from textual.geometry import Size @@ -143,10 +142,10 @@ class DocumentBase(ABC): def query_syntax_tree( self, - query: Query, + query: "Query", start_point: tuple[int, int] | None = None, end_point: tuple[int, int] | None = None, - ) -> list[tuple[Node, str]]: + ) -> dict[str, list["Node"]]: """Query the tree-sitter syntax tree. The default implementation always returns an empty list. @@ -159,11 +158,11 @@ class DocumentBase(ABC): end_point: The (row, column byte) to end the query at. Returns: - A tuple containing the nodes and text captured by the query. + A dict mapping captured node names to lists of Nodes with that name. """ - return [] + return {} - def prepare_query(self, query: str) -> Query | None: + def prepare_query(self, query: str) -> "Query | None": return None @property @@ -206,7 +205,7 @@ class Document(DocumentBase): """A document which can be opened in a TextArea.""" def __init__(self, text: str) -> None: - self._newline = _detect_newline_style(text) + self._newline: 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. @@ -371,14 +370,23 @@ class Document(DocumentBase): return index def get_location_from_index(self, index: int) -> Location: - """Given an index in the document's text, returns the corresponding location. + """Given a codepoint index in the document's text, returns the corresponding location. Args: index: The index in the document's text. Returns: The corresponding location. + + Raises: + ValueError: If the index is doesn't correspond to a location in the document. """ + error_message = ( + f"Index {index!r} does not correspond to a location in the document." + ) + if index < 0 or index > len(self.text): + raise ValueError(error_message) + column_index = 0 newline_length = len(self.newline) for line_index in range(self.line_count): @@ -391,6 +399,8 @@ class Document(DocumentBase): return (line_index + 1, 0) column_index = next_column_index + raise ValueError(error_message) + def get_line(self, index: int) -> str: """Returns the line with the given index from the document. diff --git a/contrib/python/textual/textual/document/_languages.py b/contrib/python/textual/textual/document/_languages.py deleted file mode 100644 index fb313cf7cab..00000000000 --- a/contrib/python/textual/textual/document/_languages.py +++ /dev/null @@ -1,19 +0,0 @@ -BUILTIN_LANGUAGES = sorted( - [ - "bash", - "css", - "go", - "html", - "java", - "javascript", - "json", - "kotlin", - "markdown", - "python", - "rust", - "regex", - "sql", - "toml", - "yaml", - ] -) diff --git a/contrib/python/textual/textual/document/_syntax_aware_document.py b/contrib/python/textual/textual/document/_syntax_aware_document.py index 32dbc191b33..162d3fbd544 100644 --- a/contrib/python/textual/textual/document/_syntax_aware_document.py +++ b/contrib/python/textual/textual/document/_syntax_aware_document.py @@ -1,16 +1,15 @@ 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 + from tree_sitter import Language, Node, Parser, Query, Tree TREE_SITTER = True except ImportError: TREE_SITTER = False + +from textual._tree_sitter import BUILTIN_LANGUAGES from textual.document._document import Document, EditResult, Location, _utf8_encode -from textual.document._languages import BUILTIN_LANGUAGES class SyntaxAwareDocumentError(Exception): @@ -52,7 +51,8 @@ class SyntaxAwareDocument(Document): """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.""" + + from textual._tree_sitter import get_language # If the language is `None`, then avoid doing any parsing related stuff. if isinstance(language, str): @@ -61,25 +61,22 @@ class SyntaxAwareDocument(Document): # If tree-sitter-languages is not installed properly, get_language # and get_parser may raise an OSError when unable to load their # resources + try: self.language = get_language(language) - self._parser = get_parser(language) except OSError as e: raise SyntaxAwareDocumentError( f"Could not find binaries for {language!r}" ) from e else: self.language = language - self._parser = Parser() - self._parser.set_language(language) + + self._parser = Parser(self.language) + """The tree-sitter Parser or None if tree-sitter is unavailable.""" 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. @@ -110,7 +107,7 @@ class SyntaxAwareDocument(Document): query: Query, start_point: tuple[int, int] | None = None, end_point: tuple[int, int] | None = None, - ) -> list[tuple["Node", str]]: + ) -> dict[str, list["Node"]]: """Query the tree-sitter syntax tree. The default implementation always returns an empty list. @@ -176,12 +173,13 @@ class SyntaxAwareDocument(Document): ) # Incrementally parse the document. self._syntax_tree = self._parser.parse( - self._read_callable, self._syntax_tree # type: ignore[arg-type] + self._read_callable, + self._syntax_tree, # type: ignore[arg-type] ) return replace_result - def get_line(self, line_index: int) -> str: + def get_line(self, index: int) -> str: """Return the string representing the line, not including new line characters. Args: @@ -190,7 +188,7 @@ class SyntaxAwareDocument(Document): Returns: The string representing the line. """ - line_string = self[line_index] + line_string = self[index] return line_string def _location_to_byte_offset(self, location: Location) -> int: diff --git a/contrib/python/textual/textual/dom.py b/contrib/python/textual/textual/dom.py index 68212bc8b63..d7218c60292 100644 --- a/contrib/python/textual/textual/dom.py +++ b/contrib/python/textual/textual/dom.py @@ -10,6 +10,7 @@ import re import threading from functools import lru_cache, partial from inspect import getfile +from operator import attrgetter from typing import ( TYPE_CHECKING, Any, @@ -1214,7 +1215,7 @@ class DOMNode(MessagePump): Returns: A list of nodes. """ - return [child for child in self._nodes if child.display] + return list(filter(attrgetter("display"), self._nodes)) def watch( self, diff --git a/contrib/python/textual/textual/drivers/linux_driver.py b/contrib/python/textual/textual/drivers/linux_driver.py index 510b0992f0e..7e04f4cf26e 100644 --- a/contrib/python/textual/textual/drivers/linux_driver.py +++ b/contrib/python/textual/textual/drivers/linux_driver.py @@ -216,8 +216,6 @@ class LinuxDriver(Driver): loop = asyncio.get_running_loop() def send_size_event() -> None: - if self._in_band_window_resize: - return terminal_size = self._get_terminal_size() width, height = terminal_size textual_size = Size(width, height) @@ -231,7 +229,8 @@ class LinuxDriver(Driver): self._writer_thread.start() def on_terminal_resize(signum, stack) -> None: - send_size_event() + if not self._in_band_window_resize: + send_size_event() signal.signal(signal.SIGWINCH, on_terminal_resize) diff --git a/contrib/python/textual/textual/drivers/linux_inline_driver.py b/contrib/python/textual/textual/drivers/linux_inline_driver.py index 03e55d528f1..3fc62df9dee 100644 --- a/contrib/python/textual/textual/drivers/linux_inline_driver.py +++ b/contrib/python/textual/textual/drivers/linux_inline_driver.py @@ -208,10 +208,7 @@ class LinuxInlineDriver(Driver): self.write("\x1b[?25l") # Hide cursor self.write("\033[?1004h") # Enable FocusIn/FocusOut. - self.write("\x1b[>1u") # https://sw.kovidgoyal.net/kitty/keyboard-protocol/ - # Disambiguate escape codes https://sw.kovidgoyal.net/kitty/keyboard-protocol/#progressive-enhancement - self.write("\x1b[=1;u") self.flush() self._enable_mouse_support() diff --git a/contrib/python/textual/textual/events.py b/contrib/python/textual/textual/events.py index 6e7a21e8da3..fd4dea3eddc 100644 --- a/contrib/python/textual/textual/events.py +++ b/contrib/python/textual/textual/events.py @@ -567,7 +567,7 @@ class Timer(Event, bubble=False, verbose=True): - [X] Verbose """ - __slots__ = ["time", "count", "callback"] + __slots__ = ["timer", "time", "count", "callback"] def __init__( self, diff --git a/contrib/python/textual/textual/geometry.py b/contrib/python/textual/textual/geometry.py index b91fc8bf069..e6d190f9392 100644 --- a/contrib/python/textual/textual/geometry.py +++ b/contrib/python/textual/textual/geometry.py @@ -126,6 +126,9 @@ class Offset(NamedTuple): if isinstance(other, (float, int)): x, y = self return Offset(int(x * other), int(y * other)) + if isinstance(other, tuple): + x, y = self + return Offset(int(x * other[0]), int(y * other[1])) return NotImplemented def __neg__(self) -> Offset: diff --git a/contrib/python/textual/textual/layout.py b/contrib/python/textual/textual/layout.py index 8e494fcb74d..a3ef9ec006d 100644 --- a/contrib/python/textual/textual/layout.py +++ b/contrib/python/textual/textual/layout.py @@ -91,12 +91,18 @@ class WidgetPlacement(NamedTuple): order: int = 0 fixed: bool = False overlay: bool = False + absolute: bool = False + + @property + def reset_origin(self) -> WidgetPlacement: + """Reset the origin in the placement (moves it to (0, 0)).""" + return self._replace(region=self.region.reset_offset) @classmethod def translate( cls, placements: list[WidgetPlacement], translate_offset: Offset ) -> list[WidgetPlacement]: - """Move all placements by a given offset. + """Move all non-absolute placements by a given offset. Args: placements: List of placements. @@ -108,19 +114,35 @@ class WidgetPlacement(NamedTuple): if translate_offset: return [ cls( - region + translate_offset, + ( + region + translate_offset + if layout_widget.absolute_offset is None + else region + ), offset, margin, layout_widget, order, fixed, overlay, + absolute, ) - for region, offset, margin, layout_widget, order, fixed, overlay in placements + for region, offset, margin, layout_widget, order, fixed, overlay, absolute in placements ] return placements @classmethod + def apply_absolute(cls, placements: list[WidgetPlacement]) -> None: + """Applies absolute offsets (in place). + + Args: + placements: A list of placements. + """ + for index, placement in enumerate(placements): + if placement.absolute: + placements[index] = placement.reset_origin + + @classmethod def get_bounds(cls, placements: Iterable[WidgetPlacement]) -> Region: """Get a bounding region around all placements. @@ -135,6 +157,48 @@ class WidgetPlacement(NamedTuple): ) return bounding_region + def process_offset( + self, constrain_region: Region, absolute_offset: Offset + ) -> WidgetPlacement: + """Apply any absolute offset or constrain rules to the placement. + + Args: + constrain_region: The container region when applying constrain rules. + absolute_offset: Default absolute offset that moves widget in to screen coordinates. + + Returns: + Processes placement, may be the same instance. + """ + widget = self.widget + styles = widget.styles + if not widget.absolute_offset and not styles.has_any_rules( + "constrain_x", "constrain_y" + ): + # Bail early if there is nothing to do + return self + region = self.region + margin = self.margin + if widget.absolute_offset is not None: + region = region.at_offset( + widget.absolute_offset + margin.top_left - absolute_offset + ) + + region = region.translate(self.offset).constrain( + styles.constrain_x, + styles.constrain_y, + self.margin, + constrain_region - absolute_offset, + ) + + offset = region.offset - self.region.offset + if offset != self.offset: + region, _offset, margin, widget, order, fixed, overlay, absolute = self + placement = WidgetPlacement( + region, offset, margin, widget, order, fixed, overlay, absolute + ) + return placement + return self + class Layout(ABC): """Base class of the object responsible for arranging Widgets within a container.""" diff --git a/contrib/python/textual/textual/layouts/grid.py b/contrib/python/textual/textual/layouts/grid.py index a1b0823d6d4..6b4304092b0 100644 --- a/contrib/python/textual/textual/layouts/grid.py +++ b/contrib/python/textual/textual/layouts/grid.py @@ -260,8 +260,6 @@ class GridLayout(Layout): placements: list[WidgetPlacement] = [] _WidgetPlacement = WidgetPlacement add_placement = placements.append - widgets: list[Widget] = [] - add_widget = widgets.append max_column = len(columns) - 1 max_row = len(rows) - 1 @@ -289,17 +287,21 @@ class GridLayout(Layout): ) .crop_size(cell_size) .shrink(margin) - ) + ) + offset + widget_styles = widget.styles placement_offset = ( - styles.offset.resolve(cell_size, viewport) - if styles.has_rule("offset") + widget_styles.offset.resolve(cell_size, viewport) + if widget_styles.has_rule("offset") else NULL_OFFSET ) + absolute = ( + widget_styles.has_rule("position") and styles.position == "absolute" + ) add_placement( _WidgetPlacement( - region + offset, + region, placement_offset, ( margin @@ -307,8 +309,8 @@ class GridLayout(Layout): else margin.grow_maximum(gutter_spacing) ), widget, + absolute, ) ) - add_widget(widget) return placements diff --git a/contrib/python/textual/textual/layouts/horizontal.py b/contrib/python/textual/textual/layouts/horizontal.py index c62fae3c1da..660ce860538 100644 --- a/contrib/python/textual/textual/layouts/horizontal.py +++ b/contrib/python/textual/textual/layouts/horizontal.py @@ -22,6 +22,7 @@ class HorizontalLayout(Layout): def arrange( self, parent: Widget, children: list[Widget], size: Size ) -> ArrangeResult: + parent.pre_layout(self) placements: list[WidgetPlacement] = [] add_placement = placements.append viewport = parent.app.size @@ -81,6 +82,7 @@ class HorizontalLayout(Layout): children, box_models, margins ): styles = widget.styles + overlay = styles.overlay == "screen" offset = ( styles.offset.resolve( @@ -92,23 +94,27 @@ class HorizontalLayout(Layout): ) offset_y = box_margin.top next_x = x + content_width + + region = _Region( + x.__floor__(), + offset_y, + (next_x - x.__floor__()).__floor__(), + content_height.__floor__(), + ) + absolute = styles.has_rule("position") and styles.position == "absolute" add_placement( _WidgetPlacement( - _Region( - x.__floor__(), - offset_y, - (next_x - x.__floor__()).__floor__(), - content_height.__floor__(), - ), + region, offset, box_margin, widget, 0, False, overlay, + absolute, ) ) - if not overlay: + if not overlay and not absolute: x = next_x + margin return placements diff --git a/contrib/python/textual/textual/layouts/vertical.py b/contrib/python/textual/textual/layouts/vertical.py index c7b8087f7fb..91b06bab09e 100644 --- a/contrib/python/textual/textual/layouts/vertical.py +++ b/contrib/python/textual/textual/layouts/vertical.py @@ -20,6 +20,7 @@ class VerticalLayout(Layout): def arrange( self, parent: Widget, children: list[Widget], size: Size ) -> ArrangeResult: + parent.pre_layout(self) placements: list[WidgetPlacement] = [] add_placement = placements.append viewport = parent.app.size @@ -96,23 +97,28 @@ class VerticalLayout(Layout): if styles.has_rule("offset") else NULL_OFFSET ) + + region = _Region( + box_margin.left, + y.__floor__(), + content_width.__floor__(), + next_y.__floor__() - y.__floor__(), + ) + + absolute = styles.has_rule("position") and styles.position == "absolute" add_placement( _WidgetPlacement( - _Region( - box_margin.left, - y.__floor__(), - content_width.__floor__(), - next_y.__floor__() - y.__floor__(), - ), + region, offset, box_margin, widget, 0, False, overlay, + absolute, ) ) - if not overlay: + if not overlay and not absolute: y = next_y + margin return placements diff --git a/contrib/python/textual/textual/screen.py b/contrib/python/textual/textual/screen.py index 81cc3c733ab..f120c95cbaf 100644 --- a/contrib/python/textual/textual/screen.py +++ b/contrib/python/textual/textual/screen.py @@ -261,8 +261,6 @@ class Screen(Generic[ScreenResultType], Widget): ) """The signal that is published when the screen's layout is refreshed.""" - self._bindings_updated = False - """Indicates that a binding update was requested.""" self.bindings_updated_signal: Signal[Screen] = Signal(self, "bindings_updated") """A signal published when the bindings have been updated""" @@ -315,8 +313,7 @@ class Screen(Generic[ScreenResultType], Widget): def refresh_bindings(self) -> None: """Call to request a refresh of bindings.""" - self._bindings_updated = True - self.check_idle() + self.bindings_updated_signal.publish(self) def _watch_maximized( self, previously_maximized: Widget | None, maximized: Widget | None @@ -562,6 +559,10 @@ class Screen(Generic[ScreenResultType], Widget): except NoWidget: return None + if widget.has_class("-textual-system") or widget.loading: + # Clicking Textual system widgets should not focus anything + return None + for node in widget.ancestors_with_self: if isinstance(node, Widget) and node.focusable: return node @@ -661,8 +662,6 @@ class Screen(Generic[ScreenResultType], Widget): the CSS selectors given in the argument. """ - # TODO: This shouldn't be required - self._compositor._full_map_invalidated = True if not isinstance(selector, str): selector = selector.__name__ selector_set = parse_selectors(selector) @@ -915,7 +914,7 @@ class Screen(Generic[ScreenResultType], Widget): self.log.debug(widget, "was focused") self._update_focus_styles(focused, blurred) - self.refresh_bindings() + self.call_after_refresh(self.refresh_bindings) def _extend_compose(self, widgets: list[Widget]) -> None: """Insert Textual's own internal widgets. @@ -936,32 +935,22 @@ class Screen(Generic[ScreenResultType], Widget): self.screen_layout_refresh_signal.subscribe( self, self._maybe_clear_tooltip, immediate=True ) - self.refresh_bindings() - # Send this signal so we don't get an initial frame with no bindings in the footer - self.bindings_updated_signal.publish(self) async def _on_idle(self, event: events.Idle) -> None: # Check for any widgets marked as 'dirty' (needs a repaint) event.prevent_default() + if not self.app._batch_count and self.is_current: + if ( + self._layout_required + or self._scroll_required + or self._repaint_required + or self._recompose_required + or self._dirty_widgets + ): + self._update_timer.resume() + return - try: - if not self.app._batch_count and self.is_current: - if ( - self._layout_required - or self._scroll_required - or self._repaint_required - or self._recompose_required - or self._dirty_widgets - ): - self._update_timer.resume() - return - - await self._invoke_and_clear_callbacks() - finally: - if self._bindings_updated: - self._bindings_updated = False - if self.is_attached and not self.app._exit: - self.app.call_later(self.bindings_updated_signal.publish, self) + await self._invoke_and_clear_callbacks() def _compositor_refresh(self) -> None: """Perform a compositor refresh.""" @@ -1344,7 +1333,7 @@ class Screen(Generic[ScreenResultType], Widget): if widget is self: self.post_message(event) else: - mouse_event = self._translate_mouse_move_event(event, region) + mouse_event = self._translate_mouse_move_event(event, widget, region) mouse_event._set_forwarded() widget._forward_event(mouse_event) @@ -1369,14 +1358,14 @@ class Screen(Generic[ScreenResultType], Widget): @staticmethod def _translate_mouse_move_event( - event: events.MouseMove, region: Region + event: events.MouseMove, widget: Widget, region: Region ) -> events.MouseMove: """ Returns a mouse move event whose relative coordinates are translated to the origin of the specified region. """ return events.MouseMove( - event.widget, + widget, event.x - region.x, event.y - region.y, event.delta_x, @@ -1417,6 +1406,8 @@ class Screen(Generic[ScreenResultType], Widget): if focusable_widget: self.set_focus(focusable_widget, scroll_visible=False) event.style = self.get_style_at(event.screen_x, event.screen_y) + if widget.loading: + return if widget is self: event._set_forwarded() self.post_message(event) diff --git a/contrib/python/textual/textual/scroll_view.py b/contrib/python/textual/textual/scroll_view.py index 42c89ebb233..f905d766730 100644 --- a/contrib/python/textual/textual/scroll_view.py +++ b/contrib/python/textual/textual/scroll_view.py @@ -82,10 +82,8 @@ class ScrollView(ScrollableContainer): layout: Perform layout if required. Returns: - True if anything changed, or False if nothing changed. + True if a resize event should be sent, otherwise False. """ - if self._size != size or self._container_size != container_size: - self.refresh() if ( self._size != size or virtual_size != self.virtual_size @@ -96,9 +94,8 @@ class ScrollView(ScrollableContainer): virtual_size = self.virtual_size self._container_size = size - self.styles.gutter.totals self._scroll_update(virtual_size) - return True - else: - return False + + return self._size != size or self._container_size != container_size def render(self) -> RenderableType: """Render the scrollable region (if `render_lines` is not implemented). diff --git a/contrib/python/textual/textual/tree-sitter/highlights/css.scm b/contrib/python/textual/textual/tree-sitter/highlights/css.scm index b26f0ec96c9..19b0fbfdebe 100644 --- a/contrib/python/textual/textual/tree-sitter/highlights/css.scm +++ b/contrib/python/textual/textual/tree-sitter/highlights/css.scm @@ -52,7 +52,7 @@ (property_name) (feature_name) (attribute_name) - ] @property + ] @css.property (namespace_name) @namespace diff --git a/contrib/python/textual/textual/tree-sitter/highlights/javascript.scm b/contrib/python/textual/textual/tree-sitter/highlights/javascript.scm index 613a49a86f2..9312d682880 100644 --- a/contrib/python/textual/textual/tree-sitter/highlights/javascript.scm +++ b/contrib/python/textual/textual/tree-sitter/highlights/javascript.scm @@ -1,29 +1,17 @@ -; Special identifiers -;-------------------- - -([ - (identifier) - (shorthand_property_identifier) - (shorthand_property_identifier_pattern) - ] @constant - (#match? @constant "^[A-Z_][A-Z\\d_]+$")) - +; Variables +;---------- -((identifier) @constructor - (#match? @constructor "^[A-Z]")) +(identifier) @variable -((identifier) @variable.builtin - (#match? @variable.builtin "^(arguments|module|console|window|document)$") - (#is-not? local)) +; Properties +;----------- -((identifier) @function.builtin - (#eq? @function.builtin "require") - (#is-not? local)) +(property_identifier) @property ; Function and method definitions ;-------------------------------- -(function +(function_expression name: (identifier) @function) (function_declaration name: (identifier) @function) @@ -32,20 +20,20 @@ (pair key: (property_identifier) @function.method - value: [(function) (arrow_function)]) + value: [(function_expression) (arrow_function)]) (assignment_expression left: (member_expression property: (property_identifier) @function.method) - right: [(function) (arrow_function)]) + right: [(function_expression) (arrow_function)]) (variable_declarator name: (identifier) @function - value: [(function) (arrow_function)]) + value: [(function_expression) (arrow_function)]) (assignment_expression left: (identifier) @function - right: [(function) (arrow_function)]) + right: [(function_expression) (arrow_function)]) ; Function and method calls ;-------------------------- @@ -57,15 +45,26 @@ function: (member_expression property: (property_identifier) @function.method)) -; Variables -;---------- +; Special identifiers +;-------------------- -(identifier) @variable +((identifier) @constructor + (#match? @constructor "^[A-Z]")) -; Properties -;----------- +([ + (identifier) + (shorthand_property_identifier) + (shorthand_property_identifier_pattern) + ] @constant + (#match? @constant "^[A-Z_][A-Z\\d_]+$")) -(property_identifier) @property +((identifier) @variable.builtin + (#match? @variable.builtin "^(arguments|module|console|window|document)$") + (#is-not? local)) + +((identifier) @function.builtin + (#eq? @function.builtin "require") + (#is-not? local)) ; Literals ;--------- @@ -93,10 +92,6 @@ ; Tokens ;------- -(template_substitution - "${" @punctuation.special - "}" @punctuation.special) @embedded - [ ";" (optional_chain) @@ -160,6 +155,10 @@ "}" ] @punctuation.bracket +(template_substitution + "${" @punctuation.special + "}" @punctuation.special) @embedded + [ "as" "async" diff --git a/contrib/python/textual/textual/tree-sitter/highlights/kotlin.scm b/contrib/python/textual/textual/tree-sitter/highlights/kotlin.scm deleted file mode 100644 index d2e15a68ee5..00000000000 --- a/contrib/python/textual/textual/tree-sitter/highlights/kotlin.scm +++ /dev/null @@ -1,380 +0,0 @@ -;; Based on the nvim-treesitter highlighting, which is under the Apache license. -;; See https://github.com/nvim-treesitter/nvim-treesitter/blob/f8ab59861eed4a1c168505e3433462ed800f2bae/queries/kotlin/highlights.scm -;; -;; The only difference in this file is that queries using #lua-match? -;; have been removed. - -;;; Identifiers - -(simple_identifier) @variable - -; `it` keyword inside lambdas -; FIXME: This will highlight the keyword outside of lambdas since tree-sitter -; does not allow us to check for arbitrary nestation -((simple_identifier) @variable.builtin -(#eq? @variable.builtin "it")) - -; `field` keyword inside property getter/setter -; FIXME: This will highlight the keyword outside of getters and setters -; since tree-sitter does not allow us to check for arbitrary nestation -((simple_identifier) @variable.builtin -(#eq? @variable.builtin "field")) - -; `this` this keyword inside classes -(this_expression) @variable.builtin - -; `super` keyword inside classes -(super_expression) @variable.builtin - -(class_parameter - (simple_identifier) @property) - -(class_body - (property_declaration - (variable_declaration - (simple_identifier) @property))) - -; id_1.id_2.id_3: `id_2` and `id_3` are assumed as object properties -(_ - (navigation_suffix - (simple_identifier) @property)) - -(enum_entry - (simple_identifier) @constant) - -(type_identifier) @type - -((type_identifier) @type.builtin - (#any-of? @type.builtin - "Byte" - "Short" - "Int" - "Long" - "UByte" - "UShort" - "UInt" - "ULong" - "Float" - "Double" - "Boolean" - "Char" - "String" - "Array" - "ByteArray" - "ShortArray" - "IntArray" - "LongArray" - "UByteArray" - "UShortArray" - "UIntArray" - "ULongArray" - "FloatArray" - "DoubleArray" - "BooleanArray" - "CharArray" - "Map" - "Set" - "List" - "EmptyMap" - "EmptySet" - "EmptyList" - "MutableMap" - "MutableSet" - "MutableList" -)) - -(package_header - . (identifier)) @namespace - -(import_header - "import" @include) - - -; TODO: Seperate labeled returns/breaks/continue/super/this -; Must be implemented in the parser first -(label) @label - -;;; Function definitions - -(function_declaration - . (simple_identifier) @function) - -(getter - ("get") @function.builtin) -(setter - ("set") @function.builtin) - -(primary_constructor) @constructor -(secondary_constructor - ("constructor") @constructor) - -(constructor_invocation - (user_type - (type_identifier) @constructor)) - -(anonymous_initializer - ("init") @constructor) - -(parameter - (simple_identifier) @parameter) - -(parameter_with_optional_type - (simple_identifier) @parameter) - -; lambda parameters -(lambda_literal - (lambda_parameters - (variable_declaration - (simple_identifier) @parameter))) - -;;; Function calls - -; function() -(call_expression - . (simple_identifier) @function) - -; object.function() or object.property.function() -(call_expression - (navigation_expression - (navigation_suffix - (simple_identifier) @function) . )) - -(call_expression - . (simple_identifier) @function.builtin - (#any-of? @function.builtin - "arrayOf" - "arrayOfNulls" - "byteArrayOf" - "shortArrayOf" - "intArrayOf" - "longArrayOf" - "ubyteArrayOf" - "ushortArrayOf" - "uintArrayOf" - "ulongArrayOf" - "floatArrayOf" - "doubleArrayOf" - "booleanArrayOf" - "charArrayOf" - "emptyArray" - "mapOf" - "setOf" - "listOf" - "emptyMap" - "emptySet" - "emptyList" - "mutableMapOf" - "mutableSetOf" - "mutableListOf" - "print" - "println" - "error" - "TODO" - "run" - "runCatching" - "repeat" - "lazy" - "lazyOf" - "enumValues" - "enumValueOf" - "assert" - "check" - "checkNotNull" - "require" - "requireNotNull" - "with" - "suspend" - "synchronized" -)) - -;;; Literals - -[ - (line_comment) - (multiline_comment) - (shebang_line) -] @comment - -(real_literal) @float -[ - (integer_literal) - (long_literal) - (hex_literal) - (bin_literal) - (unsigned_literal) -] @number - -[ - "null" ; should be highlighted the same as booleans - (boolean_literal) -] @boolean - -(character_literal) @character - -(string_literal) @string - -(character_escape_seq) @string.escape - -; There are 3 ways to define a regex -; - "[abc]?".toRegex() -(call_expression - (navigation_expression - ((string_literal) @string.regex) - (navigation_suffix - ((simple_identifier) @_function - (#eq? @_function "toRegex"))))) - -; - Regex("[abc]?") -(call_expression - ((simple_identifier) @_function - (#eq? @_function "Regex")) - (call_suffix - (value_arguments - (value_argument - (string_literal) @string.regex)))) - -; - Regex.fromLiteral("[abc]?") -(call_expression - (navigation_expression - ((simple_identifier) @_class - (#eq? @_class "Regex")) - (navigation_suffix - ((simple_identifier) @_function - (#eq? @_function "fromLiteral")))) - (call_suffix - (value_arguments - (value_argument - (string_literal) @string.regex)))) - -;;; Keywords - -(type_alias "typealias" @keyword) -[ - (class_modifier) - (member_modifier) - (function_modifier) - (property_modifier) - (platform_modifier) - (variance_modifier) - (parameter_modifier) - (visibility_modifier) - (reification_modifier) - (inheritance_modifier) -]@keyword - -[ - "val" - "var" - "enum" - "class" - "object" - "interface" -; "typeof" ; NOTE: It is reserved for future use -] @keyword - -("fun") @keyword.function - -(jump_expression) @keyword.return - -[ - "if" - "else" - "when" -] @conditional - -[ - "for" - "do" - "while" -] @repeat - -[ - "try" - "catch" - "throw" - "finally" -] @exception - - -(annotation - "@" @attribute (use_site_target)? @attribute) -(annotation - (user_type - (type_identifier) @attribute)) -(annotation - (constructor_invocation - (user_type - (type_identifier) @attribute))) - -(file_annotation - "@" @attribute "file" @attribute ":" @attribute) -(file_annotation - (user_type - (type_identifier) @attribute)) -(file_annotation - (constructor_invocation - (user_type - (type_identifier) @attribute))) - -;;; Operators & Punctuation - -[ - "!" - "!=" - "!==" - "=" - "==" - "===" - ">" - ">=" - "<" - "<=" - "||" - "&&" - "+" - "++" - "+=" - "-" - "--" - "-=" - "*" - "*=" - "/" - "/=" - "%" - "%=" - "?." - "?:" - "!!" - "is" - "!is" - "in" - "!in" - "as" - "as?" - ".." - "->" -] @operator - -[ - "(" ")" - "[" "]" - "{" "}" -] @punctuation.bracket - -[ - "." - "," - ";" - ":" - "::" -] @punctuation.delimiter - -; NOTE: `interpolated_identifier`s can be highlighted in any way -(string_literal - "$" @punctuation.special - (interpolated_identifier) @none) -(string_literal - "${" @punctuation.special - (interpolated_expression) @none - "}" @punctuation.special) diff --git a/contrib/python/textual/textual/tree-sitter/highlights/markdown.scm b/contrib/python/textual/textual/tree-sitter/highlights/markdown.scm index 1d6691bcfc6..563c5138f56 100644 --- a/contrib/python/textual/textual/tree-sitter/highlights/markdown.scm +++ b/contrib/python/textual/textual/tree-sitter/highlights/markdown.scm @@ -1,9 +1,51 @@ -(heading_content) @heading -(list_marker) @comment -(strong_emphasis) @bold -(emphasis) @italic -(strikethrough) @strikethrough -(link) @link -(code_span) @inline_code -(info_string) @info_string -(fenced_code_block) @fenced_code_block +(atx_heading (inline) @heading) +(setext_heading (paragraph) @heading) + +[ + (atx_h1_marker) + (atx_h2_marker) + (atx_h3_marker) + (atx_h4_marker) + (atx_h5_marker) + (atx_h6_marker) + (setext_h1_underline) + (setext_h2_underline) +] @heading.marker + +[ + (link_title) + (indented_code_block) + (fenced_code_block) +] @text.literal + +[ + (fenced_code_block_delimiter) +] @punctuation.delimiter + +(code_fence_content) @none + +[ + (link_destination) +] @link.uri + +[ + (link_label) +] @link.label + +[ + (list_marker_plus) + (list_marker_minus) + (list_marker_star) + (list_marker_dot) + (list_marker_parenthesis) + (thematic_break) +] @list.marker + +[ + (block_continuation) + (block_quote_marker) +] @punctuation.special + +[ + (backslash_escape) +] @string.escape diff --git a/contrib/python/textual/textual/tree-sitter/highlights/sql.scm b/contrib/python/textual/textual/tree-sitter/highlights/sql.scm index 03a15fe381f..fe4913026c0 100644 --- a/contrib/python/textual/textual/tree-sitter/highlights/sql.scm +++ b/contrib/python/textual/textual/tree-sitter/highlights/sql.scm @@ -1,114 +1,444 @@ -(string) @string -(number) @number -(comment) @comment +(object_reference + name: (identifier) @type) -(function_call - function: (identifier) @function) +(invocation + (object_reference + name: (identifier) @function.call)) [ - (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 + (keyword_gist) + (keyword_btree) + (keyword_hash) + (keyword_spgist) + (keyword_gin) + (keyword_brin) + (keyword_array) +] @function.call + +(relation + alias: (identifier) @variable) + +(field + name: (identifier) @field) + +(term + alias: (identifier) @variable) + +((term + value: (cast + name: (keyword_cast) @function.call + parameter: [(literal)]?))) + +(comment) @comment @spell +(marginalia) @comment + +((literal) @number + (#match? @number "^[-+]?%d+$")) + +((literal) @float + (#match? @float "^[-+]?%d*\.%d*$")) + +(literal) @string + +(parameter) @parameter + +[ + (keyword_true) + (keyword_false) +] @boolean + +[ + (keyword_asc) + (keyword_desc) + (keyword_terminated) + (keyword_escaped) + (keyword_unsigned) + (keyword_nulls) + (keyword_last) + (keyword_delimited) + (keyword_replication) + (keyword_auto_increment) + (keyword_default) + (keyword_collate) + (keyword_concurrently) + (keyword_engine) + (keyword_always) + (keyword_generated) + (keyword_preceding) + (keyword_following) + (keyword_first) + (keyword_current_timestamp) + (keyword_immutable) + (keyword_atomic) + (keyword_parallel) + (keyword_leakproof) + (keyword_safe) + (keyword_cost) + (keyword_strict) +] @attribute + +[ + (keyword_materialized) + (keyword_recursive) + (keyword_temp) + (keyword_temporary) + (keyword_unlogged) + (keyword_external) + (keyword_parquet) + (keyword_csv) + (keyword_rcfile) + (keyword_textfile) + (keyword_orc) + (keyword_avro) + (keyword_jsonfile) + (keyword_sequencefile) + (keyword_volatile) +] @storageclass + +[ + (keyword_case) + (keyword_when) + (keyword_then) + (keyword_else) +] @conditional + +[ + (keyword_select) + (keyword_from) + (keyword_where) + (keyword_index) + (keyword_join) + (keyword_primary) + (keyword_delete) + (keyword_create) + (keyword_show) + (keyword_insert) + (keyword_merge) + (keyword_distinct) + (keyword_replace) + (keyword_update) + (keyword_into) + (keyword_overwrite) + (keyword_matched) + (keyword_values) + (keyword_value) + (keyword_attribute) + (keyword_set) + (keyword_left) + (keyword_right) + (keyword_outer) + (keyword_inner) + (keyword_full) + (keyword_order) + (keyword_partition) + (keyword_group) + (keyword_with) + (keyword_without) + (keyword_as) + (keyword_having) + (keyword_limit) + (keyword_offset) + (keyword_table) + (keyword_tables) + (keyword_key) + (keyword_references) + (keyword_foreign) + (keyword_constraint) + (keyword_force) + (keyword_use) + (keyword_for) + (keyword_if) + (keyword_exists) + (keyword_column) + (keyword_columns) + (keyword_cross) + (keyword_lateral) + (keyword_natural) + (keyword_alter) + (keyword_drop) + (keyword_add) + (keyword_view) + (keyword_end) + (keyword_is) + (keyword_using) + (keyword_between) + (keyword_window) + (keyword_no) + (keyword_data) + (keyword_type) + (keyword_rename) + (keyword_to) + (keyword_schema) + (keyword_owner) + (keyword_authorization) + (keyword_all) + (keyword_any) + (keyword_some) + (keyword_returning) + (keyword_begin) + (keyword_commit) + (keyword_rollback) + (keyword_transaction) + (keyword_only) + (keyword_like) + (keyword_similar) + (keyword_over) + (keyword_change) + (keyword_modify) + (keyword_after) + (keyword_before) + (keyword_range) + (keyword_rows) + (keyword_groups) + (keyword_exclude) + (keyword_current) + (keyword_ties) + (keyword_others) + (keyword_zerofill) + (keyword_format) + (keyword_fields) + (keyword_row) + (keyword_sort) + (keyword_compute) + (keyword_comment) + (keyword_location) + (keyword_cached) + (keyword_uncached) + (keyword_lines) + (keyword_stored) + (keyword_virtual) + (keyword_partitioned) + (keyword_analyze) + (keyword_explain) + (keyword_verbose) + (keyword_truncate) + (keyword_rewrite) + (keyword_optimize) + (keyword_vacuum) + (keyword_cache) + (keyword_language) + (keyword_called) + (keyword_conflict) + (keyword_declare) + (keyword_filter) + (keyword_function) + (keyword_input) + (keyword_name) + (keyword_oid) + (keyword_oids) + (keyword_precision) + (keyword_regclass) + (keyword_regnamespace) + (keyword_regproc) + (keyword_regtype) + (keyword_restricted) + (keyword_return) + (keyword_returns) + (keyword_separator) + (keyword_setof) + (keyword_stable) + (keyword_support) + (keyword_tblproperties) + (keyword_trigger) + (keyword_unsafe) + (keyword_admin) + (keyword_connection) + (keyword_cycle) + (keyword_database) + (keyword_encrypted) + (keyword_increment) + (keyword_logged) + (keyword_none) + (keyword_owned) + (keyword_password) + (keyword_reset) + (keyword_role) + (keyword_sequence) + (keyword_start) + (keyword_restart) + (keyword_tablespace) + (keyword_until) + (keyword_user) + (keyword_valid) + (keyword_action) + (keyword_definer) + (keyword_invoker) + (keyword_security) + (keyword_extension) + (keyword_version) + (keyword_out) + (keyword_inout) + (keyword_variadic) + (keyword_ordinality) + (keyword_session) + (keyword_isolation) + (keyword_level) + (keyword_serializable) + (keyword_repeatable) + (keyword_read) + (keyword_write) + (keyword_committed) + (keyword_uncommitted) + (keyword_deferrable) + (keyword_names) + (keyword_zone) + (keyword_immediate) + (keyword_deferred) + (keyword_constraints) + (keyword_snapshot) + (keyword_characteristics) + (keyword_off) + (keyword_follows) + (keyword_precedes) + (keyword_each) + (keyword_instead) + (keyword_of) + (keyword_initially) + (keyword_old) + (keyword_new) + (keyword_referencing) + (keyword_statement) + (keyword_execute) + (keyword_procedure) + (keyword_copy) + (keyword_delimiter) + (keyword_encoding) + (keyword_escape) + (keyword_force_not_null) + (keyword_force_null) + (keyword_force_quote) + (keyword_freeze) + (keyword_header) + (keyword_match) + (keyword_program) + (keyword_quote) + (keyword_stdin) + (keyword_extended) + (keyword_main) + (keyword_plain) + (keyword_storage) + (keyword_compression) + (keyword_duplicate) +] @keyword [ - "::" + (keyword_restrict) + (keyword_unbounded) + (keyword_unique) + (keyword_cascade) + (keyword_delayed) + (keyword_high_priority) + (keyword_low_priority) + (keyword_ignore) + (keyword_nothing) + (keyword_check) + (keyword_option) + (keyword_local) + (keyword_cascaded) + (keyword_wait) + (keyword_nowait) + (keyword_metadata) + (keyword_incremental) + (keyword_bin_pack) + (keyword_noscan) + (keyword_stats) + (keyword_statistics) + (keyword_maxvalue) + (keyword_minvalue) +] @type.qualifier + +[ + (keyword_int) + (keyword_null) + (keyword_boolean) + (keyword_binary) + (keyword_varbinary) + (keyword_image) + (keyword_bit) + (keyword_inet) + (keyword_character) + (keyword_smallserial) + (keyword_serial) + (keyword_bigserial) + (keyword_smallint) + (keyword_mediumint) + (keyword_bigint) + (keyword_tinyint) + (keyword_decimal) + (keyword_float) + (keyword_double) + (keyword_numeric) + (keyword_real) + (double) + (keyword_money) + (keyword_smallmoney) + (keyword_char) + (keyword_nchar) + (keyword_varchar) + (keyword_nvarchar) + (keyword_varying) + (keyword_text) + (keyword_string) + (keyword_uuid) + (keyword_json) + (keyword_jsonb) + (keyword_xml) + (keyword_bytea) + (keyword_enum) + (keyword_date) + (keyword_datetime) + (keyword_time) + (keyword_datetime2) + (keyword_datetimeoffset) + (keyword_smalldatetime) + (keyword_timestamp) + (keyword_timestamptz) + (keyword_geometry) + (keyword_geography) + (keyword_box2d) + (keyword_box3d) + (keyword_interval) +] @type.builtin + +[ + (keyword_in) + (keyword_and) + (keyword_or) + (keyword_not) + (keyword_by) + (keyword_on) + (keyword_do) + (keyword_union) + (keyword_except) + (keyword_intersect) +] @keyword.operator + +[ + "+" + "-" + "*" + "/" + "%" + "^" + ":=" + "=" "<" "<=" - "<>" - "=" - ">" + "!=" ">=" + ">" + "<>" + (op_other) + (op_unary_other) ] @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/contrib/python/textual/textual/tree-sitter/highlights/toml.scm b/contrib/python/textual/textual/tree-sitter/highlights/toml.scm index 26e3982fef4..58ba6eb2d3e 100644 --- a/contrib/python/textual/textual/tree-sitter/highlights/toml.scm +++ b/contrib/python/textual/textual/tree-sitter/highlights/toml.scm @@ -24,7 +24,7 @@ "." @punctuation.delimiter "," @punctuation.delimiter -"=" @toml.operator +"=" @operator "[" @punctuation.bracket "]" @punctuation.bracket diff --git a/contrib/python/textual/textual/tree-sitter/highlights/xml.scm b/contrib/python/textual/textual/tree-sitter/highlights/xml.scm new file mode 100644 index 00000000000..9861eea1784 --- /dev/null +++ b/contrib/python/textual/textual/tree-sitter/highlights/xml.scm @@ -0,0 +1,168 @@ +;; XML declaration + +"xml" @keyword + +[ "version" "encoding" "standalone" ] @property + +(EncName) @string.special + +(VersionNum) @number + +[ "yes" "no" ] @boolean + +;; Processing instructions + +(PI) @embedded + +(PI (PITarget) @keyword) + +;; Element declaration + +(elementdecl + "ELEMENT" @keyword + (Name) @tag) + +(contentspec + (_ (Name) @property)) + +"#PCDATA" @type.builtin + +[ "EMPTY" "ANY" ] @string.special.symbol + +[ "*" "?" "+" ] @operator + +;; Entity declaration + +(GEDecl + "ENTITY" @keyword + (Name) @constant) + +(GEDecl (EntityValue) @string) + +(NDataDecl + "NDATA" @keyword + (Name) @label) + +;; Parsed entity declaration + +(PEDecl + "ENTITY" @keyword + "%" @operator + (Name) @constant) + +(PEDecl (EntityValue) @string) + +;; Notation declaration + +(NotationDecl + "NOTATION" @keyword + (Name) @constant) + +(NotationDecl + (ExternalID + (SystemLiteral (URI) @string.special))) + +;; Attlist declaration + +(AttlistDecl + "ATTLIST" @keyword + (Name) @tag) + +(AttDef (Name) @property) + +(AttDef (Enumeration (Nmtoken) @string)) + +(DefaultDecl (AttValue) @string) + +[ + (StringType) + (TokenizedType) +] @type.builtin + +(NotationType "NOTATION" @type.builtin) + +[ + "#REQUIRED" + "#IMPLIED" + "#FIXED" +] @attribute + +;; Entities + +(EntityRef) @constant + +((EntityRef) @constant.builtin + (#any-of? @constant.builtin + "&" "<" ">" """ "'")) + +(CharRef) @constant + +(PEReference) @constant + +;; External references + +[ "PUBLIC" "SYSTEM" ] @keyword + +(PubidLiteral) @string.special + +(SystemLiteral (URI) @markup.link) + +;; Processing instructions + +(XmlModelPI "xml-model" @keyword) + +(StyleSheetPI "xml-stylesheet" @keyword) + +(PseudoAtt (Name) @property) + +(PseudoAtt (PseudoAttValue) @string) + +;; Doctype declaration + +(doctypedecl "DOCTYPE" @keyword) + +(doctypedecl (Name) @type) + +;; Tags + +(STag (Name) @tag) + +(ETag (Name) @tag) + +(EmptyElemTag (Name) @tag) + +;; Attributes + +(Attribute (Name) @property) + +(Attribute (AttValue) @string) + +;; Delimiters & punctuation + +[ + "<?" "?>" + "<!" "]]>" + "<" ">" + "</" "/>" +] @punctuation.delimiter + +[ "(" ")" "[" "]" ] @punctuation.bracket + +[ "\"" "'" ] @punctuation.delimiter + +[ "," "|" "=" ] @operator + +;; Text + +(CharData) @markup + +(CDSect + (CDStart) @markup.heading + (CData) @markup.raw + "]]>" @markup.heading) + +;; Misc + +(Comment) @comment + +(ERROR) @error diff --git a/contrib/python/textual/textual/visual.py b/contrib/python/textual/textual/visual.py index ed21c26fe1c..29303b8d3a5 100644 --- a/contrib/python/textual/textual/visual.py +++ b/contrib/python/textual/textual/visual.py @@ -13,6 +13,7 @@ from rich.measure import Measurement from rich.protocol import is_renderable, rich_cast from rich.segment import Segment from rich.style import Style as RichStyle +from rich.terminal_theme import TerminalTheme from rich.text import Text from textual._context import active_app @@ -141,18 +142,21 @@ class Style: return new_style @classmethod - def from_rich_style(cls, rich_style: RichStyle) -> Style: + def from_rich_style( + cls, rich_style: RichStyle, theme: TerminalTheme | None = None + ) -> Style: """Build a Style from a (Rich) Style. Args: rich_style: A Rich Style object. + theme: Optional Rich [terminal theme][rich.terminal_theme.TerminalTheme]. Returns: New Style. """ return Style( - Color.from_rich_color(rich_style.bgcolor), - Color.from_rich_color(rich_style.color), + Color.from_rich_color(rich_style.bgcolor, theme), + Color.from_rich_color(rich_style.color, theme), bold=rich_style.bold, dim=rich_style.dim, italic=rich_style.italic, diff --git a/contrib/python/textual/textual/widget.py b/contrib/python/textual/textual/widget.py index 827288b0422..a543aee836b 100644 --- a/contrib/python/textual/textual/widget.py +++ b/contrib/python/textual/textual/widget.py @@ -593,7 +593,7 @@ class Widget(DOMNode): @property def is_anchored(self) -> bool: """Is this widget anchored?""" - return self._parent is not None and self._parent is self + return isinstance(self._parent, Widget) and self._parent._anchored is self @property def is_mouse_over(self) -> bool: @@ -2446,6 +2446,21 @@ class Widget(DOMNode): """ + def set_scroll(self, x: float | None, y: float | None) -> None: + """Set the scroll position without any validation. + + This is a low-level method for when you want to see the scroll position in the next frame. + For a more fully featured method, see [`scroll_to`][textual.widget.Widget.scroll_to]. + + Args: + x: Desired `X` coordinate. + y: Desired `Y` coordinate. + """ + if x is not None: + self.set_reactive(Widget.scroll_x, round(x)) + if y is not None: + self.set_reactive(Widget.scroll_y, round(y)) + def scroll_to( self, x: float | None = None, @@ -3680,8 +3695,9 @@ class Widget(DOMNode): layout: Perform layout if required. Returns: - True if anything changed, or False if nothing changed. + True if a resize event should be sent, otherwise False. """ + self._layout_cache.clear() if ( self._size != size @@ -4065,9 +4081,7 @@ class Widget(DOMNode): self._check_refresh() if self.is_anchored: - self.scroll_visible(animate=self._anchor_animate) - if self._anchored: - self._anchored.scroll_visible(animate=self._anchor_animate) + self.scroll_visible(animate=self._anchor_animate, immediate=True) def _check_refresh(self) -> None: """Check if a refresh was requested.""" diff --git a/contrib/python/textual/textual/widgets/_digits.py b/contrib/python/textual/textual/widgets/_digits.py index b85fe275257..86492ebe8f8 100644 --- a/contrib/python/textual/textual/widgets/_digits.py +++ b/contrib/python/textual/textual/widgets/_digits.py @@ -6,6 +6,7 @@ from rich.align import Align, AlignMethod if TYPE_CHECKING: from textual.app import RenderResult + from textual.geometry import Size from textual.renderables.digits import Digits as DigitsRenderable from textual.widget import Widget @@ -59,7 +60,7 @@ class Digits(Widget): value: New value to display. Raises: - ValueError: If the value isn't a `str`. + TypeError: If the value isn't a `str`. """ if not isinstance(value, str): raise TypeError("value must be a str") diff --git a/contrib/python/textual/textual/widgets/_footer.py b/contrib/python/textual/textual/widgets/_footer.py index 142a7512806..7cc543bbe9d 100644 --- a/contrib/python/textual/textual/widgets/_footer.py +++ b/contrib/python/textual/textual/widgets/_footer.py @@ -47,12 +47,7 @@ class FooterKey(Widget): } &.-disabled { - text-style: dim; - &:hover { - .footer-key--key { - background: $foreground-disabled; - } - } + text-style: dim; } &.-compact { @@ -207,9 +202,10 @@ class Footer(ScrollableContainer, can_focus=False, can_focus_children=False): def compose(self) -> ComposeResult: if not self._bindings_ready: return + active_bindings = self.screen.active_bindings bindings = [ (binding, enabled, tooltip) - for (_, binding, enabled, tooltip) in self.screen.active_bindings.values() + for (_, binding, enabled, tooltip) in active_bindings.values() if binding.show ] action_to_bindings: defaultdict[str, list[tuple[Binding, bool, str]]] @@ -229,20 +225,22 @@ class Footer(ScrollableContainer, can_focus=False, can_focus_children=False): tooltip=tooltip, ).data_bind(Footer.compact) if self.show_command_palette and self.app.ENABLE_COMMAND_PALETTE: - for key, binding in self.app._bindings: - if binding.action in ( - "app.command_palette", - "command_palette", - ): - yield FooterKey( - key, - self.app.get_key_display(binding), - binding.description, - binding.action, - classes="-command-palette", - tooltip=binding.tooltip or binding.description, - ) - break + try: + _node, binding, enabled, tooltip = active_bindings[ + self.app.COMMAND_PALETTE_BINDING + ] + except KeyError: + pass + else: + yield FooterKey( + binding.key, + self.app.get_key_display(binding), + binding.description, + binding.action, + classes="-command-palette", + disabled=not enabled, + tooltip=binding.tooltip or binding.description, + ) async def bindings_changed(self, screen: Screen) -> None: self._bindings_ready = True diff --git a/contrib/python/textual/textual/widgets/_list_view.py b/contrib/python/textual/textual/widgets/_list_view.py index e2363a80f84..b8c36bdd4cb 100644 --- a/contrib/python/textual/textual/widgets/_list_view.py +++ b/contrib/python/textual/textual/widgets/_list_view.py @@ -5,6 +5,7 @@ from typing import ClassVar, Iterable, Optional from typing_extensions import TypeGuard from textual._loop import loop_from_index +from textual.await_complete import AwaitComplete from textual.await_remove import AwaitRemove from textual.binding import Binding, BindingType from textual.containers import VerticalScroll @@ -30,10 +31,6 @@ class ListView(VerticalScroll, can_focus=True, can_focus_children=False): DEFAULT_CSS = """ ListView { background: $surface; - &:focus-within { - background-tint: $foreground 5%; - } - & > ListItem { color: $foreground; height: auto; @@ -51,12 +48,15 @@ class ListView(VerticalScroll, can_focus=True, can_focus_children=False): } } - &:focus > ListItem.-highlight > Widget { - width: 1fr; - color: $block-cursor-foreground; - background: $block-cursor-background; - text-style: $block-cursor-text-style; + &:focus { + background-tint: $foreground 5%; + & > ListItem.-highlight { + color: $block-cursor-foreground; + background: $block-cursor-background; + text-style: $block-cursor-text-style; + } } + } """ @@ -281,7 +281,7 @@ class ListView(VerticalScroll, can_focus=True, can_focus_children=False): await_mount = self.mount(*items, before=index) return await_mount - def pop(self, index: Optional[int] = None) -> AwaitRemove: + def pop(self, index: Optional[int] = None) -> AwaitComplete: """Remove last ListItem from ListView or Remove ListItem from ListView by index @@ -292,13 +292,31 @@ class ListView(VerticalScroll, can_focus=True, can_focus_children=False): An awaitable that yields control to the event loop until the DOM has been updated to reflect item being removed. """ - if index is None: - await_remove = self.query("ListItem").last().remove() - else: - await_remove = self.query("ListItem")[index].remove() - return await_remove - - def remove_items(self, indices: Iterable[int]) -> AwaitRemove: + if len(self) == 0: + raise IndexError("pop from empty list") + + index = index if index is not None else -1 + item_to_remove = self.query("ListItem")[index] + normalized_index = index if index >= 0 else index + len(self) + + async def do_pop() -> None: + """Remove the item and update the highlighted index.""" + await item_to_remove.remove() + if self.index is not None: + if normalized_index < self.index: + self.index -= 1 + elif normalized_index == self.index: + old_index = self.index + # Force a re-validation of the index + self.index = self.index + # If the index hasn't changed, the watcher won't be called + # but we need to update the highlighted item + if old_index == self.index: + self.watch_index(old_index, self.index) + + return AwaitComplete(do_pop()) + + def remove_items(self, indices: Iterable[int]) -> AwaitComplete: """Remove ListItems from ListView by indices Args: @@ -309,8 +327,29 @@ class ListView(VerticalScroll, can_focus=True, can_focus_children=False): """ items = self.query("ListItem") items_to_remove = [items[index] for index in indices] - await_remove = self.remove_children(items_to_remove) - return await_remove + normalized_indices = set( + index if index >= 0 else index + len(self) for index in indices + ) + + async def do_remove_items() -> None: + """Remove the items and update the highlighted index.""" + await self.remove_children(items_to_remove) + if self.index is not None: + removed_before_highlighted = sum( + 1 for index in normalized_indices if index < self.index + ) + if removed_before_highlighted: + self.index -= removed_before_highlighted + elif self.index in normalized_indices: + old_index = self.index + # Force a re-validation of the index + self.index = self.index + # If the index hasn't changed, the watcher won't be called + # but we need to update the highlighted item + if old_index == self.index: + self.watch_index(old_index, self.index) + + return AwaitComplete(do_remove_items()) def action_select_cursor(self) -> None: """Select the current item in the list.""" diff --git a/contrib/python/textual/textual/widgets/_loading_indicator.py b/contrib/python/textual/textual/widgets/_loading_indicator.py index 7a8cb43bf84..355e458f01b 100644 --- a/contrib/python/textual/textual/widgets/_loading_indicator.py +++ b/contrib/python/textual/textual/widgets/_loading_indicator.py @@ -8,8 +8,10 @@ from rich.text import Text if TYPE_CHECKING: from textual.app import RenderResult + +from textual import on from textual.color import Gradient -from textual.events import Mount +from textual.events import InputEvent, Mount from textual.widget import Widget @@ -56,6 +58,12 @@ class LoadingIndicator(Widget): self._start_time = time() self.auto_refresh = 1 / 16 + @on(InputEvent) + def on_input(self, event: InputEvent) -> None: + """Prevent all input events from bubbling, thus disabling widgets in a loading state.""" + event.stop() + event.prevent_default() + def render(self) -> RenderResult: if self.app.animation_level == "none": return Text("Loading...") diff --git a/contrib/python/textual/textual/widgets/_option_list.py b/contrib/python/textual/textual/widgets/_option_list.py index 963f521d26a..6aeac230ce2 100644 --- a/contrib/python/textual/textual/widgets/_option_list.py +++ b/contrib/python/textual/textual/widgets/_option_list.py @@ -195,7 +195,7 @@ class OptionList(ScrollView, can_focus=True): | `option-list--separator` | Target the separators. | """ - highlighted: reactive[int | None] = reactive["int | None"](None) + highlighted: reactive[int | None] = reactive(None) """The index of the currently-highlighted option, or `None` if no option is highlighted.""" class OptionMessage(Message): @@ -295,7 +295,7 @@ class OptionList(ScrollView, can_focus=True): """A dictionary of option IDs and the option indexes they relate to.""" self._content_render_cache: LRUCache[tuple[int, str, int], list[Strip]] - self._content_render_cache = LRUCache(256) + self._content_render_cache = LRUCache(1024) self._lines: list[tuple[int, int]] | None = None self._spans: list[OptionLineSpan] | None = None @@ -324,7 +324,7 @@ class OptionList(ScrollView, can_focus=True): self._lines = None self._spans = None self._content_render_cache.clear() - self.check_idle() + self._populate() def notify_style_update(self) -> None: self._content_render_cache.clear() @@ -332,10 +332,6 @@ class OptionList(ScrollView, can_focus=True): def _on_resize(self): self._refresh_lines() - def on_idle(self): - if self._lines is None: - self._populate() - def _add_lines( self, new_content: list[OptionListContent], width: int, option_index=0 ) -> None: @@ -366,11 +362,12 @@ class OptionList(ScrollView, can_focus=True): self._lines.append(OptionLineSpan(-1, 0)) self.virtual_size = Size(width, len(self._lines)) + self.refresh(layout=self.styles.auto_dimensions) + self._scroll_update(self.virtual_size) def _populate(self) -> None: """Populate the lines data-structure.""" - if self._lines is not None: - return + self._lines = [] self._spans = [] @@ -378,15 +375,19 @@ class OptionList(ScrollView, can_focus=True): self._contents, self.scrollable_content_region.width - self._left_gutter_width(), ) - self.refresh() def get_content_width(self, container: Size, viewport: Size) -> int: """Get maximum width of options.""" console = self.app.console options = console.options - return max( - Measurement.get(console, options, option.prompt).maximum - for option in self._options + padding = self.get_component_styles("option-list--option").padding + padding_width = padding.width + return ( + max( + Measurement.get(console, options, option.prompt).maximum + for option in self._options + ) + + padding_width ) def get_content_height(self, container: Size, viewport: Size, width: int) -> int: @@ -550,7 +551,7 @@ class OptionList(ScrollView, can_focus=True): self.scrollable_content_region.width - self._left_gutter_width(), option_index=option_index, ) - self.refresh() + self.refresh(layout=True) return self def add_option(self, item: NewOptionListContent = None) -> Self: @@ -823,8 +824,9 @@ class OptionList(ScrollView, can_focus=True): ) from None def render_line(self, y: int) -> Strip: - self._populate() assert self._lines is not None + if not self._lines: + self._populate() _scroll_x, scroll_y = self.scroll_offset line_number = scroll_y + y @@ -878,9 +880,13 @@ class OptionList(ScrollView, can_focus=True): top: Scroll highlight to top of the list. """ highlighted = self.highlighted - if highlighted is None or self._spans is None: + + if highlighted is None or not self.is_mounted: return + if not self._spans: + self._populate() + try: y, height = self._spans[highlighted] except IndexError: @@ -893,8 +899,13 @@ class OptionList(ScrollView, can_focus=True): force=True, animate=False, top=top, + immediate=True, ) + def on_show(self) -> None: + if self.highlighted is not None: + self.scroll_to_highlight() + def validate_highlighted(self, highlighted: int | None) -> int | None: """Validate the `highlighted` property value on access.""" if highlighted is None or not self._options: @@ -952,7 +963,6 @@ class OptionList(ScrollView, can_focus=True): # If we find ourselves in a position where we don't know where we're # going, we need a fallback location. Where we go will depend on the # direction. - self._populate() assert self._spans is not None assert self._lines is not None diff --git a/contrib/python/textual/textual/widgets/_select.py b/contrib/python/textual/textual/widgets/_select.py index 8d651cbe446..fa9af348c38 100644 --- a/contrib/python/textual/textual/widgets/_select.py +++ b/contrib/python/textual/textual/widgets/_select.py @@ -66,7 +66,7 @@ class SelectOverlay(OptionList): index: Index of new selection. """ self.highlighted = index - self.scroll_to_highlight(top=True) + self.scroll_to_highlight() def action_dismiss(self) -> None: """Dismiss the overlay.""" @@ -234,11 +234,9 @@ class Select(Generic[SelectType], Vertical, can_focus=True): .down-arrow { display: none; } - .up-arrow { display: block; } - & > SelectOverlay { display: block; } @@ -380,6 +378,18 @@ class Select(Generic[SelectType], Vertical, can_focus=True): disabled=disabled, ) + @property + def selection(self) -> SelectType | None: + """The currently selected item. + + Unlike [value][textual.widgets.Select.value], this will not return Blanks. + If nothing is selected, this will return `None`. + + """ + value = self.value + assert not isinstance(value, NoSelection) + return value + def _setup_variables_for_options( self, options: Iterable[tuple[RenderableType, SelectType]], @@ -404,7 +414,7 @@ class Select(Generic[SelectType], Vertical, can_focus=True): def _setup_options_renderables(self) -> None: """Sets up the `Option` renderables associated with the `Select` options.""" - self._select_options: list[Option] = [ + options: list[Option] = [ ( Option(Text(self.prompt, style="dim")) if value == self.BLANK @@ -415,8 +425,7 @@ class Select(Generic[SelectType], Vertical, can_focus=True): option_list = self.query_one(SelectOverlay) option_list.clear_options() - for option in self._select_options: - option_list.add_option(option) + option_list.add_options(options) def _init_selected_option(self, hint: SelectType | NoSelection = BLANK) -> None: """Initialises the selected option for the `Select`.""" @@ -503,7 +512,7 @@ class Select(Generic[SelectType], Vertical, can_focus=True): return self.set_class(expanded, "-expanded") if expanded: - overlay.focus() + overlay.focus(scroll_visible=False) if self.value is self.BLANK: overlay.select(None) self.query_one(SelectCurrent).has_value = False @@ -511,7 +520,7 @@ class Select(Generic[SelectType], Vertical, can_focus=True): value = self.value for index, (_prompt, prompt_value) in enumerate(self._options): if value == prompt_value: - overlay.select(index) + self.call_after_refresh(overlay.select, index) break self.query_one(SelectCurrent).has_value = True diff --git a/contrib/python/textual/textual/widgets/_selection_list.py b/contrib/python/textual/textual/widgets/_selection_list.py index 7493990f2e9..1d147377cf0 100644 --- a/contrib/python/textual/textual/widgets/_selection_list.py +++ b/contrib/python/textual/textual/widgets/_selection_list.py @@ -50,7 +50,8 @@ class Selection(Generic[SelectionType], Option): disabled: The initial enabled/disabled state. Enabled by default. """ if isinstance(prompt, str): - prompt = Text.from_markup(prompt) + prompt = Text.from_markup(prompt, overflow="ellipsis") + prompt.no_wrap = True super().__init__(prompt.split()[0], id, disabled) self._value: SelectionType = value """The value associated with the selection.""" diff --git a/contrib/python/textual/textual/widgets/_tabbed_content.py b/contrib/python/textual/textual/widgets/_tabbed_content.py index 37630b05dc1..2d625e7cd0c 100644 --- a/contrib/python/textual/textual/widgets/_tabbed_content.py +++ b/contrib/python/textual/textual/widgets/_tabbed_content.py @@ -332,6 +332,7 @@ class TabbedContent(Widget): self.titles = [self.render_str(title) for title in titles] self._tab_content: list[Widget] = [] self._initial = initial + self._tab_counter = 0 super().__init__(name=name, id=id, classes=classes, disabled=disabled) @property @@ -357,6 +358,15 @@ class TabbedContent(Widget): content.id = f"tab-{new_id}" return content + def _generate_tab_id(self) -> int: + """Auto generate a new tab id. + + Returns: + An auto-incrementing integer. + """ + self._tab_counter += 1 + return self._tab_counter + def compose(self) -> ComposeResult: """Compose the tabbed content.""" @@ -368,7 +378,7 @@ class TabbedContent(Widget): if isinstance(content, TabPane) else TabPane(title or self.render_str(f"Tab {index}"), content) ), - index, + self._generate_tab_id(), ) for index, (title, content) in enumerate( zip_longest(self.titles, self._tab_content), 1 @@ -424,7 +434,7 @@ class TabbedContent(Widget): if isinstance(after, TabPane): after = after.id tabs = self.get_child_by_type(ContentTabs) - pane = self._set_id(pane, tabs.tab_count + 1) + pane = self._set_id(pane, self._generate_tab_id()) assert pane.id is not None pane.display = False return AwaitComplete( diff --git a/contrib/python/textual/textual/widgets/_text_area.py b/contrib/python/textual/textual/widgets/_text_area.py index 6540889c6fc..e4982103362 100644 --- a/contrib/python/textual/textual/widgets/_text_area.py +++ b/contrib/python/textual/textual/widgets/_text_area.py @@ -14,7 +14,7 @@ from rich.text import Text from typing_extensions import Literal from textual._text_area_theme import TextAreaTheme -from textual._tree_sitter import TREE_SITTER +from textual._tree_sitter import BUILTIN_LANGUAGES, TREE_SITTER from textual.color import Color from textual.document._document import ( Document, @@ -27,7 +27,6 @@ from textual.document._document import ( from textual.document._document_navigator import DocumentNavigator from textual.document._edit import Edit from textual.document._history import EditHistory -from textual.document._languages import BUILTIN_LANGUAGES from textual.document._syntax_aware_document import ( SyntaxAwareDocument, SyntaxAwareDocumentError, @@ -36,7 +35,7 @@ from textual.document._wrapped_document import WrappedDocument from textual.expand_tabs import expand_tabs_inline, expand_text_tabs_from_widths if TYPE_CHECKING: - from tree_sitter import Language + from tree_sitter import Language, Query from textual import events, log from textual._cells import cell_len, cell_width_to_column_index @@ -415,6 +414,13 @@ TextArea { self._languages: dict[str, TextAreaLanguage] = {} """Maps language names to TextAreaLanguage.""" + for language_name, language_object in BUILTIN_LANGUAGES.items(): + self._languages[language_name] = TextAreaLanguage( + language_name, + language_object, + self._get_builtin_highlight_query(language_name), + ) + self._themes: dict[str, TextAreaTheme] = {} """Maps theme names to TextAreaTheme.""" @@ -583,26 +589,28 @@ TextArea { 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) - ) + for highlight_name, nodes in captures.items(): + for node in nodes: + 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 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)) + # Add the last line of the node range + highlights[node_end_row].append( + (0, node_end_column, highlight_name) + ) def _watch_has_focus(self, focus: bool) -> None: self._cursor_visible = focus @@ -721,6 +729,9 @@ TextArea { def _watch_language(self, language: str | None) -> None: """When the language is updated, update the type of document.""" + if not TREE_SITTER: + return + 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. " @@ -830,7 +841,8 @@ TextArea { def register_language( self, - language: "str | Language", + name: str, + language: "Language", highlight_query: str, ) -> None: """Register a language and corresponding highlight query. @@ -845,34 +857,29 @@ TextArea { 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. + name: The name of the language. + language: 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 + self._languages[name] = TextAreaLanguage(name, language, highlight_query) - from tree_sitter_languages import get_language - - if isinstance(language, str): - language_name = language - language = get_language(language_name) - else: - language_name = language.name + def update_highlight_query(self, name: str, highlight_query: str) -> None: + """Update the highlight query for an already registered language. - # 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) + Args: + name: The name of the language. + highlight_query: The highlight query to use for syntax highlighting this language. + """ + if name not in self._languages: + raise LanguageDoesNotExist( + f"{name!r} is not a registered language.\n" + f"To register a language, call `TextArea.register_language`." + ) + self._languages[name].highlight_query = highlight_query + if name == self.language: + self._set_document(self.text, name) def _set_document(self, text: str, language: str | None) -> None: """Construct and return an appropriate document. diff --git a/contrib/python/textual/textual/widgets/_toast.py b/contrib/python/textual/textual/widgets/_toast.py index 1fc87648ae1..8111a1538fa 100644 --- a/contrib/python/textual/textual/widgets/_toast.py +++ b/contrib/python/textual/textual/widgets/_toast.py @@ -150,7 +150,7 @@ class ToastRack(Container, inherit_css=False): layer: _toastrack; width: 1fr; height: auto; - dock: top; + dock: bottom; align: right bottom; visibility: hidden; layout: vertical; diff --git a/contrib/python/textual/textual/widgets/_toggle_button.py b/contrib/python/textual/textual/widgets/_toggle_button.py index 34a40d6bea2..865dca3ea50 100644 --- a/contrib/python/textual/textual/widgets/_toggle_button.py +++ b/contrib/python/textual/textual/widgets/_toggle_button.py @@ -84,7 +84,7 @@ class ToggleButton(Static, can_focus=True): } } } - """ # TODO: https://github.com/Textualize/textual/issues/1780 + """ BUTTON_LEFT: str = "β" """The character used for the left side of the toggle button.""" diff --git a/contrib/python/textual/textual/widgets/_tree.py b/contrib/python/textual/textual/widgets/_tree.py index 79c64be2244..40f73ba6b7b 100644 --- a/contrib/python/textual/textual/widgets/_tree.py +++ b/contrib/python/textual/textual/widgets/_tree.py @@ -374,7 +374,7 @@ class TreeNode(Generic[TreeDataType]): before: Optional index or `TreeNode` to add the node before. after: Optional index or `TreeNode` to add the node after. expand: Node should be expanded. - allow_expand: Allow use to expand the node via keyboard or mouse. + allow_expand: Allow user to expand the node via keyboard or mouse. Returns: A new Tree node @@ -1525,6 +1525,8 @@ class Tree(Generic[TreeDataType], ScrollView, can_focus=True): will cause both an expand/collapse event to occur, as well as a selected event. """ + if self.cursor_line < 0: + return try: line = self._tree_lines[self.cursor_line] except IndexError: diff --git a/contrib/python/textual/textual/widgets/text_area.py b/contrib/python/textual/textual/widgets/text_area.py index 058f8235420..1d4621a78b8 100644 --- a/contrib/python/textual/textual/widgets/text_area.py +++ b/contrib/python/textual/textual/widgets/text_area.py @@ -1,4 +1,5 @@ from textual._text_area_theme import TextAreaTheme +from textual._tree_sitter import BUILTIN_LANGUAGES from textual.document._document import ( Document, DocumentBase, @@ -9,7 +10,6 @@ from textual.document._document import ( from textual.document._document_navigator import DocumentNavigator from textual.document._edit import Edit from textual.document._history import EditHistory -from textual.document._languages import BUILTIN_LANGUAGES from textual.document._syntax_aware_document import SyntaxAwareDocument from textual.document._wrapped_document import WrappedDocument from textual.widgets._text_area import ( diff --git a/contrib/python/textual/ya.make b/contrib/python/textual/ya.make index 8a8ef5f7b3b..21a6be3c442 100644 --- a/contrib/python/textual/ya.make +++ b/contrib/python/textual/ya.make @@ -2,7 +2,7 @@ PY3_LIBRARY() -VERSION(0.86.3) +VERSION(0.89.1) LICENSE(MIT) @@ -119,6 +119,7 @@ PY_SRCS( textual/demo/__main__.py textual/demo/data.py textual/demo/demo_app.py + textual/demo/game.py textual/demo/home.py textual/demo/page.py textual/demo/projects.py @@ -129,7 +130,6 @@ PY_SRCS( textual/document/_document_navigator.py textual/document/_edit.py textual/document/_history.py - textual/document/_languages.py textual/document/_syntax_aware_document.py textual/document/_wrapped_document.py textual/dom.py @@ -273,13 +273,13 @@ RESOURCE_FILES( textual/tree-sitter/highlights/java.scm textual/tree-sitter/highlights/javascript.scm textual/tree-sitter/highlights/json.scm - textual/tree-sitter/highlights/kotlin.scm textual/tree-sitter/highlights/markdown.scm textual/tree-sitter/highlights/python.scm textual/tree-sitter/highlights/regex.scm textual/tree-sitter/highlights/rust.scm textual/tree-sitter/highlights/sql.scm textual/tree-sitter/highlights/toml.scm + textual/tree-sitter/highlights/xml.scm textual/tree-sitter/highlights/yaml.scm ) |
