mirror of
https://github.com/Textualize/textual.git
synced 2025-10-17 02:38:12 +03:00
Merge branch 'main' into screen-title-sub-title
This commit is contained in:
3
.github/workflows/pythonpackage.yml
vendored
3
.github/workflows/pythonpackage.yml
vendored
@@ -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
|
||||
|
||||
19
CHANGELOG.md
19
CHANGELOG.md
@@ -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
|
||||
|
||||
45
docs/blog/posts/textual-web.md
Normal file
45
docs/blog/posts/textual-web.md
Normal 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.
|
||||
@@ -12,3 +12,9 @@ _No other attributes_
|
||||
## Code
|
||||
|
||||
::: textual.events.Blur
|
||||
|
||||
## See also
|
||||
|
||||
- [DescendantBlur](descendant_blur.md)
|
||||
- [DescendantFocus](descendant_focus.md)
|
||||
- [Focus](focus.md)
|
||||
|
||||
@@ -12,3 +12,9 @@ _No other attributes_
|
||||
## Code
|
||||
|
||||
::: textual.events.DescendantBlur
|
||||
|
||||
## See also
|
||||
|
||||
- [Blur](blur.md)
|
||||
- [DescendantFocus](descendant_focus.md)
|
||||
- [Focus](focus.md)
|
||||
|
||||
@@ -12,3 +12,9 @@ _No other attributes_
|
||||
## Code
|
||||
|
||||
::: textual.events.DescendantFocus
|
||||
|
||||
## See also
|
||||
|
||||
- [Blur](blur.md)
|
||||
- [DescendantBlur](descendant_blur.md)
|
||||
- [Focus](focus.md)
|
||||
|
||||
@@ -12,3 +12,9 @@ _No other attributes_
|
||||
## Code
|
||||
|
||||
::: textual.events.Focus
|
||||
|
||||
## See also
|
||||
|
||||
- [Blur](blur.md)
|
||||
- [DescendantBlur](descendant_blur.md)
|
||||
- [DescendantFocus](descendant_focus.md)
|
||||
|
||||
42
docs/examples/guide/screens/modes01.py
Normal file
42
docs/examples/guide/screens/modes01.py
Normal 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()
|
||||
27
docs/examples/widgets/horizontal_rules.py
Normal file
27
docs/examples/widgets/horizontal_rules.py
Normal 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()
|
||||
13
docs/examples/widgets/horizontal_rules.tcss
Normal file
13
docs/examples/widgets/horizontal_rules.tcss
Normal file
@@ -0,0 +1,13 @@
|
||||
Screen {
|
||||
align: center middle;
|
||||
}
|
||||
|
||||
Vertical {
|
||||
height: auto;
|
||||
width: 80%;
|
||||
}
|
||||
|
||||
Label {
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
}
|
||||
27
docs/examples/widgets/vertical_rules.py
Normal file
27
docs/examples/widgets/vertical_rules.py
Normal 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()
|
||||
14
docs/examples/widgets/vertical_rules.tcss
Normal file
14
docs/examples/widgets/vertical_rules.tcss
Normal file
@@ -0,0 +1,14 @@
|
||||
Screen {
|
||||
align: center middle;
|
||||
}
|
||||
|
||||
Horizontal {
|
||||
width: auto;
|
||||
height: 80%;
|
||||
}
|
||||
|
||||
Label {
|
||||
width: 6;
|
||||
height: 100%;
|
||||
text-align: center;
|
||||
}
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
16
docs/images/screens/modes1.excalidraw.svg
Normal file
16
docs/images/screens/modes1.excalidraw.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 11 KiB |
16
docs/images/screens/modes2.excalidraw.svg
Normal file
16
docs/images/screens/modes2.excalidraw.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 11 KiB |
@@ -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
75
docs/widgets/rule.md
Normal 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
|
||||
@@ -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
29
poetry.lock
generated
@@ -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]]
|
||||
|
||||
@@ -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
116
src/textual/_slug.py
Normal 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
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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."""
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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__(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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.
|
||||
|
||||
217
src/textual/widgets/_rule.py
Normal file
217
src/textual/widgets/_rule.py
Normal 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,
|
||||
)
|
||||
@@ -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.
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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."""
|
||||
|
||||
13
src/textual/widgets/rule.py
Normal file
13
src/textual/widgets/rule.py
Normal file
@@ -0,0 +1,13 @@
|
||||
from ._rule import (
|
||||
InvalidLineStyle,
|
||||
InvalidRuleOrientation,
|
||||
LineStyle,
|
||||
RuleOrientation,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"InvalidLineStyle",
|
||||
"InvalidRuleOrientation",
|
||||
"LineStyle",
|
||||
"RuleOrientation",
|
||||
]
|
||||
@@ -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))
|
||||
|
||||
@@ -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
@@ -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")
|
||||
|
||||
|
||||
@@ -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
26
tests/test_rule.py
Normal 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
62
tests/test_slug.py
Normal 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
|
||||
Reference in New Issue
Block a user