Merge branch 'main' into screen-title-sub-title

This commit is contained in:
Rodrigo Girão Serrão
2023-09-11 10:31:29 +01:00
47 changed files with 1380 additions and 71 deletions

View File

@@ -17,7 +17,7 @@ jobs:
strategy:
matrix:
os: [ubuntu-latest, macos-latest, windows-latest]
python-version: ["3.7", "3.8", "3.9", "3.10", "3.11"]
python-version: ["3.7", "3.8", "3.9", "3.10", "3.11", "3.12"]
defaults:
run:
shell: bash
@@ -33,6 +33,7 @@ jobs:
with:
python-version: ${{ matrix.python-version }}
architecture: x64
allow-prereleases: true
- name: Load cached venv
id: cached-poetry-dependencies
uses: actions/cache@v3

View File

@@ -9,7 +9,6 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
### Added
- TCSS styles `layer` and `layers` can be strings https://github.com/Textualize/textual/pull/3169
- Screen-specific (sub-)title attributes https://github.com/Textualize/textual/pull/3199:
- `Screen.TITLE`
- `Screen.SUB_TITLE`
@@ -17,16 +16,32 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
- `Screen.sub_title`
- Properties `Header.screen_title` and `Header.screen_sub_title` https://github.com/Textualize/textual/pull/3199
### Fixed
- Fixed a crash when removing an option from an `OptionList` while the mouse is hovering over the last option https://github.com/Textualize/textual/issues/3270
## [0.36.0] - 2023-09-05
### Added
- TCSS styles `layer` and `layers` can be strings https://github.com/Textualize/textual/pull/3169
- `App.return_code` for the app return code https://github.com/Textualize/textual/pull/3202
- Added `animate` switch to `Tree.scroll_to_line` and `Tree.scroll_to_node` https://github.com/Textualize/textual/pull/3210
- Added `Rule` widget https://github.com/Textualize/textual/pull/3209
- Added App.current_mode to get the current mode https://github.com/Textualize/textual/pull/3233
### Changed
- Reactive callbacks are now scheduled on the message pump of the reactable that is watching instead of the owner of reactive attribute https://github.com/Textualize/textual/pull/3065
- Callbacks scheduled with `call_next` will now have the same prevented messages as when the callback was scheduled https://github.com/Textualize/textual/pull/3065
- Added `cursor_type` to the `DataTable` constructor.
- Fixed `push_screen` not updating Screen.CSS styles https://github.com/Textualize/textual/issues/3217
### Fixed
- Fixed flicker when calling pop_screen multiple times https://github.com/Textualize/textual/issues/3126
- Fixed setting styles.layout not updating https://github.com/Textualize/textual/issues/3047
- Fixed flicker when scrolling tree up or down a line https://github.com/Textualize/textual/issues/3206
## [0.35.1]
@@ -78,6 +93,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
- Fixed background refresh https://github.com/Textualize/textual/issues/3055
- Fixed `SelectionList.clear_options` https://github.com/Textualize/textual/pull/3075
- `MouseMove` events bubble up from widgets. `App` and `Screen` receive `MouseMove` events even if there's no Widget under the cursor. https://github.com/Textualize/textual/issues/2905
- Fixed click on double-width char https://github.com/Textualize/textual/issues/2968
### Changed
@@ -1246,6 +1262,7 @@ https://textual.textualize.io/blog/2022/11/08/version-040/#version-040
- New handler system for messages that doesn't require inheritance
- Improved traceback handling
[0.36.0]: https://github.com/Textualize/textual/compare/v0.35.1...v0.36.0
[0.35.1]: https://github.com/Textualize/textual/compare/v0.35.0...v0.35.1
[0.35.0]: https://github.com/Textualize/textual/compare/v0.34.0...v0.35.0
[0.34.0]: https://github.com/Textualize/textual/compare/v0.33.0...v0.34.0

View File

@@ -0,0 +1,45 @@
---
draft: false
date: 2023-09-06
categories:
- News
title: "What is Textual Web?"
authors:
- willmcgugan
---
# What is Textual Web?
If you know us, you will know that we are the team behind [Rich](https://github.com/Textualize/rich) and [Textual](https://github.com/Textualize/textual) — two popular Python libraries that work magic in the terminal.
!!! note
Not to mention [Rich-CLI](https://github.com/Textualize/rich-cli), [Trogon](https://github.com/Textualize/trogon), and [Frogmouth](https://github.com/Textualize/frogmouth)
Today we are adding one project more to that lineup: [textual-web](https://github.com/Textualize/textual-web).
<!-- more -->
Textual Web takes a Textual-powered TUI and turns it in to a web application.
Here's a video of that in action:
<div class="video-wrapper">
<iframe width="auto" src="https://www.youtube.com/embed/A8k8TD7_wg0" title="Textual Web in action" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" allowfullscreen></iframe>
</div>
With the `textual-web` command you can publish any Textual app on the web, making it available to anyone you send the URL to.
This works without creating a socket server on your machine, so you won't have to configure firewalls and ports to share your applications.
We're excited about the possibilities here.
Textual web apps are fast to spin up and tear down, and they can run just about anywhere that has an outgoing internet connection.
They can be built by a single developer without any experience with a traditional web stack.
All you need is proficiency in Python and a little time to read our [lovely docs](https://textual.textualize.io/).
Future releases will expose more of the Web platform APIs to Textual apps, such as notifications and file system access.
We plan to do this in a way that allows the same (Python) code to drive those features.
For instance, a Textual app might save a file to disk in a terminal, but offer to download it in the browser.
Also in the pipeline is [PWA](https://en.wikipedia.org/wiki/Progressive_web_app) support, so you can build terminal apps, web apps, and desktop apps with a single codebase.
Textual Web is currently in a public beta. Join our [Discord server](https://discord.gg/Enf6Z3qhVr) if you would like to help us test, or if you have any questions.

View File

@@ -12,3 +12,9 @@ _No other attributes_
## Code
::: textual.events.Blur
## See also
- [DescendantBlur](descendant_blur.md)
- [DescendantFocus](descendant_focus.md)
- [Focus](focus.md)

View File

@@ -12,3 +12,9 @@ _No other attributes_
## Code
::: textual.events.DescendantBlur
## See also
- [Blur](blur.md)
- [DescendantFocus](descendant_focus.md)
- [Focus](focus.md)

View File

@@ -12,3 +12,9 @@ _No other attributes_
## Code
::: textual.events.DescendantFocus
## See also
- [Blur](blur.md)
- [DescendantBlur](descendant_blur.md)
- [Focus](focus.md)

View File

@@ -12,3 +12,9 @@ _No other attributes_
## Code
::: textual.events.Focus
## See also
- [Blur](blur.md)
- [DescendantBlur](descendant_blur.md)
- [DescendantFocus](descendant_focus.md)

View File

@@ -0,0 +1,42 @@
from textual.app import App, ComposeResult
from textual.screen import Screen
from textual.widgets import Footer, Placeholder
class DashboardScreen(Screen):
def compose(self) -> ComposeResult:
yield Placeholder("Dashboard Screen")
yield Footer()
class SettingsScreen(Screen):
def compose(self) -> ComposeResult:
yield Placeholder("Settings Screen")
yield Footer()
class HelpScreen(Screen):
def compose(self) -> ComposeResult:
yield Placeholder("Help Screen")
yield Footer()
class ModesApp(App):
BINDINGS = [
("d", "switch_mode('dashboard')", "Dashboard"), # (1)!
("s", "switch_mode('settings')", "Settings"),
("h", "switch_mode('help')", "Help"),
]
MODES = {
"dashboard": DashboardScreen, # (2)!
"settings": SettingsScreen,
"help": HelpScreen,
}
def on_mount(self) -> None:
self.switch_mode("dashboard") # (3)!
if __name__ == "__main__":
app = ModesApp()
app.run()

View File

@@ -0,0 +1,27 @@
from textual.app import App, ComposeResult
from textual.widgets import Rule, Label
from textual.containers import Vertical
class HorizontalRulesApp(App):
CSS_PATH = "horizontal_rules.tcss"
def compose(self) -> ComposeResult:
with Vertical():
yield Label("solid (default)")
yield Rule()
yield Label("heavy")
yield Rule(line_style="heavy")
yield Label("thick")
yield Rule(line_style="thick")
yield Label("dashed")
yield Rule(line_style="dashed")
yield Label("double")
yield Rule(line_style="double")
yield Label("ascii")
yield Rule(line_style="ascii")
if __name__ == "__main__":
app = HorizontalRulesApp()
app.run()

View File

@@ -0,0 +1,13 @@
Screen {
align: center middle;
}
Vertical {
height: auto;
width: 80%;
}
Label {
width: 100%;
text-align: center;
}

View File

@@ -0,0 +1,27 @@
from textual.app import App, ComposeResult
from textual.widgets import Rule, Label
from textual.containers import Horizontal
class VerticalRulesApp(App):
CSS_PATH = "vertical_rules.tcss"
def compose(self) -> ComposeResult:
with Horizontal():
yield Label("solid")
yield Rule(orientation="vertical")
yield Label("heavy")
yield Rule(orientation="vertical", line_style="heavy")
yield Label("thick")
yield Rule(orientation="vertical", line_style="thick")
yield Label("dashed")
yield Rule(orientation="vertical", line_style="dashed")
yield Label("double")
yield Rule(orientation="vertical", line_style="double")
yield Label("ascii")
yield Rule(orientation="vertical", line_style="ascii")
if __name__ == "__main__":
app = VerticalRulesApp()
app.run()

View File

@@ -0,0 +1,14 @@
Screen {
align: center middle;
}
Horizontal {
width: auto;
height: 80%;
}
Label {
width: 6;
height: 100%;
text-align: center;
}

View File

@@ -204,6 +204,41 @@ The addition of `[str]` tells mypy that `run()` is expected to return a string.
Type annotations are entirely optional (but recommended) with Textual.
### Return code
When you exit a Textual app with [`App.exit()`][textual.app.App.exit], you can optionally specify a *return code* with the `return_code` parameter.
!!! info "What are return codes?"
Returns codes are a standard feature provided by your operating system.
When any application exits it can return an integer to indicate if it was successful or not.
A return code of `0` indicates success, any other value indicates that an error occurred.
The exact meaning of a non-zero return code is application-dependant.
When a Textual app exits normally, the return code will be `0`. If there is an unhandled exception, Textual will set a return code of `1`.
You may want to set a different value for the return code if there is error condition that you want to differentiate from an unhandled exception.
Here's an example of setting a return code for an error condition:
```python
if critical_error:
self.exit(return_code=4, message="Critical error occurred")
```
The app's return code can be queried with `app.return_code`, which will be `None` if it hasn't been set, or an integer.
Textual won't explicitly exit the process.
To exit the app with a return code, you should call `sys.exit`.
Here's how you might do that:
```python
if __name__ == "__main__"
app = MyApp()
app.run()
import sys
sys.exit(app.return_code or 0)
```
## CSS

View File

@@ -256,3 +256,63 @@ Returning data in this way can help keep your code manageable by making it easy
You may have noticed in the previous example that we changed the base class to `ModalScreen[bool]`.
The addition of `[bool]` adds typing information that tells the type checker to expect a boolean in the call to `dismiss`, and that any callback set in `push_screen` should also expect the same type. As always, typing is optional in Textual, but this may help you catch bugs.
## Modes
Some apps may benefit from having multiple screen stacks, rather than just one.
Consider an app with a dashboard screen, a settings screen, and a help screen.
These are independent in the sense that we don't want to prevent the user from switching between them, even if there are one or more modal screens on the screen stack.
But we may still want each individual screen to have a navigation stack where we can push and pop screens.
In Textual we can manage this with *modes*.
A mode is simply a named screen stack, which we can switch between as required.
When we switch modes, the topmost screen in the new mode becomes the active visible screen.
The following diagram illustrates such an app with modes.
On startup the app switches to the "dashboard" mode which makes the top of the stack visible.
<div class="excalidraw">
--8<-- "docs/images/screens/modes1.excalidraw.svg"
</div>
If we later change the mode to "settings", the top of that mode's screen stack becomes visible.
<div class="excalidraw">
--8<-- "docs/images/screens/modes2.excalidraw.svg"
</div>
To add modes to your app, define a [`MODES`][textual.app.App.MODES] class variable in your App class which should be a `dict` that maps the name of the mode on to either a screen object, a callable that returns a screen, or the name of an installed screen.
However you specify it, the values in `MODES` set the base screen for each mode's screen stack.
You can switch between these screens at any time by calling [`App.switch_mode`][textual.app.App.switch_mode].
When you switch to a new mode, the topmost screen in the new stack becomes visible.
Any calls to [`App.push_screen`][textual.app.App.push_screen] or [`App.pop_screen`][textual.app.App.pop_screen] will affect only the active mode.
Let's look at an example with modes:
=== "modes01.py"
```python hl_lines="25-29 30-34 37"
--8<-- "docs/examples/guide/screens/modes01.py"
```
1. `switch_mode` is a builtin action to switch modes.
2. Associates `DashboardScreen` with the name "dashboard".
3. Switches to the dashboard mode.
=== "Output"
```{.textual path="docs/examples/guide/screens/modes01.py"}
```
=== "Output (after pressing S)"
```{.textual path="docs/examples/guide/screens/modes01.py", press="s"}
```
Here we have defined three screens.
One for a dashboard, one for settings, and one for help.
We've bound keys to each of these screens, so the user can switch between the screens.
Pressing ++d++, ++s++, or ++h++ switches between these modes.

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 11 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 11 KiB

View File

@@ -225,6 +225,16 @@ Display and update text in a scrolling panel.
```{.textual path="docs/examples/widgets/rich_log.py" press="H,i"}
```
## Rule
A rule widget to separate content, similar to a `<hr>` HTML tag.
[Rule reference](./widgets/rule.md){ .md-button .md-button--primary }
```{.textual path="docs/examples/widgets/horizontal_rules.py"}
```
## Select
Select from a number of possible options.

75
docs/widgets/rule.md Normal file
View File

@@ -0,0 +1,75 @@
# Rule
A rule widget to separate content, similar to a `<hr>` HTML tag.
- [ ] Focusable
- [ ] Container
## Examples
### Horizontal Rule
The default orientation of a rule is horizontal.
The example below shows horizontal rules with all the available line styles.
=== "Output"
```{.textual path="docs/examples/widgets/horizontal_rules.py"}
```
=== "horizontal_rules.py"
```python
--8<-- "docs/examples/widgets/horizontal_rules.py"
```
=== "horizontal_rules.tcss"
```sass
--8<-- "docs/examples/widgets/horizontal_rules.tcss"
```
### Vertical Rule
The example below shows vertical rules with all the available line styles.
=== "Output"
```{.textual path="docs/examples/widgets/vertical_rules.py"}
```
=== "vertical_rules.py"
```python
--8<-- "docs/examples/widgets/vertical_rules.py"
```
=== "vertical_rules.tcss"
```sass
--8<-- "docs/examples/widgets/vertical_rules.tcss"
```
## Reactive Attributes
| Name | Type | Default | Description |
| ------------- | ----------------- | -------------- | ---------------------------- |
| `orientation` | `RuleOrientation` | `"horizontal"` | The orientation of the rule. |
| `line_style` | `LineStyle` | `"solid"` | The line style of the rule. |
## Messages
This widget sends no messages.
---
::: textual.widgets.Rule
options:
heading_level: 2
::: textual.widgets.rule
options:
show_root_heading: true
show_root_toc_entry: true

View File

@@ -153,6 +153,7 @@ nav:
- "widgets/radiobutton.md"
- "widgets/radioset.md"
- "widgets/rich_log.md"
- "widgets/rule.md"
- "widgets/select.md"
- "widgets/selection_list.md"
- "widgets/sparkline.md"

29
poetry.lock generated
View File

@@ -690,14 +690,14 @@ smmap = ">=3.0.1,<6"
[[package]]
name = "gitpython"
version = "3.1.32"
version = "3.1.33"
description = "GitPython is a Python library used to interact with Git repositories"
category = "dev"
optional = false
python-versions = ">=3.7"
files = [
{file = "GitPython-3.1.32-py3-none-any.whl", hash = "sha256:e3d59b1c2c6ebb9dfa7a184daf3b6dd4914237e7488a1730a6d8f6f5d0b4187f"},
{file = "GitPython-3.1.32.tar.gz", hash = "sha256:8d9b8cb1e80b9735e8717c9362079d3ce4c6e5ddeebedd0361b228c3a67a62f6"},
{file = "GitPython-3.1.33-py3-none-any.whl", hash = "sha256:11f22466f982211ad8f3bdb456c03be8466c71d4da8774f3a9f68344e89559cb"},
{file = "GitPython-3.1.33.tar.gz", hash = "sha256:13aaa3dff88a23afec2d00eb3da3f2e040e2282e41de484c5791669b31146084"},
]
[package.dependencies]
@@ -1221,14 +1221,14 @@ mkdocs = "*"
[[package]]
name = "mkdocs-material"
version = "9.2.4"
version = "9.2.6"
description = "Documentation that simply works"
category = "dev"
optional = false
python-versions = ">=3.7"
files = [
{file = "mkdocs_material-9.2.4-py3-none-any.whl", hash = "sha256:2df876367625ff5e0f7112bc19a57521ed21ce9a2b85656baf9bb7f5dc3cb987"},
{file = "mkdocs_material-9.2.4.tar.gz", hash = "sha256:25008187b89fc376cb4ed2312b1fea4121bf2bd956442f38afdc6b4dcc21c57d"},
{file = "mkdocs_material-9.2.6-py3-none-any.whl", hash = "sha256:84bc7e79c1d0bae65a77123efd5ef74731b8c3671601c7962c5db8dba50a65ad"},
{file = "mkdocs_material-9.2.6.tar.gz", hash = "sha256:3806c58dd112e7b9677225e2021035ddbe3220fbd29d9dc812aa7e01f70b5e0a"},
]
[package.dependencies]
@@ -1664,20 +1664,23 @@ plugins = ["importlib-metadata"]
[[package]]
name = "pymdown-extensions"
version = "10.1"
version = "10.2.1"
description = "Extension pack for Python Markdown."
category = "dev"
optional = false
python-versions = ">=3.7"
files = [
{file = "pymdown_extensions-10.1-py3-none-any.whl", hash = "sha256:ef25dbbae530e8f67575d222b75ff0649b1e841e22c2ae9a20bad9472c2207dc"},
{file = "pymdown_extensions-10.1.tar.gz", hash = "sha256:508009b211373058debb8247e168de4cbcb91b1bff7b5e961b2c3e864e00b195"},
{file = "pymdown_extensions-10.2.1-py3-none-any.whl", hash = "sha256:bded105eb8d93f88f2f821f00108cb70cef1269db6a40128c09c5f48bfc60ea4"},
{file = "pymdown_extensions-10.2.1.tar.gz", hash = "sha256:d0c534b4a5725a4be7ccef25d65a4c97dba58b54ad7c813babf0eb5ba9c81591"},
]
[package.dependencies]
markdown = ">=3.2"
pyyaml = "*"
[package.extras]
extra = ["pygments (>=2.12)"]
[[package]]
name = "pyquery"
version = "2.0.0"
@@ -2379,14 +2382,14 @@ zstd = ["zstandard (>=0.18.0)"]
[[package]]
name = "virtualenv"
version = "20.24.3"
version = "20.24.4"
description = "Virtual Python Environment builder"
category = "dev"
optional = false
python-versions = ">=3.7"
files = [
{file = "virtualenv-20.24.3-py3-none-any.whl", hash = "sha256:95a6e9398b4967fbcb5fef2acec5efaf9aa4972049d9ae41f95e0972a683fd02"},
{file = "virtualenv-20.24.3.tar.gz", hash = "sha256:e5c3b4ce817b0b328af041506a2a299418c98747c4b1e68cb7527e74ced23efc"},
{file = "virtualenv-20.24.4-py3-none-any.whl", hash = "sha256:29c70bb9b88510f6414ac3e55c8b413a1f96239b6b789ca123437d5e892190cb"},
{file = "virtualenv-20.24.4.tar.gz", hash = "sha256:772b05bfda7ed3b8ecd16021ca9716273ad9f4467c801f27e83ac73430246dca"},
]
[package.dependencies]
@@ -2396,7 +2399,7 @@ importlib-metadata = {version = ">=6.6", markers = "python_version < \"3.8\""}
platformdirs = ">=3.9.1,<4"
[package.extras]
docs = ["furo (>=2023.5.20)", "proselint (>=0.13)", "sphinx (>=7.0.1)", "sphinx-argparse (>=0.4)", "sphinxcontrib-towncrier (>=0.2.1a0)", "towncrier (>=23.6)"]
docs = ["furo (>=2023.7.26)", "proselint (>=0.13)", "sphinx (>=7.1.2)", "sphinx-argparse (>=0.4)", "sphinxcontrib-towncrier (>=0.2.1a0)", "towncrier (>=23.6)"]
test = ["covdefaults (>=2.3)", "coverage (>=7.2.7)", "coverage-enable-subprocess (>=1)", "flaky (>=3.7)", "packaging (>=23.1)", "pytest (>=7.4)", "pytest-env (>=0.8.2)", "pytest-freezer (>=0.4.8)", "pytest-mock (>=3.11.1)", "pytest-randomly (>=3.12)", "pytest-timeout (>=2.1)", "setuptools (>=68)", "time-machine (>=2.10)"]
[[package]]

View File

@@ -1,6 +1,6 @@
[tool.poetry]
name = "textual"
version = "0.35.1"
version = "0.36.0"
homepage = "https://github.com/Textualize/textual"
repository = "https://github.com/Textualize/textual"
documentation = "https://textual.textualize.io/"

116
src/textual/_slug.py Normal file
View File

@@ -0,0 +1,116 @@
"""Provides a utility function and class for creating Markdown-friendly slugs.
The approach to creating slugs is designed to be as close to
GitHub-flavoured Markdown as possible. However, because there doesn't appear
to be any actual documentation for this 'standard', the code here involves
some guesswork and also some pragmatic shortcuts.
Expect this to grow over time.
The main rules used in here at the moment are:
1. Strip all leading and trailing whitespace.
2. Remove all non-lingual characters (emoji, etc).
3. Remove all punctuation and whitespace apart from dash and underscore.
"""
from __future__ import annotations
from collections import defaultdict
from re import compile
from string import punctuation
from typing import Pattern
from urllib.parse import quote
from typing_extensions import Final
WHITESPACE_REPLACEMENT: Final[str] = "-"
"""The character to replace undesirable characters with."""
REMOVABLE: Final[str] = punctuation.replace(WHITESPACE_REPLACEMENT, "").replace("_", "")
"""The collection of characters that should be removed altogether."""
NONLINGUAL: Final[str] = (
r"\U000024C2-\U0001F251"
r"\U00002702-\U000027B0"
r"\U0001F1E0-\U0001F1FF"
r"\U0001F300-\U0001F5FF"
r"\U0001F600-\U0001F64F"
r"\U0001F680-\U0001F6FF"
r"\U0001f926-\U0001f937"
r"\u200D"
r"\u2640-\u2642"
)
"""A string that can be used in a regular expression to remove most non-lingual characters."""
STRIP_RE: Final[Pattern] = compile(f"[{REMOVABLE}{NONLINGUAL}]+")
"""A regular expression for finding all the characters that should be removed."""
WHITESPACE_RE: Final[Pattern] = compile(r"\s")
"""A regular expression for finding all the whitespace and turning it into `REPLACEMENT`."""
def slug(text: str) -> str:
"""Create a Markdown-friendly slug from the given text.
Args:
text: The text to generate a slug from.
Returns:
A slug for the given text.
The rules used in generating the slug are based on observations of how
GitHub-flavoured Markdown works.
"""
result = text.strip().lower()
for rule, replacement in (
(STRIP_RE, ""),
(WHITESPACE_RE, WHITESPACE_REPLACEMENT),
):
result = rule.sub(replacement, result)
return quote(result)
class TrackedSlugs:
"""Provides a class for generating tracked slugs.
While [`slug`][textual._slug.slug] will generate a slug for a given
string, it does not guarantee that it is unique for a given context. If
you want to ensure that the same string generates unique slugs (perhaps
heading slugs within a Markdown document, as an example), use an
instance of this class to generate them.
Example:
```python
>>> slug("hello world")
'hello-world'
>>> slug("hello world")
'hello-world'
>>> unique = TrackedSlugs()
>>> unique.slug("hello world")
'hello-world'
>>> unique.slug("hello world")
'hello-world-1'
```
"""
def __init__(self) -> None:
"""Initialise the tracked slug object."""
self._used: defaultdict[str, int] = defaultdict(int)
"""Keeps track of how many times a particular slug has been used."""
def slug(self, text: str) -> str:
"""Create a Markdown-friendly unique slug from the given text.
Args:
text: The text to generate a slug from.
Returns:
A slug for the given text.
"""
slugged = slug(text)
used = self._used[slugged]
self._used[slugged] += 1
if used:
slugged = f"{slugged}-{used}"
return slugged

View File

@@ -477,11 +477,14 @@ class App(Generic[ReturnType], DOMNode):
# Dev dependencies not installed
pass
else:
self.devtools = DevtoolsClient()
self.devtools = DevtoolsClient(constants.DEVTOOLS_HOST)
self._devtools_redirector = StdoutRedirector(self.devtools)
self._loop: asyncio.AbstractEventLoop | None = None
self._return_value: ReturnType | None = None
"""Internal attribute used to set the return value for the app."""
self._return_code: int | None = None
"""Internal attribute used to set the return code for the app."""
self._exit = False
self._disable_tooltips = False
self._disable_notifications = False
@@ -531,6 +534,23 @@ class App(Generic[ReturnType], DOMNode):
"""
return self._return_value
@property
def return_code(self) -> int | None:
"""The return code with which the app exited.
Non-zero codes indicate errors.
A value of 1 means the app exited with a fatal error.
If the app wasn't exited yet, this will be `None`.
Example:
The return code can be used to exit the process via `sys.exit`.
```py
my_app.run()
sys.exit(my_app.return_code)
```
"""
return self._return_code
@property
def children(self) -> Sequence["Widget"]:
"""A view onto the app's immediate children.
@@ -650,17 +670,27 @@ class App(Generic[ReturnType], DOMNode):
"""
return self._screen_stacks[self._current_mode]
@property
def current_mode(self) -> str:
"""The name of the currently active mode."""
return self._current_mode
def exit(
self, result: ReturnType | None = None, message: RenderableType | None = None
self,
result: ReturnType | None = None,
return_code: int = 0,
message: RenderableType | None = None,
) -> None:
"""Exit the app, and return the supplied result.
Args:
result: Return value.
return_code: The return code. Use non-zero values for error codes.
message: Optional message to display on exit.
"""
self._exit = True
self._return_value = result
self._return_code = return_code
self.post_message(messages.ExitApp())
if message:
self._exit_renderables.append(message)
@@ -1173,7 +1203,7 @@ class App(Generic[ReturnType], DOMNode):
"""Called when app is ready to process events."""
app_ready_event.set()
async def run_app(app) -> None:
async def run_app(app: App) -> None:
if message_hook is not None:
message_hook_context_var.set(message_hook)
app._loop = asyncio.get_running_loop()
@@ -1512,19 +1542,19 @@ class App(Generic[ReturnType], DOMNode):
return self.mount(*widgets, before=before, after=after)
def _init_mode(self, mode: str) -> None:
"""Do internal initialisation of a new screen stack mode."""
"""Do internal initialisation of a new screen stack mode.
Args:
mode: Name of the mode.
"""
stack = self._screen_stacks.get(mode, [])
if not stack:
_screen = self.MODES[mode]
if callable(_screen):
screen, _ = self._get_screen(_screen())
else:
screen, _ = self._get_screen(self.MODES[mode])
new_screen: Screen | str = _screen() if callable(_screen) else _screen
screen, _ = self._get_screen(new_screen)
stack.append(screen)
self._load_screen_css(screen)
self._screen_stacks[mode] = stack
def switch_mode(self, mode: str) -> None:
@@ -1734,6 +1764,7 @@ class App(Generic[ReturnType], DOMNode):
)
self._load_screen_css(next_screen)
self._screen_stack.append(next_screen)
self.stylesheet.update(next_screen)
next_screen.post_message(events.ScreenResume())
self.log.system(f"{self.screen} is current (PUSHED)")
return await_mount
@@ -1916,6 +1947,7 @@ class App(Generic[ReturnType], DOMNode):
Args:
error: An exception instance.
"""
self._return_code = 1
# If we're running via pilot and this is the first exception encountered,
# take note of it so that we can re-raise for test frameworks later.
if self.is_headless and self._exception is None:
@@ -2027,6 +2059,7 @@ class App(Generic[ReturnType], DOMNode):
try:
try:
await self._dispatch_message(events.Compose())
default_screen = self.screen
await self._dispatch_message(events.Mount())
self.check_idle()
finally:
@@ -2035,7 +2068,8 @@ class App(Generic[ReturnType], DOMNode):
Reactive._initialize_object(self)
self.stylesheet.update(self)
self.refresh()
if self.screen is not default_screen:
self.stylesheet.update(default_screen)
await self.animator.start()

View File

@@ -56,6 +56,9 @@ FILTERS: Final[str] = get_environ("TEXTUAL_FILTERS", "")
LOG_FILE: Final[str | None] = get_environ("TEXTUAL_LOG", None)
"""A last resort log file that appends all logs, when devtools isn't working."""
DEVTOOLS_HOST: Final[str] = get_environ("TEXTUAL_DEVTOOLS_HOST", "127.0.0.1")
"""The host where textual console is running."""
DEVTOOLS_PORT: Final[int] = get_environ_int("TEXTUAL_DEVTOOLS_PORT", 8081)
"""Constant with the port that the devtools will connect to."""

View File

@@ -32,6 +32,7 @@ if typing.TYPE_CHECKING:
from ._radio_button import RadioButton
from ._radio_set import RadioSet
from ._rich_log import RichLog
from ._rule import Rule
from ._select import Select
from ._selection_list import SelectionList
from ._sparkline import Sparkline
@@ -67,6 +68,7 @@ __all__ = [
"ProgressBar",
"RadioButton",
"RadioSet",
"Rule",
"Select",
"SelectionList",
"Sparkline",

View File

@@ -22,6 +22,7 @@ from ._progress_bar import ProgressBar as ProgressBar
from ._radio_button import RadioButton as RadioButton
from ._radio_set import RadioSet as RadioSet
from ._rich_log import RichLog as RichLog
from ._rule import Rule as Rule
from ._select import Select as Select
from ._selection_list import SelectionList as SelectionList
from ._sparkline import Sparkline as Sparkline

View File

@@ -158,7 +158,7 @@ class Button(Static, can_focus=True):
variant = reactive("default")
"""The variant name for the button."""
class Pressed(Message, bubble=True):
class Pressed(Message):
"""Event sent when a `Button` is pressed.
Can be handled using `on_button_pressed` in a subclass of

View File

@@ -322,7 +322,7 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True):
)
"""The coordinate of the `DataTable` that is being hovered."""
class CellHighlighted(Message, bubble=True):
class CellHighlighted(Message):
"""Posted when the cursor moves to highlight a new cell.
This is only relevant when the `cursor_type` is `"cell"`.
@@ -359,7 +359,7 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True):
"""Alias for the data table."""
return self.data_table
class CellSelected(Message, bubble=True):
class CellSelected(Message):
"""Posted by the `DataTable` widget when a cell is selected.
This is only relevant when the `cursor_type` is `"cell"`. Can be handled using
@@ -394,7 +394,7 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True):
"""Alias for the data table."""
return self.data_table
class RowHighlighted(Message, bubble=True):
class RowHighlighted(Message):
"""Posted when a row is highlighted.
This message is only posted when the
@@ -423,7 +423,7 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True):
"""Alias for the data table."""
return self.data_table
class RowSelected(Message, bubble=True):
class RowSelected(Message):
"""Posted when a row is selected.
This message is only posted when the
@@ -452,7 +452,7 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True):
"""Alias for the data table."""
return self.data_table
class ColumnHighlighted(Message, bubble=True):
class ColumnHighlighted(Message):
"""Posted when a column is highlighted.
This message is only posted when the
@@ -481,7 +481,7 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True):
"""Alias for the data table."""
return self.data_table
class ColumnSelected(Message, bubble=True):
class ColumnSelected(Message):
"""Posted when a column is selected.
This message is only posted when the
@@ -510,7 +510,7 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True):
"""Alias for the data table."""
return self.data_table
class HeaderSelected(Message, bubble=True):
class HeaderSelected(Message):
"""Posted when a column header/label is clicked."""
def __init__(
@@ -540,7 +540,7 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True):
"""Alias for the data table."""
return self.data_table
class RowLabelSelected(Message, bubble=True):
class RowLabelSelected(Message):
"""Posted when a row label is clicked."""
def __init__(

View File

@@ -65,7 +65,7 @@ class DirectoryTree(Tree[DirEntry]):
PATH: Callable[[str | Path], Path] = Path
"""Callable that returns a fresh path object."""
class FileSelected(Message, bubble=True):
class FileSelected(Message):
"""Posted when a file is selected.
Can be handled using `on_directory_tree_file_selected` in a subclass of

View File

@@ -421,10 +421,11 @@ class Input(Widget, can_focus=True):
cell_offset = 0
_cell_size = get_character_cell_size
for index, char in enumerate(self.value):
if cell_offset >= click_x:
cell_width = _cell_size(char)
if cell_offset <= click_x < (cell_offset + cell_width):
self.cursor_position = index
break
cell_offset += _cell_size(char)
cell_offset += cell_width
else:
self.cursor_position = len(self.value)

View File

@@ -38,7 +38,7 @@ class ListView(VerticalScroll, can_focus=True, can_focus_children=False):
index = reactive[Optional[int]](0, always_update=True)
class Highlighted(Message, bubble=True):
class Highlighted(Message):
"""Posted when the highlighted item changes.
Highlighted item is controlled using up/down keys.
@@ -65,7 +65,7 @@ class ListView(VerticalScroll, can_focus=True, can_focus_children=False):
"""
return self.list_view
class Selected(Message, bubble=True):
class Selected(Message):
"""Posted when a list item is selected, e.g. when you press the enter key on it.
Can be handled using `on_list_view_selected` in a subclass of `ListView` or in

View File

@@ -565,7 +565,7 @@ class Markdown(Widget):
self._markdown = markdown
self._parser_factory = parser_factory
class TableOfContentsUpdated(Message, bubble=True):
class TableOfContentsUpdated(Message):
"""The table of contents was updated."""
def __init__(
@@ -586,7 +586,7 @@ class Markdown(Widget):
"""
return self.markdown
class TableOfContentsSelected(Message, bubble=True):
class TableOfContentsSelected(Message):
"""An item in the TOC was selected."""
def __init__(self, markdown: Markdown, block_id: str) -> None:
@@ -605,7 +605,7 @@ class Markdown(Widget):
"""
return self.markdown
class LinkClicked(Message, bubble=True):
class LinkClicked(Message):
"""A link in the document was clicked."""
def __init__(self, markdown: Markdown, href: str) -> None:

View File

@@ -613,6 +613,7 @@ class OptionList(ScrollView, can_focus=True):
self._refresh_content_tracking(force=True)
# Force a re-validation of the highlight.
self.highlighted = self.highlighted
self._mouse_hovering_over = None
self.refresh()
def remove_option(self, option_id: str) -> Self:

View File

@@ -76,7 +76,7 @@ class RadioSet(Container, can_focus=True, can_focus_children=False):
"""The index of the currently-selected radio button."""
@rich.repr.auto
class Changed(Message, bubble=True):
class Changed(Message):
"""Posted when the pressed button in the set changes.
This message can be handled using an `on_radio_set_changed` method.
@@ -124,7 +124,7 @@ class RadioSet(Container, can_focus=True, can_focus_children=False):
"""Initialise the radio set.
Args:
buttons: A collection of labels or [`RadioButton`][textual.widgets.RadioButton]s to group together.
buttons: The labels or [`RadioButton`][textual.widgets.RadioButton]s to group together.
name: The name of the radio set.
id: The ID of the radio set in the DOM.
classes: The CSS classes of the radio set.

View File

@@ -0,0 +1,217 @@
from __future__ import annotations
from rich.text import Text
from typing_extensions import Literal
from ..app import RenderResult
from ..css._error_tools import friendly_list
from ..reactive import Reactive, reactive
from ..widget import Widget
RuleOrientation = Literal["horizontal", "vertical"]
"""The valid orientations of the rule widget."""
LineStyle = Literal[
"ascii",
"blank",
"dashed",
"double",
"heavy",
"hidden",
"none",
"solid",
"thick",
]
"""The valid line styles of the rule widget."""
_VALID_RULE_ORIENTATIONS = {"horizontal", "vertical"}
_VALID_LINE_STYLES = {
"ascii",
"blank",
"dashed",
"double",
"heavy",
"hidden",
"none",
"solid",
"thick",
}
_HORIZONTAL_LINE_CHARS: dict[LineStyle, str] = {
"ascii": "-",
"blank": " ",
"dashed": "",
"double": "",
"heavy": "",
"hidden": " ",
"none": " ",
"solid": "",
"thick": "",
}
_VERTICAL_LINE_CHARS: dict[LineStyle, str] = {
"ascii": "|",
"blank": " ",
"dashed": "",
"double": "",
"heavy": "",
"hidden": " ",
"none": " ",
"solid": "",
"thick": "",
}
class InvalidRuleOrientation(Exception):
"""Exception raised for an invalid rule orientation."""
class InvalidLineStyle(Exception):
"""Exception raised for an invalid rule line style."""
class Rule(Widget, can_focus=False):
"""A rule widget to separate content, similar to a `<hr>` HTML tag."""
DEFAULT_CSS = """
Rule {
color: $primary;
}
Rule.-horizontal {
min-height: 1;
max-height: 1;
margin: 1 0;
}
Rule.-vertical {
min-width: 1;
max-width: 1;
margin: 0 2;
}
"""
orientation: Reactive[RuleOrientation] = reactive[RuleOrientation]("horizontal")
"""The orientation of the rule."""
line_style: Reactive[LineStyle] = reactive[LineStyle]("solid")
"""The line style of the rule."""
def __init__(
self,
orientation: RuleOrientation = "horizontal",
line_style: LineStyle = "solid",
*,
name: str | None = None,
id: str | None = None,
classes: str | None = None,
disabled: bool = False,
) -> None:
"""Initialize a rule widget.
Args:
orientation: The orientation of the rule.
line_style: The line style of the rule.
name: The name of the widget.
id: The ID of the widget in the DOM.
classes: The CSS classes of the widget.
disabled: Whether the widget is disabled or not.
"""
super().__init__(name=name, id=id, classes=classes, disabled=disabled)
self.orientation = orientation
self.line_style = line_style
def render(self) -> RenderResult:
rule_char: str
if self.orientation == "vertical":
rule_char = _VERTICAL_LINE_CHARS[self.line_style]
return Text(rule_char * self.size.height)
elif self.orientation == "horizontal":
rule_char = _HORIZONTAL_LINE_CHARS[self.line_style]
return Text(rule_char * self.size.width)
else:
raise InvalidRuleOrientation(
f"Valid rule orientations are {friendly_list(_VALID_RULE_ORIENTATIONS)}"
)
def watch_orientation(
self, old_orientation: RuleOrientation, orientation: RuleOrientation
) -> None:
self.remove_class(f"-{old_orientation}")
self.add_class(f"-{orientation}")
def validate_orientation(self, orientation: RuleOrientation) -> RuleOrientation:
if orientation not in _VALID_RULE_ORIENTATIONS:
raise InvalidRuleOrientation(
f"Valid rule orientations are {friendly_list(_VALID_RULE_ORIENTATIONS)}"
)
return orientation
def validate_line_style(self, style: LineStyle) -> LineStyle:
if style not in _VALID_LINE_STYLES:
raise InvalidLineStyle(
f"Valid rule line styles are {friendly_list(_VALID_LINE_STYLES)}"
)
return style
@classmethod
def horizontal(
cls,
line_style: LineStyle = "solid",
name: str | None = None,
id: str | None = None,
classes: str | None = None,
disabled: bool = False,
) -> Rule:
"""Utility constructor for creating a horizontal rule.
Args:
line_style: The line style of the rule.
name: The name of the widget.
id: The ID of the widget in the DOM.
classes: The CSS classes of the widget.
disabled: Whether the widget is disabled or not.
Returns:
A rule widget with horizontal orientation.
"""
return Rule(
orientation="horizontal",
line_style=line_style,
name=name,
id=id,
classes=classes,
disabled=disabled,
)
@classmethod
def vertical(
cls,
line_style: LineStyle = "solid",
name: str | None = None,
id: str | None = None,
classes: str | None = None,
disabled: bool = False,
) -> Rule:
"""Utility constructor for creating a vertical rule.
Args:
line_style: The line style of the rule.
name: The name of the widget.
id: The ID of the widget in the DOM.
classes: The CSS classes of the widget.
disabled: Whether the widget is disabled or not.
Returns:
A rule widget with vertical orientation.
"""
return Rule(
orientation="vertical",
line_style=line_style,
name=name,
id=id,
classes=classes,
disabled=disabled,
)

View File

@@ -226,7 +226,7 @@ class Select(Generic[SelectType], Vertical, can_focus=True):
value: var[SelectType | None] = var[Optional[SelectType]](None)
"""The value of the select."""
class Changed(Message, bubble=True):
class Changed(Message):
"""Posted when the select value was changed.
This message can be handled using a `on_select_changed` method.

View File

@@ -80,7 +80,7 @@ class Switch(Widget, can_focus=True):
slider_pos = reactive(0.0)
"""The position of the slider."""
class Changed(Message, bubble=True):
class Changed(Message):
"""Posted when the status of the switch changes.
Can be handled using `on_switch_changed` in a subclass of `Switch`

View File

@@ -232,7 +232,7 @@ class ToggleButton(Static, can_focus=True):
"""Toggle the value of the widget when clicked with the mouse."""
self.toggle()
class Changed(Message, bubble=True):
class Changed(Message):
"""Posted when the value of the toggle button changes."""
def __init__(self, toggle_button: ToggleButton, value: bool) -> None:

View File

@@ -508,7 +508,7 @@ class Tree(Generic[TreeDataType], ScrollView, can_focus=True):
),
}
class NodeCollapsed(Generic[EventTreeDataType], Message, bubble=True):
class NodeCollapsed(Generic[EventTreeDataType], Message):
"""Event sent when a node is collapsed.
Can be handled using `on_tree_node_collapsed` in a subclass of `Tree` or in a
@@ -525,7 +525,7 @@ class Tree(Generic[TreeDataType], ScrollView, can_focus=True):
"""The tree that sent the message."""
return self.node.tree
class NodeExpanded(Generic[EventTreeDataType], Message, bubble=True):
class NodeExpanded(Generic[EventTreeDataType], Message):
"""Event sent when a node is expanded.
Can be handled using `on_tree_node_expanded` in a subclass of `Tree` or in a
@@ -542,7 +542,7 @@ class Tree(Generic[TreeDataType], ScrollView, can_focus=True):
"""The tree that sent the message."""
return self.node.tree
class NodeHighlighted(Generic[EventTreeDataType], Message, bubble=True):
class NodeHighlighted(Generic[EventTreeDataType], Message):
"""Event sent when a node is highlighted.
Can be handled using `on_tree_node_highlighted` in a subclass of `Tree` or in a
@@ -559,7 +559,7 @@ class Tree(Generic[TreeDataType], ScrollView, can_focus=True):
"""The tree that sent the message."""
return self.node.tree
class NodeSelected(Generic[EventTreeDataType], Message, bubble=True):
class NodeSelected(Generic[EventTreeDataType], Message):
"""Event sent when a node is selected.
Can be handled using `on_tree_node_selected` in a subclass of `Tree` or in a
@@ -888,25 +888,29 @@ class Tree(Generic[TreeDataType], ScrollView, can_focus=True):
self.cursor_line = -1
self._invalidate()
def scroll_to_line(self, line: int) -> None:
def scroll_to_line(self, line: int, animate: bool = True) -> None:
"""Scroll to the given line.
Args:
line: A line number.
animate: Enable animation.
"""
region = self._get_label_region(line)
if region is not None:
self.scroll_to_region(region)
self.scroll_to_region(region, animate=animate)
def scroll_to_node(self, node: TreeNode[TreeDataType]) -> None:
def scroll_to_node(
self, node: TreeNode[TreeDataType], animate: bool = True
) -> None:
"""Scroll to the given node.
Args:
node: Node to scroll in to view.
animate: Animate scrolling.
"""
line = node._line
if line != -1:
self.scroll_to_line(line)
self.scroll_to_line(line, animate=animate)
def refresh_line(self, line: int) -> None:
"""Refresh (repaint) a given line in the tree.
@@ -1156,7 +1160,7 @@ class Tree(Generic[TreeDataType], ScrollView, can_focus=True):
self.cursor_line = self.last_line
else:
self.cursor_line -= 1
self.scroll_to_line(self.cursor_line)
self.scroll_to_line(self.cursor_line, animate=False)
def action_cursor_down(self) -> None:
"""Move the cursor down one node."""
@@ -1164,7 +1168,7 @@ class Tree(Generic[TreeDataType], ScrollView, can_focus=True):
self.cursor_line = 0
else:
self.cursor_line += 1
self.scroll_to_line(self.cursor_line)
self.scroll_to_line(self.cursor_line, animate=False)
def action_page_down(self) -> None:
"""Move the cursor down a page's-worth of nodes."""

View File

@@ -0,0 +1,13 @@
from ._rule import (
InvalidLineStyle,
InvalidRuleOrientation,
LineStyle,
RuleOrientation,
)
__all__ = [
"InvalidLineStyle",
"InvalidRuleOrientation",
"LineStyle",
"RuleOrientation",
]

View File

@@ -6,28 +6,61 @@ from textual.app import App, ComposeResult
from textual.geometry import Offset
from textual.widgets import Input
# A string containing only single-width characters
TEXT_SINGLE = "That gum you like is going to come back in style"
# A string containing only double-width characters
TEXT_DOUBLE = "こんにちは"
# A string containing both single and double-width characters
TEXT_MIXED = "aこんbcにdちeは"
class InputApp(App[None]):
TEST_TEXT = "That gum you like is going to come back in style"
def __init__(self, text):
super().__init__()
self._text = text
def compose(self) -> ComposeResult:
yield Input(self.TEST_TEXT)
yield Input(self._text)
@pytest.mark.parametrize(
"click_at, should_land",
"text, click_at, should_land",
(
(0, 0),
(1, 1),
(10, 10),
(len(InputApp.TEST_TEXT) - 1, len(InputApp.TEST_TEXT) - 1),
(len(InputApp.TEST_TEXT), len(InputApp.TEST_TEXT)),
(len(InputApp.TEST_TEXT) * 2, len(InputApp.TEST_TEXT)),
# Single-width characters
(TEXT_SINGLE, 0, 0),
(TEXT_SINGLE, 1, 1),
(TEXT_SINGLE, 10, 10),
(TEXT_SINGLE, len(TEXT_SINGLE) - 1, len(TEXT_SINGLE) - 1),
(TEXT_SINGLE, len(TEXT_SINGLE), len(TEXT_SINGLE)),
(TEXT_SINGLE, len(TEXT_SINGLE) * 2, len(TEXT_SINGLE)),
# Double-width characters
(TEXT_DOUBLE, 0, 0),
(TEXT_DOUBLE, 1, 0),
(TEXT_DOUBLE, 2, 1),
(TEXT_DOUBLE, 3, 1),
(TEXT_DOUBLE, 4, 2),
(TEXT_DOUBLE, 5, 2),
(TEXT_DOUBLE, (len(TEXT_DOUBLE) * 2) - 1, len(TEXT_DOUBLE) - 1),
(TEXT_DOUBLE, len(TEXT_DOUBLE) * 2, len(TEXT_DOUBLE)),
(TEXT_DOUBLE, len(TEXT_DOUBLE) * 10, len(TEXT_DOUBLE)),
# Mixed-width characters
(TEXT_MIXED, 0, 0),
(TEXT_MIXED, 1, 1),
(TEXT_MIXED, 2, 1),
(TEXT_MIXED, 3, 2),
(TEXT_MIXED, 4, 2),
(TEXT_MIXED, 5, 3),
(TEXT_MIXED, 13, 9),
(TEXT_MIXED, 14, 9),
(TEXT_MIXED, 15, 10),
(TEXT_MIXED, 1000, 10),
),
)
async def test_mouse_clicks_within(click_at, should_land):
async def test_mouse_clicks_within(text, click_at, should_land):
"""Mouse clicks should result in the cursor going to the right place."""
async with InputApp().run_test() as pilot:
async with InputApp(text).run_test() as pilot:
# Note the offsets to take into account the decoration around an
# Input.
await pilot.click(Input, Offset(click_at + 3, 1))
@@ -37,7 +70,7 @@ async def test_mouse_clicks_within(click_at, should_land):
async def test_mouse_click_outwith():
"""Mouse clicks outside the input should not affect cursor position."""
async with InputApp().run_test() as pilot:
async with InputApp(TEXT_SINGLE).run_test() as pilot:
pilot.app.query_one(Input).cursor_position = 3
assert pilot.app.query_one(Input).cursor_position == 3
await pilot.click(Input, Offset(0, 0))

View File

@@ -5,6 +5,7 @@ from __future__ import annotations
import pytest
from textual.app import App, ComposeResult
from textual.geometry import Offset
from textual.widgets import OptionList
from textual.widgets.option_list import Option, OptionDoesNotExist
@@ -99,3 +100,13 @@ async def test_remove_invalid_index() -> None:
async with OptionListApp().run_test() as pilot:
with pytest.raises(OptionDoesNotExist):
pilot.app.query_one(OptionList).remove_option_at_index(23)
async def test_remove_with_hover_on_last_option():
"""https://github.com/Textualize/textual/issues/3270"""
async with OptionListApp().run_test() as pilot:
await pilot.hover(OptionList, Offset(1, 1) + Offset(2, 1))
option_list = pilot.app.query_one(OptionList)
assert option_list._mouse_hovering_over == 1
option_list.remove_option_at_index(0)
assert option_list._mouse_hovering_over == None

File diff suppressed because one or more lines are too long

View File

@@ -285,6 +285,14 @@ def test_progress_bar_completed_styled(snap_compare):
assert snap_compare(WIDGET_EXAMPLES_DIR / "progress_bar_styled_.py", press=["u"])
def test_rule_horizontal_rules(snap_compare):
assert snap_compare(WIDGET_EXAMPLES_DIR / "horizontal_rules.py")
def test_rule_vertical_rules(snap_compare):
assert snap_compare(WIDGET_EXAMPLES_DIR / "vertical_rules.py")
def test_select(snap_compare):
assert snap_compare(WIDGET_EXAMPLES_DIR / "select_widget.py")

View File

@@ -1,3 +1,5 @@
import contextlib
from textual.app import App, ComposeResult
from textual.widgets import Button, Input
@@ -67,3 +69,40 @@ def test_setting_sub_title():
app.sub_title = [True, False, 2]
assert app.sub_title == "[True, False, 2]"
async def test_default_return_code_is_zero():
app = App()
async with app.run_test():
app.exit()
assert app.return_code == 0
async def test_return_code_is_one_after_crash():
class MyApp(App):
def key_p(self):
1 / 0
app = MyApp()
with contextlib.suppress(ZeroDivisionError):
async with app.run_test() as pilot:
await pilot.press("p")
assert app.return_code == 1
async def test_set_return_code():
app = App()
async with app.run_test():
app.exit(return_code=42)
assert app.return_code == 42
def test_no_return_code_before_running():
app = App()
assert app.return_code is None
async def test_no_return_code_while_running():
app = App()
async with app.run_test():
assert app.return_code is None

26
tests/test_rule.py Normal file
View File

@@ -0,0 +1,26 @@
import pytest
from textual.widgets import Rule
from textual.widgets.rule import InvalidLineStyle, InvalidRuleOrientation
def test_invalid_rule_orientation():
with pytest.raises(InvalidRuleOrientation):
Rule(orientation="invalid orientation!")
def test_invalid_rule_line_style():
with pytest.raises(InvalidLineStyle):
Rule(line_style="invalid line style!")
def test_invalid_reactive_rule_orientation_change():
rule = Rule()
with pytest.raises(InvalidRuleOrientation):
rule.orientation = "invalid orientation!"
def test_invalid_reactive_rule_line_style_change():
rule = Rule()
with pytest.raises(InvalidLineStyle):
rule.line_style = "invalid line style!"

62
tests/test_slug.py Normal file
View File

@@ -0,0 +1,62 @@
import pytest
from textual._slug import TrackedSlugs, slug
@pytest.mark.parametrize(
"text, expected",
[
("test", "test"),
("Test", "test"),
(" Test ", "test"),
("-test-", "-test-"),
("!test!", "test"),
("test!!test", "testtest"),
("test! !test", "test-test"),
("test test", "test-test"),
("test test", "test--test"),
("test test", "test----------test"),
("--test", "--test"),
("test--", "test--"),
("--test--test--", "--test--test--"),
("test!\"#$%&'()*+,-./:;<=>?@[]^_`{|}~test", "test-_test"),
("tëst", "t%C3%ABst"),
("test🙂test", "testtest"),
("test🤷test", "testtest"),
("test🤷🏻test", "testtest"),
],
)
def test_simple_slug(text: str, expected: str) -> None:
"""The simple slug function should produce the expected slug."""
assert slug(text) == expected
@pytest.fixture(scope="module")
def tracker() -> TrackedSlugs:
return TrackedSlugs()
@pytest.mark.parametrize(
"text, expected",
[
("test", "test"),
("test", "test-1"),
("test", "test-2"),
("-test-", "-test-"),
("-test-", "-test--1"),
("test!\"#$%&'()*+,-./:;<=>?@[]^_`{|}~test", "test-_test"),
("test!\"#$%&'()*+,-./:;<=>?@[]^_`{|}~test", "test-_test-1"),
("tëst", "t%C3%ABst"),
("tëst", "t%C3%ABst-1"),
("tëst", "t%C3%ABst-2"),
("test🙂test", "testtest"),
("test🤷test", "testtest-1"),
("test🤷🏻test", "testtest-2"),
("test", "test-3"),
("test", "test-4"),
(" test ", "test-5"),
],
)
def test_tracked_slugs(tracker: TrackedSlugs, text: str, expected: str) -> None:
"""The tracked slugging class should produce the expected slugs."""
assert tracker.slug(text) == expected