merge main and resolve changelog conflict

This commit is contained in:
TomJGooding
2023-09-07 16:08:46 +01:00
29 changed files with 1185 additions and 10 deletions

View File

@@ -5,14 +5,21 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](http://keepachangelog.com/)
and this project adheres to [Semantic Versioning](http://semver.org/).
## Unreleased
## [Unreleased]
### Added
- Added `DirectoryTree.DirectorySelected` message https://github.com/Textualize/textual/issues/3200
## [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 `DirectoryTree.DirectorySelected` message https://github.com/Textualize/textual/issues/3200
- 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
@@ -1246,6 +1253,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"

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

@@ -668,6 +668,11 @@ 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,
@@ -1535,19 +1540,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:

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

@@ -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

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

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")

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