diff --git a/.github/workflows/pythonpackage.yml b/.github/workflows/pythonpackage.yml
index 9df7cf2fbf..32f2e0a2b5 100644
--- a/.github/workflows/pythonpackage.yml
+++ b/.github/workflows/pythonpackage.yml
@@ -49,7 +49,8 @@ jobs:
if: ${{ matrix.python-version == '3.8' }}
- name: Upload snapshot report
if: always()
- uses: actions/upload-artifact@v3
+ uses: actions/upload-artifact@v4
with:
- name: snapshot-report-textual
+ name: snapshot_report_textual
path: snapshot_report.html
+ overwrite: true
diff --git a/CHANGELOG.md b/CHANGELOG.md
index ab70eb5340..95f5112ade 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -24,6 +24,9 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
- Added App.ALLOW_SELECT for a global switch to disable text selection https://github.com/Textualize/textual/pull/5409
- Added `DOMNode.query_ancestor` https://github.com/Textualize/textual/pull/5409
- Added selection to Log widget https://github.com/Textualize/textual/pull/5467
+- Added `text-wrap` and `text-overflow` CSS values https://github.com/Textualize/textual/pull/5485
+- Added Textual markup to replace Rich markup https://github.com/Textualize/textual/pull/5485
+- Added `Content.from_markup` https://github.com/Textualize/textual/pull/5485
### Fixed
diff --git a/docs/api/content.md b/docs/api/content.md
new file mode 100644
index 0000000000..d798a5645e
--- /dev/null
+++ b/docs/api/content.md
@@ -0,0 +1,5 @@
+---
+title: "textual.content"
+---
+
+::: textual.content
diff --git a/docs/api/style.md b/docs/api/style.md
new file mode 100644
index 0000000000..ebe99cf48e
--- /dev/null
+++ b/docs/api/style.md
@@ -0,0 +1,5 @@
+---
+title: "textual.style"
+---
+
+::: textual.style
diff --git a/docs/examples/guide/content/content01.py b/docs/examples/guide/content/content01.py
new file mode 100644
index 0000000000..35e55b3951
--- /dev/null
+++ b/docs/examples/guide/content/content01.py
@@ -0,0 +1,36 @@
+from textual.app import App, ComposeResult
+from textual.widgets import Static
+
+TEXT1 = """\
+Hello, [bold $text on $primary]World[/]!
+
+[@click=app.notify('Hello, World!')]Click me[/]
+"""
+
+TEXT2 = """\
+Markup will [bold]not[/bold] be displayed.
+
+Tags will be left in the output.
+
+"""
+
+
+class ContentApp(App):
+ CSS = """
+ Screen {
+ Static {
+ height: 1fr;
+ }
+ #text1 { background: $primary-muted; }
+ #text2 { background: $error-muted; }
+ }
+ """
+
+ def compose(self) -> ComposeResult:
+ yield Static(TEXT1, id="text1")
+ yield Static(TEXT2, id="text2", markup=False) # (1)!
+
+
+if __name__ == "__main__":
+ app = ContentApp()
+ app.run()
diff --git a/docs/examples/guide/content/playground.py b/docs/examples/guide/content/playground.py
new file mode 100644
index 0000000000..b2d876a30a
--- /dev/null
+++ b/docs/examples/guide/content/playground.py
@@ -0,0 +1,5 @@
+from textual._markup_playground import MarkupPlayground
+
+if __name__ == "__main__":
+ app = MarkupPlayground()
+ app.run()
diff --git a/docs/examples/guide/content/renderables.py b/docs/examples/guide/content/renderables.py
new file mode 100644
index 0000000000..9bea0ef50d
--- /dev/null
+++ b/docs/examples/guide/content/renderables.py
@@ -0,0 +1,36 @@
+from rich.syntax import Syntax
+
+from textual.app import App, ComposeResult, RenderResult
+from textual.reactive import reactive
+from textual.widget import Widget
+
+
+class CodeView(Widget):
+ """Widget to display Python code."""
+
+ DEFAULT_CSS = """
+ CodeView { height: auto; }
+ """
+
+ code = reactive("")
+
+ def render(self) -> RenderResult:
+ # Syntax is a Rich renderable that displays syntax highlighted code
+ syntax = Syntax(self.code, "python", line_numbers=True, indent_guides=True)
+ return syntax
+
+
+class CodeApp(App):
+ """App to demonstrate Rich renderables in Textual."""
+
+ def compose(self) -> ComposeResult:
+ with open(__file__) as self_file:
+ code = self_file.read()
+ code_view = CodeView()
+ code_view.code = code
+ yield code_view
+
+
+if __name__ == "__main__":
+ app = CodeApp()
+ app.run()
diff --git a/docs/examples/styles/border_sub_title_align_all.py b/docs/examples/styles/border_sub_title_align_all.py
index 1832d97f74..7632c095c6 100644
--- a/docs/examples/styles/border_sub_title_align_all.py
+++ b/docs/examples/styles/border_sub_title_align_all.py
@@ -39,7 +39,7 @@ def compose(self):
"had to fill up",
"lbl4",
"", # (4)!
- "[link=https://textual.textualize.io]Left[/]", # (5)!
+ "[link='https://textual.textualize.io']Left[/]", # (5)!
)
yield make_label_container( # (6)!
"nine labels", "lbl5", "Title", "Subtitle"
diff --git a/docs/examples/styles/link_background.py b/docs/examples/styles/link_background.py
index 6516f1b6aa..1959de722c 100644
--- a/docs/examples/styles/link_background.py
+++ b/docs/examples/styles/link_background.py
@@ -7,7 +7,7 @@ class LinkBackgroundApp(App):
def compose(self):
yield Label(
- "Visit the [link=https://textualize.io]Textualize[/link] website.",
+ "Visit the [link='https://textualize.io']Textualize[/link] website.",
id="lbl1", # (1)!
)
yield Label(
diff --git a/docs/examples/styles/link_background_hover.py b/docs/examples/styles/link_background_hover.py
index fc33e576d7..9dfee854e9 100644
--- a/docs/examples/styles/link_background_hover.py
+++ b/docs/examples/styles/link_background_hover.py
@@ -7,7 +7,7 @@ class LinkHoverBackgroundApp(App):
def compose(self):
yield Label(
- "Visit the [link=https://textualize.io]Textualize[/link] website.",
+ "Visit the [link='https://textualize.io']Textualize[/link] website.",
id="lbl1", # (1)!
)
yield Label(
diff --git a/docs/examples/styles/link_color.py b/docs/examples/styles/link_color.py
index 3d6a83cc7f..38acb8b85a 100644
--- a/docs/examples/styles/link_color.py
+++ b/docs/examples/styles/link_color.py
@@ -7,7 +7,7 @@ class LinkColorApp(App):
def compose(self):
yield Label(
- "Visit the [link=https://textualize.io]Textualize[/link] website.",
+ "Visit the [link='https://textualize.io']Textualize[/link] website.",
id="lbl1", # (1)!
)
yield Label(
diff --git a/docs/examples/styles/link_color_hover.py b/docs/examples/styles/link_color_hover.py
index 7344123ae0..ac0bfb5650 100644
--- a/docs/examples/styles/link_color_hover.py
+++ b/docs/examples/styles/link_color_hover.py
@@ -7,7 +7,7 @@ class LinkHoverColorApp(App):
def compose(self):
yield Label(
- "Visit the [link=https://textualize.io]Textualize[/link] website.",
+ "Visit the [link='https://textualize.io']Textualize[/link] website.",
id="lbl1", # (1)!
)
yield Label(
diff --git a/docs/examples/styles/link_style.py b/docs/examples/styles/link_style.py
index 405666ca24..77d17bba94 100644
--- a/docs/examples/styles/link_style.py
+++ b/docs/examples/styles/link_style.py
@@ -7,7 +7,7 @@ class LinkStyleApp(App):
def compose(self):
yield Label(
- "Visit the [link=https://textualize.io]Textualize[/link] website.",
+ "Visit the [link='https://textualize.io']Textualize[/link] website.",
id="lbl1", # (1)!
)
yield Label(
diff --git a/docs/examples/styles/link_style_hover.py b/docs/examples/styles/link_style_hover.py
index 3d47406b48..aed2777957 100644
--- a/docs/examples/styles/link_style_hover.py
+++ b/docs/examples/styles/link_style_hover.py
@@ -7,7 +7,7 @@ class LinkHoverStyleApp(App):
def compose(self):
yield Label(
- "Visit the [link=https://textualize.io]Textualize[/link] website.",
+ "Visit the [link='https://textualize.io']Textualize[/link] website.",
id="lbl1", # (1)!
)
yield Label(
diff --git a/docs/examples/styles/text_overflow.py b/docs/examples/styles/text_overflow.py
new file mode 100644
index 0000000000..7f21401310
--- /dev/null
+++ b/docs/examples/styles/text_overflow.py
@@ -0,0 +1,18 @@
+from textual.app import App, ComposeResult
+from textual.widgets import Static
+
+TEXT = """I must not fear. Fear is the mind-killer. Fear is the little-death that brings total obliteration. I will face my fear."""
+
+
+class WrapApp(App):
+ CSS_PATH = "text_overflow.tcss"
+
+ def compose(self) -> ComposeResult:
+ yield Static(TEXT, id="static1")
+ yield Static(TEXT, id="static2")
+ yield Static(TEXT, id="static3")
+
+
+if __name__ == "__main__":
+ app = WrapApp()
+ app.run()
diff --git a/docs/examples/styles/text_overflow.tcss b/docs/examples/styles/text_overflow.tcss
new file mode 100644
index 0000000000..edc977e0dd
--- /dev/null
+++ b/docs/examples/styles/text_overflow.tcss
@@ -0,0 +1,17 @@
+Static {
+ height: 1fr;
+ text-wrap: nowrap;
+}
+
+#static1 {
+ text-overflow: clip; # Overflowing text is clipped
+ background: red 20%;
+}
+#static2 {
+ text-overflow: fold; # Overflowing text is folded on to the next line
+ background: green 20%;
+}
+#static3 {
+ text-overflow: ellipsis; # Overflowing text is truncated with an ellipsis
+ background: blue 20%;
+}
diff --git a/docs/examples/styles/text_wrap.py b/docs/examples/styles/text_wrap.py
new file mode 100644
index 0000000000..35c6d70d01
--- /dev/null
+++ b/docs/examples/styles/text_wrap.py
@@ -0,0 +1,17 @@
+from textual.app import App, ComposeResult
+from textual.widgets import Static
+
+TEXT = """I must not fear. Fear is the mind-killer. Fear is the little-death that brings total obliteration. I will face my fear."""
+
+
+class WrapApp(App):
+ CSS_PATH = "text_wrap.tcss"
+
+ def compose(self) -> ComposeResult:
+ yield Static(TEXT, id="static1")
+ yield Static(TEXT, id="static2")
+
+
+if __name__ == "__main__":
+ app = WrapApp()
+ app.run()
diff --git a/docs/examples/styles/text_wrap.tcss b/docs/examples/styles/text_wrap.tcss
new file mode 100644
index 0000000000..24b7c9def7
--- /dev/null
+++ b/docs/examples/styles/text_wrap.tcss
@@ -0,0 +1,12 @@
+Static {
+ height: 1fr;
+}
+
+#static1 {
+ text-wrap: wrap; /* this is the default */
+ background: blue 20%;
+}
+#static2 {
+ text-wrap: nowrap; /* disable wrapping */
+ background: green 20%;
+}
diff --git a/docs/guide/CSS.md b/docs/guide/CSS.md
index 499f53a50b..76d6f2394c 100644
--- a/docs/guide/CSS.md
+++ b/docs/guide/CSS.md
@@ -100,7 +100,7 @@ With a header and a footer widget the DOM looks like this:
--8<-- "docs/images/dom2.excalidraw.svg"
-!!! note
+!!! note "What we didn't show"
We've simplified the above example somewhat. Both the Header and Footer widgets contain children of their own. When building an app with pre-built widgets you rarely need to know how they are constructed unless you plan on changing the styles of individual components.
@@ -108,17 +108,17 @@ Both Header and Footer are children of the Screen object.
To further explore the DOM, we're going to build a simple dialog with a question and two buttons. To do this we're going to import and use a few more builtin widgets:
-- `textual.layout.Container` For our top-level dialog.
-- `textual.layout.Horizontal` To arrange widgets left to right.
-- `textual.widgets.Static` For simple content.
-- `textual.widgets.Button` For a clickable button.
+- [`textual.containers.Container`][textual.containers.Container] For our top-level dialog.
+- [`textual.containers.Horizontal`][textual.containers.Horizontal] To arrange widgets left to right.
+- [`textual.widgets.Static`][textual.widgets.Static] For simple content.
+- [`textual.widgets.Button`][textual.widgets.Button] For a clickable button.
```python hl_lines="12 13 14 15 16 17 18 19 20" title="dom3.py"
--8<-- "docs/examples/guide/dom3.py"
```
-We've added a Container to our DOM which (as the name suggests) is a container for other widgets. The container has a number of other widgets passed as positional arguments which will be added as the children of the container. Not all widgets accept child widgets in this way. A Button widget doesn't require any children, for example.
+We've added a Container to our DOM which (as the name suggests) contains other widgets. The container has a number of other widgets passed as positional arguments which will be added as the children of the container. Not all widgets accept child widgets in this way. A Button widget doesn't require any children, for example.
Here's the DOM created by the above code:
@@ -139,7 +139,7 @@ You may recognize some elements in the above screenshot, but it doesn't quite lo
To add a stylesheet set the `CSS_PATH` classvar to a relative path:
-!!! note
+!!! note "What are TCSS files?"
Textual CSS files are typically given the extension `.tcss` to differentiate them from browser CSS (`.css`).
@@ -223,7 +223,7 @@ Static {
}
```
-!!! note
+!!! note "This is different to browser CSS"
The fact that the type selector matches base classes is a departure from browser CSS which doesn't have the same concept.
@@ -312,6 +312,18 @@ For example, the following will draw a red outline around all widgets:
}
```
+While it is rare to need to style all widgets, you can combine the universal selector with a parent, to select all children of that parent.
+
+For instance, here's how we would make all children of a `VerticalScroll` have a red background:
+
+```css
+VerticalScroll * {
+ background: red;
+}
+```
+
+See [Combinators](#combinators) for more details on combining selectors like this.
+
### Pseudo classes
Pseudo classes can be used to match widgets in a particular state. Pseudo classes are set automatically by Textual. For instance, you might want a button to have a green background when the mouse cursor moves over it. We can do this with the `:hover` pseudo selector.
@@ -403,7 +415,7 @@ It is possible that several selectors match a given widget. If the same style is
The specificity rules are usually enough to fix any conflicts in your stylesheets. There is one last way of resolving conflicting selectors which applies to individual rules. If you add the text `!important` to the end of a rule then it will "win" regardless of the specificity.
-!!! warning
+!!! warning "If everything is Important, nothing is Important"
Use `!important` sparingly (if at all) as it can make it difficult to modify your CSS in the future.
@@ -445,7 +457,7 @@ This will be translated into:
Variables allow us to define reusable styling in a single place.
If we decide we want to change some aspect of our design in the future, we only have to update a single variable.
-!!! note
+!!! note "Where can variables be used?"
Variables can only be used in the _values_ of a CSS declaration. You cannot, for example, refer to a variable inside a selector.
@@ -576,3 +588,6 @@ If we were to add other selectors for additional screens or widgets, it would be
### Why use nesting?
There is no requirement to use nested CSS, but it can help to group related rule sets together (which makes it easier to edit). Nested CSS can also help you avoid some repetition in your selectors, i.e. in the nested CSS we only need to type `#questions` once, rather than four times in the non-nested CSS.
+
+Nesting CSS will also make rules that are *more* specific.
+This is useful if you find your rules are applying to widgets that you didn't intend.
diff --git a/docs/guide/actions.md b/docs/guide/actions.md
index 312ca53c8e..d724b0765c 100644
--- a/docs/guide/actions.md
+++ b/docs/guide/actions.md
@@ -56,9 +56,9 @@ Consequently `"set_background('blue')"` is a valid action string, but `"set_back
## Links
-Actions may be embedded as links within console markup. You can create such links with a `@click` tag.
+Actions may be embedded in [markup](./content.md#actions) with the `@click` tag.
-The following example mounts simple static text with embedded action links.
+The following example mounts simple static text with embedded action links:
=== "actions03.py"
diff --git a/docs/guide/app.md b/docs/guide/app.md
index a39df1b245..c64716a344 100644
--- a/docs/guide/app.md
+++ b/docs/guide/app.md
@@ -34,9 +34,6 @@ When you call [App.run()][textual.app.App.run] Textual puts the terminal into a
If you hit ++ctrl+q++ Textual will exit application mode and return you to the command prompt. Any content you had in the terminal prior to application mode will be restored.
-!!! tip
-
- A side effect of application mode is that you may no longer be able to select and copy text in the usual way. Terminals typically offer a way to bypass this limit with a key modifier. On iTerm you can select text if you hold the ++option++ key. See the documentation for your terminal software for how to select text in application mode.
#### Run inline
@@ -65,14 +62,10 @@ We recommend the default behavior for full-screen apps, but you may want to pres
## Events
-Textual has an event system you can use to respond to key presses, mouse actions, and internal state changes. Event handlers are methods prefixed with `on_` followed by the name of the event.
+Textual has an [event system](./events.md) you can use to respond to key presses, mouse actions, and internal state changes. Event handlers are methods prefixed with `on_` followed by the name of the event.
One such event is the *mount* event which is sent to an application after it enters application mode. You can respond to this event by defining a method called `on_mount`.
-!!! info
-
- You may have noticed we use the term "send" and "sent" in relation to event handler methods in preference to "calling". This is because Textual uses a message passing system where events are passed (or *sent*) between components. See [events](./events.md) for details.
-
Another such event is the *key* event which is sent when the user presses a key. The following example contains handlers for both those events:
```python title="event01.py"
@@ -84,13 +77,12 @@ The `on_mount` handler sets the `self.screen.styles.background` attribute to `"d
```{.textual path="docs/examples/app/event01.py" hl_lines="23-25"}
```
-The key event handler (`on_key`) has an `event` parameter which will receive a [Key][textual.events.Key] instance. Every event has an associated event object which will be passed to the handler method if it is present in the method's parameter list.
+When you press a key, the key event handler (`on_key`) which will receive a [Key][textual.events.Key] instance.
+If you don't require the event in your handler, you can omit it.
-!!! note
-
- It is unusual (but not unprecedented) for a method's parameters to affect how it is called. Textual accomplishes this by inspecting the method prior to calling it.
-
-Some events contain additional information you can inspect in the handler. The [Key][textual.events.Key] event has a `key` attribute which is the name of the key that was pressed. The `on_key` method above uses this attribute to change the background color if any of the keys from ++0++ to ++9++ are pressed.
+Events may contain additional information which you can inspect in the handler.
+In the case of the [Key][textual.events.Key] event, there is a `key` attribute which is the name of the key that was pressed.
+The `on_key` method above uses this attribute to change the background color if any of the keys from ++0++ to ++9++ are pressed.
### Async events
@@ -224,7 +216,7 @@ You may have noticed that we subclassed `App[str]` rather than the usual `App`.
The addition of `[str]` tells mypy that `run()` is expected to return a string. It may also return `None` if [App.exit()][textual.app.App.exit] is called without a return value, so the return type of `run` will be `str | None`. Replace the `str` in `[str]` with the type of the value you intend to call the exit method with.
-!!! note
+!!! note "Typing in Textual"
Type annotations are entirely optional (but recommended) with Textual.
@@ -317,7 +309,7 @@ For example:
## CSS
-Textual apps can reference [CSS](CSS.md) files which define how your app and widgets will look, while keeping your Python code free of display related code (which tends to be messy).
+Textual apps can reference [CSS](CSS.md) files which define how your app and widgets will look, while keeping your project free of messy display related code.
!!! info
diff --git a/docs/guide/content.md b/docs/guide/content.md
new file mode 100644
index 0000000000..a574f11603
--- /dev/null
+++ b/docs/guide/content.md
@@ -0,0 +1,422 @@
+# Content
+
+The *content* of widget (displayed within the widget's borders) is typically specified in a call to [`Static.update`][textual.widgets.Static.update] or returned from [`render()`][textual.widget.Widget.render] in the case of [custom widgets](./widgets.md#custom-widgets).
+
+There are a few ways for you to specify this content.
+
+- Text — a string containing [markup](#markup).
+- [Content](#content-class) objects — for more advanced control over output.
+- Rich renderables — any object that may be printed with [Rich](https://rich.readthedocs.io/en/latest/).
+
+In this chapter, we will cover all these methods.
+
+## Markup
+
+When building a custom widget you can embed color and style information in the string returned from the Widget's [`render()`][textual.widget.Widget.render] method.
+Markup is specified as a string which contains
+Text enclosed in square brackets (`[]`) won't appear in the output, but will modify the style of the text that follows.
+This is known as *Textual markup*.
+
+Before we explore Textual markup in detail, let's first demonstrate some of what it can do.
+In the following example, we have two widgets.
+The top has Textual markup enabled, while the bottom widget has Textual markup *disabled*.
+
+Notice how the markup *tags* change the style in the first widget, but are left unaltered in the second:
+
+
+=== "Output"
+
+ ```{.textual path="docs/examples/guide/content/content01.py"}
+ ```
+
+=== "content01.py"
+
+ ```python
+ --8<-- "docs/examples/guide/content/content01.py"
+ ```
+
+ 1. With `markup=False`, tags have no effect and left in the output.
+
+
+### Playground
+
+Textual comes with a markup playground where you can enter Textual markup and see the result's live.
+To launch the playground, run the following command:
+
+```
+python -m textual.markup
+```
+
+You can experiment with markup by entering it in to the textarea at the top of the terminal, and seeing the results in the lower pane:
+
+```{.textual path="docs/examples/guide/content/playground.py", type="[i]Hello!"] lines=16}
+```
+
+You might find it helpful to try out some of the examples from this guide in the playground.
+
+!!! note "What are Variables?"
+
+ You may have noticed the "Variables" tab. This allows you to experiment with [variable substitution](#markup-variables).
+
+### Tags
+
+There are two types of tag: an *opening* tag which starts a style change, and a *closing* tag which ends a style change.
+An opening tag looks like this:
+
+```
+[bold]
+```
+
+
+The second type of tag, known as a *closing* tag, is almost identical, but starts with a forward slash inside the first square bracket.
+A closing tag looks like this:
+
+```
+[/bold]
+```
+
+A closing tag marks the end of a style from the corresponding opening tag.
+
+By wrapping text in an opening and closing tag, we can apply the style to just the characters we want.
+For example, the following makes just the first word in "Hello, World!" bold:
+
+```
+[bold]Hello[/bold], World!
+```
+
+Note how the tags change the style but are removed from the output:
+
+```{.textual path="docs/examples/guide/content/playground.py", type="[bold]Hello[/bold], World!" lines=16}
+```
+
+You can use any number of tags.
+If tags overlap their styles are combined.
+For instance, the following combines the bold and italic styles:
+
+```
+[bold]Bold [italic]Bold and italic[/italic][/bold]
+```
+
+Here's the output:
+
+```{.textual path="docs/examples/guide/content/playground.py", type="[bold]Bold [italic]Bold and italic[/italic][/bold]" lines=16}
+```
+
+#### Auto-closing tags
+
+A closing tag without any style information (i.e. `[/]`) is an *auto-closing* tag.
+Auto-closing tags will close the last opened tag.
+
+The following uses an auto-closing tag to end the bold style:
+
+```
+[bold]Hello[/], World!
+```
+
+This is equivalent to the following (but saves typing a few characters):
+
+```
+[bold]Hello[/bold], World!
+```
+
+Auto-closing tags recommended when it is clear which tag they are intended to close.
+
+### Styles
+
+Tags may contain any number of the following values:
+
+| Style | Abbreviation | Description |
+| ----------- | ------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| `bold` | `b` | **Bold text** |
+| `dim` | `d` | Dim text (slightly transparent) |
+| `italic` | `i` | *Italic text* |
+| `underline` | `u` | Underlined text |
+| `strike` | `s` | Strikethrough text |
+| `reverse` | `r` | Reversed colors text (background swapped with foreground) |
+
+These styles can be abbreviate to save typing.
+For example `[bold]` and `[b]` are equivalent.
+
+Styles can also be combined within the same tag, so `[bold italic]` produces text that is both bold *and* italic.
+
+#### Inverting styles
+
+You can invert a style by preceding it with the word `not`.
+This is useful if you have text with a given style, but you temporarily want to disable it.
+
+For instance, the following starts with `[bold]`, which would normally make the rest of the text bold.
+However, the `[not bold]` tag disables bold until the corresponding `[/not bold]` tag:
+
+```
+[bold]This is bold [not bold]This is not bold[/not bold] This is bold.
+```
+
+Here's what this markup will produce:
+
+```{.textual path="docs/examples/guide/content/playground.py" lines=16 type="[bold]This is bold [not bold]This is not bold[/not bold] This is bold."]}
+```
+
+### Colors
+
+Colors may specified in the same way as a CSS [<color>](/css_types/color).
+Here are a few examples:
+
+```
+[#ff0000]HTML hex style[/]
+[rgba(0,255,0)]HTML RGB style[/]
+
+```
+
+You can also any of the [named colors](/css_types/color).
+
+```
+[chartreuse]This is a green color[/]
+[sienna]This is a kind of yellow-brown.[/]
+```
+
+Colors may also include an *alpha* component, which makes the color fade in to the background.
+For instance, if we specify the color with `rgba(...)`, then we can add an alpha component between 0 and 1.
+An alpha of 0 is fully transparent (and therefore invisible). An alpha of 1 is fully opaque, and equivalent to a color without an alpha component.
+A value between 0 and 1 results in a faded color.
+
+In the following example we have an alpha of 0.5, which will produce a color half way between the background and solid green:
+
+```
+[rgba(0, 255, 0, 0.5)]Faded green (and probably hard to read)[/]
+```
+
+Here's the output:
+
+```{.textual path="docs/examples/guide/content/playground.py", type="[rgba(0, 255, 0, 0.5)]Faded green (and probably hard to read)[/]" lines=16}
+```
+
+!!! warning
+
+ Be careful when using colors with an alpha component. Text that is blended too much with the background may become hard to read.
+
+
+#### Auto colors
+
+You can also specify a color as "auto", which is a special value that tells Textual to pick either white or black text -- whichever has the best contrast.
+
+For example, the following will produce either white or black text (I haven't checked) on a sienna background:
+
+```
+[auto on sienna]This should be fairly readable.
+```
+
+
+#### Opacity
+
+While you can set the opacity in the color itself by adding an alpha component to the color, you can also modify the alpha of the previous color with a percentage.
+
+For example, the addition of `50%` will result in a color half way between the background and "red":
+
+```
+[red 50%]This is in faded red[/]
+```
+
+
+#### Background colors
+
+Background colors may be specified by preceding a color with the world `on`.
+Here's an example:
+
+```
+[on #ff0000]Background is bright red.
+```
+
+Background colors may also have an alpha component (either in the color itself or with a percentage).
+This will result in a color that is blended with the widget's parent (or Screen).
+
+Here's an example that tints the background with 20% red:
+
+```
+[on #ff0000 20%]The background has a red tint.[/]
+```
+
+Here's the output:
+
+```{.textual path="docs/examples/guide/content/playground.py" lines=15 type="[on #ff0000 20%]The background has a red tint.[/]"]}
+```
+
+
+### CSS variables
+
+You can also use CSS variables in markup, such as those specified in the [design](./design.md#base-colors) guide.
+
+To use any of the theme colors, simple use the name of the color including the `$` at the first position.
+For example, this will display text in the *accent* color:
+
+```
+[$accent]Accent color[/]
+```
+
+You may also use a color variable in the background position.
+The following displays text in the 'warning' style on a muted 'warning' background for emphasis:
+
+```
+[$warning on $warning-muted]This is a warning![/]
+```
+
+Here's the result of that markup:
+
+```{.textual path="docs/examples/guide/content/playground.py" lines=16 type="[$warning on $warning-muted]This is a warning![/]"]}
+```
+
+### Links
+
+Styles may contain links which will create clickable links that launch your web browser, if supported by your terminal.
+
+To create a link add `link=` followed by your link in quotes (single or double).
+For instance, the following create a clickable link:
+
+```
+[link="https://www.willmcgugan.com"]Visit my blog![/link]
+```
+
+This will produce the following output:
+
+
+### Actions
+
+In addition to links, you can also markup content that runs [actions](./actions.md) when clicked.
+To do this create a style that starts with `@click=` and is followed by the action you wish to run.
+
+For instance, the following will highlight the word "bell", which plays the terminal bell sound when click:
+
+```
+Play the [@click=app.bell]bell[/]
+```
+
+Here's what it looks like:
+
+```{.textual path="docs/examples/guide/content/playground.py" lines=16 type="Play the [@click=app.bell]bell[/]"]}
+```
+
+We've used an [auto-closing](#auto-closing-tags) to close the click action here.
+If you do need to close the tag explicitly, you can omit the action:
+
+```
+Play the [@click=app.bell]bell[/@click=]
+```
+
+Actions may be combined with other styles, so you could set the style of the clickable link:
+
+```
+Play the [on $success 30% @click=app.bell]bell[/]
+```
+
+Here's what that looks like:
+
+```{.textual path="docs/examples/guide/content/playground.py" lines=16 type="Play the [on $success 30% @click=app.bell]bell[/]"]}
+```
+
+
+## Content class
+
+Under the hood, Textual will convert markup into a [Content][textual.content.Content] instance.
+You can also return a Content object directly from `render()`.
+This can give you more flexibility beyond the markup.
+
+To clarify, here's a render method that returns a string with markup:
+
+```python
+class WelcomeWidget(Widget):
+ def render(self) -> RenderResult:
+ return "[b]Hello, World![/b]"
+```
+
+This is roughly the equivalent to the following code:
+
+```python
+class WelcomeWidget(Widget):
+ def render(self) -> RenderResult:
+ return Content.from_markup("[b]Hello, World![/b]")
+```
+
+### Constructing content
+
+The [Content][textual.content.Content] class accepts a default string in it's constructor.
+
+Here's an example:
+
+```python
+Content("hello, World!")
+```
+
+Note that if you construct Content in this way, it *won't* process markup (any square brackets will be displayed literally).
+
+If you want markup, you can create a `Content` with the [Content.from_markup][textual.content.Content.from_markup] alternative constructor:
+
+```python
+Content.from_markup("hello, [bold]World[/bold]!")
+```
+
+### Styling content
+
+You can add styles to content with the [stylize][textual.content.Content.stylize] or [stylize_before][textual.content.Content.stylize] methods.
+
+For instance, in the following code we create content with the text "Hello, World!" and style "World" to be bold:
+
+```python
+content = Content("Hello, World!")
+content = content.stylize(7, 12, "bold")
+```
+
+Note that `Content` is *immutable* and methods will return new instances rather than updating the current instance.
+
+
+### Markup variables
+
+You may be tempted to combine markup with Python's f-strings (or other string template system).
+Something along these lines:
+
+```python
+class WelcomeWidget(Widget):
+ def render(self) -> RenderResult:
+ name = "Will"
+ return f"Hello [bold]{name}[/bold]!"
+```
+
+While this is straightforward and intuitive, it can potentially break in subtle ways.
+If the 'name' variable contains square brackets, these may be interpreted as markup.
+For instance if the user entered their name at some point as "[magenta italic]Will" then your app will display those styles where you didn't intend them to be.
+
+We can avoid this problem by relying on the [Content.from_markup][textual.content.Content.from_markup] method to insert the variables for us.
+If you supply variables as keyword arguments, these will be substituted in the markup using the same syntax as [string.Template](https://docs.python.org/3/library/string.html#template-strings).
+Any square brackets in the variables will be present in the output, but won't change the styles.
+
+Here's how we can fix the previous example:
+
+```python
+return Content.from_markup("hello [bold]$name[/bold]!", name=name)
+```
+
+You can experiment with this feature by entering a dictionary of variables in the variables text-area.
+
+Here's what that looks like:
+
+```{.textual path="docs/examples/guide/content/playground.py" lines=20 columns=110 type='hello [bold]$name[/bold]!\t{"name": "[magenta italic]Will"}\t']}
+```
+
+## Rich renderables
+
+Textual supports Rich *renderables*, which means you can display any object that works with Rich, such as Rich's [Text](https://rich.readthedocs.io/en/latest/text.html) object.
+
+The Content class is preferred for simple text, as it supports more of Textual's features.
+But you can display any of the objects in the [Rich library](https://github.com/Textualize/rich) (or ecosystem) within a widget.
+
+Here's an example which displays its own code using Rich's [Syntax](https://rich.readthedocs.io/en/latest/syntax.html) object.
+
+=== "Output"
+
+ ```{.textual path="docs/examples/guide/content/renderables.py"}
+ ```
+
+=== "renderables.py"
+
+ ```python
+ --8<-- "docs/examples/guide/content/renderables.py"
+ ```
+
diff --git a/docs/guide/styles.md b/docs/guide/styles.md
index 7150ffd26b..c251f9faf4 100644
--- a/docs/guide/styles.md
+++ b/docs/guide/styles.md
@@ -134,7 +134,7 @@ This code produces the following result.
```{.textual path="docs/examples/guide/styles/dimensions01.py"}
```
-Note how the text wraps in the widget, and is cropped because it doesn't fit in the space provided.
+Note how the text wraps, but doesn't fit in the 10 lines provided, resulting in the last line being omitted entirely.
#### Auto dimensions
@@ -189,7 +189,7 @@ With the width set to `"50%"` and the height set to `"80%"`, the widget will kee
Percentage units can be problematic for some relative values. For instance, if we want to divide the screen into thirds, we would have to set a dimension to `33.3333333333%` which is awkward. Textual supports `fr` units which are often better than percentage-based units for these situations.
-When specifying `fr` units for a given dimension, Textual will divide the available space by the sum of the `fr` units on that dimension. That space will then be divided amongst the widgets as a proportion of their individual `fr` values.
+When specifying `fr` units for a given dimension, Textual will divide the available space by the sum of the `fr` units for that dimension. That space is then assigned according to each widget's `fr` values.
Let's look at an example. We will create two widgets, one with a height of `"2fr"` and one with a height of `"1fr"`.
@@ -197,14 +197,18 @@ Let's look at an example. We will create two widgets, one with a height of `"2fr
--8<-- "docs/examples/guide/styles/dimensions04.py"
```
-The total `fr` units for height is 3. The first widget will have a screen height of two thirds because its height style is set to `2fr`. The second widget's height style is `1fr` so its screen height will be one third. Here's what that looks like.
+The total `fr` units for height is 3.
+The first widget has a height ot `2fr`, which results in the height being two thirds of the total height.
+The second widget has a height of `1fr` which makes it take up the remaining third of the height.
+Here's what that looks like.
```{.textual path="docs/examples/guide/styles/dimensions04.py"}
```
### Maximum and minimums
-The same units may also be used to set limits on a dimension. The following styles set minimum and maximum sizes and can accept any of the values used in width and height.
+The same units may also be used to set limits on a dimension.
+The following styles set minimum and maximum sizes and can accept any of the values used in width and height.
- [min-width](../styles/min_width.md) sets a minimum width.
- [max-width](../styles/max_width.md) sets a maximum width.
@@ -345,12 +349,12 @@ Notice how each widget has an additional two rows and columns around the border.
```{.textual path="docs/examples/guide/styles/margin01.py"}
```
-!!! note
+!!! note "Margins overlap"
In the above example both widgets have a margin of 2, but there are only 2 lines of space between the widgets. This is because margins of consecutive widgets *overlap*. In other words when there are two widgets next to each other Textual picks the greater of the two margins.
## More styles
-We've covered the most fundamental styles used by Textual apps, but there are many more which you can use to customize many aspects of how your app looks. See the [Styles reference](../styles/index.md) for a comprehensive list.
+We've covered some fundamental styles used by Textual apps, but there are many more which you can use to customize all aspects of how your app looks. See the [Styles reference](../styles/index.md) for a comprehensive list.
In the next chapter we will discuss Textual CSS which is a powerful way of applying styles to widgets that keeps your code free of style attributes.
diff --git a/docs/guide/widgets.md b/docs/guide/widgets.md
index 7587397c89..4c83316ffe 100644
--- a/docs/guide/widgets.md
+++ b/docs/guide/widgets.md
@@ -24,9 +24,12 @@ Let's create a simple custom widget to display a greeting.
--8<-- "docs/examples/guide/widgets/hello01.py"
```
-The highlighted lines define a custom widget class with just a [render()][textual.widget.Widget.render] method. Textual will display whatever is returned from render in the content area of your widget. We have returned a string in the code above, but there are other possible return types which we will cover later.
+The highlighted lines define a custom widget class with just a [render()][textual.widget.Widget.render] method.
+Textual will display whatever is returned from render in the [content](./content.md) area of your widget.
-Note that the text contains tags in square brackets, i.e. `[b]`. This is [console markup](https://rich.readthedocs.io/en/latest/markup.html) which allows you to embed various styles within your content. If you run this you will find that `World` is in bold.
+Note that the text contains tags in square brackets, i.e. `[b]`.
+This is [Textual markup](./content.md#markup) which allows you to embed various styles within your content.
+If you run this you will find that `World` is in bold.
```{.textual path="docs/examples/guide/widgets/hello01.py"}
```
@@ -118,15 +121,16 @@ CSS defined within `DEFAULT_CSS` has an automatically lower [specificity](./CSS.
## Text links
-Text in a widget may be marked up with links which perform an action when clicked. Links in console markup use the following format:
+Text in a widget may be marked up with links which perform an action when clicked.
+Links in markup use the following format:
```
-"Click [@click='app.bell']Me[/]"
+"Click [@click=app.bell]Me[/]"
```
The `@click` tag introduces a click handler, which runs the `app.bell` action.
-Let's use markup links in the hello example so that the greeting becomes a link which updates the widget.
+Let's use links in the hello example so that the greeting becomes a link which updates the widget.
=== "hello05.py"
diff --git a/docs/snippets/syntax_block_end.md b/docs/snippets/syntax_block_end.md
index a100faef8d..40edfcbfd6 100644
--- a/docs/snippets/syntax_block_end.md
+++ b/docs/snippets/syntax_block_end.md
@@ -1,2 +1,2 @@
-
+
diff --git a/docs/snippets/syntax_block_start.md b/docs/snippets/syntax_block_start.md
index 3eddc9775e..5000971dda 100644
--- a/docs/snippets/syntax_block_start.md
+++ b/docs/snippets/syntax_block_start.md
@@ -1,2 +1,2 @@
-
+
diff --git a/docs/styles/text_overflow.md b/docs/styles/text_overflow.md
new file mode 100644
index 0000000000..018a152a82
--- /dev/null
+++ b/docs/styles/text_overflow.md
@@ -0,0 +1,73 @@
+# Text-overflow
+
+The `text-overflow` style defines what happens when text *overflows*.
+
+Text overflow occurs when there is not enough space to fit the text on a line.
+This may happen if wrapping is disabled (via [text-wrap](./text_wrap.md)) or if a single word is too large to fit within the width of its container.
+
+## Syntax
+
+--8<-- "docs/snippets/syntax_block_start.md"
+text-overflow: clip | fold | ellipsis;
+--8<-- "docs/snippets/syntax_block_end.md"
+
+### Values
+
+| Value | Description |
+| ---------- | ---------------------------------------------------------------------------------------------------- |
+| `clip` | Overflowing text will be clipped (the overflow portion is removed from the output). |
+| `fold` | Overflowing text will fold on to the next line(s). |
+| `ellipsis` | Overflowing text will be truncated and the last visible character will be replaced with an ellipsis. |
+
+
+## Example
+
+In the following example we show the output of each of the values of `text_overflow`.
+
+The widgets all have [text wrapping](./text_wrap.md) disabled, which will cause the
+example string to overflow as it is longer than the available width.
+
+In the first (top) widget, `text-overflow` is set to "clip" which clips any text that is overflowing, resulting in a single line.
+
+In the second widget, `text-overflow` is set to "fold", which causes the overflowing text to *fold* on to the next line.
+When text folds like this, it won't respect word boundaries--so you may get words broken across lines.
+
+In the third widget, `text-overflow` is set to "ellipsis", which is similar to "clip", but with the last character set to an ellipsis.
+This option is useful to indicate to the user that there may be more text.
+
+=== "Output"
+
+ ```{.textual path="docs/examples/styles/text_overflow.py"}
+ ```
+
+=== "text_overflow.py"
+
+ ```py
+ --8<-- "docs/examples/styles/text_overflow.py"
+ ```
+
+=== "text_overflow.tcss"
+
+ ```css
+ --8<-- "docs/examples/styles/text_overflow.tcss"
+ ```
+
+
+### CSS
+
+```css
+#widget {
+ text-overflow: ellipsis;
+}
+```
+
+### Python
+
+```py
+widget.styles.text_overflow = "ellipsis"
+```
+
+
+## See also
+
+ - [`text-wrap`](./text_wrap.md) which is used to enable or disable wrapping.
diff --git a/docs/styles/text_wrap.md b/docs/styles/text_wrap.md
new file mode 100644
index 0000000000..0b0975e80d
--- /dev/null
+++ b/docs/styles/text_wrap.md
@@ -0,0 +1,61 @@
+# Text-wrap
+
+The `text-wrap` style set how Textual should wrap text.
+The default value is "wrap" which will word-wrap text.
+You can also set this style to "nowrap" which will disable wrapping entirely.
+
+## Syntax
+
+--8<-- "docs/snippets/syntax_block_start.md"
+text-wrap: wrap | nowrap;
+--8<-- "docs/snippets/syntax_block_end.md"
+
+
+## Example
+
+In the following example we have two pieces of text.
+
+The first (top) text has the default value for `text-wrap` ("wrap") which will cause text to be word wrapped as normal.
+The second has `text-wrap` set to "nowrap" which disables text wrapping and results in a single line.
+
+=== "Output"
+
+ ```{.textual path="docs/examples/styles/text_wrap.py"}
+ ```
+
+=== "text_wrap.py"
+
+ ```py
+ --8<-- "docs/examples/styles/text_wrap.py"
+ ```
+
+=== "text_wrap.tcss"
+
+ ```css
+ --8<-- "docs/examples/styles/text_wrap.tcss"
+ ```
+
+
+## CSS
+
+
+```css
+text-wrap: wrap;
+text-wrap: nowrap;
+```
+
+
+## Python
+
+
+```py
+widget.styles.text_wrap = "wrap"
+widget.styles.text_wrap = "nowrap"
+```
+
+
+
+## See also
+
+ - [`text-overflow`](./text_overflow.md) to set what happens to text that overflows the available width.
+
diff --git a/docs/tutorial.md b/docs/tutorial.md
index 2ae05bf75d..7435788cd2 100644
--- a/docs/tutorial.md
+++ b/docs/tutorial.md
@@ -195,6 +195,9 @@ The `TimeDisplay` is currently very simple, all it does is extend `Digits` witho
The `Stopwatch` widget class extends the `HorizontalGroup` container class, which will arrange its children into a horizontal row. The Stopwatch's `compose()` adds those children, which correspond to the components from the sketch above.
+!!! tip "Coordinating widgets"
+
+ If you are building custom widgets of your own, be sure to see guide on [coordinating widgets](./guide/widgets.md#coordinating-widgets).
#### The buttons
diff --git a/docs/widgets/rich_log.md b/docs/widgets/rich_log.md
index 5f373218fd..8824dd2dfa 100644
--- a/docs/widgets/rich_log.md
+++ b/docs/widgets/rich_log.md
@@ -33,7 +33,7 @@ The example below shows an application showing a `RichLog` with different kinds
| Name | Type | Default | Description |
| ----------- | ------ | ------- | ------------------------------------------------------------ |
| `highlight` | `bool` | `False` | Automatically highlight content. |
-| `markup` | `bool` | `False` | Apply Rich console markup. |
+| `markup` | `bool` | `False` | Apply markup. |
| `max_lines` | `int` | `None` | Maximum number of lines in the log or `None` for no maximum. |
| `min_width` | `int` | 78 | Minimum width of renderables. |
| `wrap` | `bool` | `False` | Enable word wrapping. |
diff --git a/mkdocs-nav.yml b/mkdocs-nav.yml
index ad3907fca5..7432b8ab42 100644
--- a/mkdocs-nav.yml
+++ b/mkdocs-nav.yml
@@ -10,14 +10,15 @@ nav:
- "guide/app.md"
- "guide/styles.md"
- "guide/CSS.md"
- - "guide/design.md"
- "guide/queries.md"
- "guide/layout.md"
- "guide/events.md"
- "guide/input.md"
- "guide/actions.md"
- "guide/reactivity.md"
+ - "guide/design.md"
- "guide/widgets.md"
+ - "guide/content.md"
- "guide/animation.md"
- "guide/screens.md"
- "guide/workers.md"
@@ -137,6 +138,8 @@ nav:
- "styles/scrollbar_size.md"
- "styles/text_align.md"
- "styles/text_opacity.md"
+ - "styles/text_overflow.md"
+ - "styles/text_wrap.md"
- "styles/text_style.md"
- "styles/tint.md"
- "styles/visibility.md"
diff --git a/poetry.lock b/poetry.lock
index 278fd3d3f7..94fc4cbf6b 100644
--- a/poetry.lock
+++ b/poetry.lock
@@ -269,13 +269,13 @@ uvloop = ["uvloop (>=0.15.2)"]
[[package]]
name = "cachecontrol"
-version = "0.14.1"
+version = "0.14.2"
description = "httplib2 caching for requests"
optional = false
python-versions = ">=3.8"
files = [
- {file = "cachecontrol-0.14.1-py3-none-any.whl", hash = "sha256:65e3abd62b06382ce3894df60dde9e0deb92aeb734724f68fa4f3b91e97206b9"},
- {file = "cachecontrol-0.14.1.tar.gz", hash = "sha256:06ef916a1e4eb7dba9948cdfc9c76e749db2e02104a9a1277e8b642591a0f717"},
+ {file = "cachecontrol-0.14.2-py3-none-any.whl", hash = "sha256:ebad2091bf12d0d200dfc2464330db638c5deb41d546f6d7aca079e87290f3b0"},
+ {file = "cachecontrol-0.14.2.tar.gz", hash = "sha256:7d47d19f866409b98ff6025b6a0fca8e4c791fb31abbd95f622093894ce903a2"},
]
[package.dependencies]
@@ -1165,13 +1165,13 @@ dev = ["click", "codecov", "mkdocs-gen-files", "mkdocs-git-authors-plugin", "mkd
[[package]]
name = "mkdocs-material"
-version = "9.5.49"
+version = "9.5.50"
description = "Documentation that simply works"
optional = false
python-versions = ">=3.8"
files = [
- {file = "mkdocs_material-9.5.49-py3-none-any.whl", hash = "sha256:c3c2d8176b18198435d3a3e119011922f3e11424074645c24019c2dcf08a360e"},
- {file = "mkdocs_material-9.5.49.tar.gz", hash = "sha256:3671bb282b4f53a1c72e08adbe04d2481a98f85fed392530051f80ff94a9621d"},
+ {file = "mkdocs_material-9.5.50-py3-none-any.whl", hash = "sha256:f24100f234741f4d423a9d672a909d859668a4f404796be3cf035f10d6050385"},
+ {file = "mkdocs_material-9.5.50.tar.gz", hash = "sha256:ae5fe16f3d7c9ccd05bb6916a7da7420cf99a9ce5e33debd9d40403a090d5825"},
]
[package.dependencies]
@@ -1188,7 +1188,7 @@ regex = ">=2022.4"
requests = ">=2.26,<3.0"
[package.extras]
-git = ["mkdocs-git-committers-plugin-2 (>=1.1,<2.0)", "mkdocs-git-revision-date-localized-plugin (>=1.2.4,<2.0)"]
+git = ["mkdocs-git-committers-plugin-2 (>=1.1,<3)", "mkdocs-git-revision-date-localized-plugin (>=1.2.4,<2.0)"]
imaging = ["cairosvg (>=2.6,<3.0)", "pillow (>=10.2,<11.0)"]
recommended = ["mkdocs-minify-plugin (>=0.7,<1.0)", "mkdocs-redirects (>=1.2,<2.0)", "mkdocs-rss-plugin (>=1.6,<2.0)"]
@@ -1720,13 +1720,13 @@ files = [
[[package]]
name = "pygments"
-version = "2.18.0"
+version = "2.19.1"
description = "Pygments is a syntax highlighting package written in Python."
optional = false
python-versions = ">=3.8"
files = [
- {file = "pygments-2.18.0-py3-none-any.whl", hash = "sha256:b8e6aca0523f3ab76fee51799c488e38782ac06eafcf95e7ba832985c8e7b13a"},
- {file = "pygments-2.18.0.tar.gz", hash = "sha256:786ff802f32e91311bff3889f6e9a86e81505fe99f2735bb6d60ae0c5004f199"},
+ {file = "pygments-2.19.1-py3-none-any.whl", hash = "sha256:9ea1544ad55cecf4b8242fab6dd35a93bbce657034b0611ee383099054ab6d8c"},
+ {file = "pygments-2.19.1.tar.gz", hash = "sha256:61c16d2a8576dc0649d9f39e089b5f02bcd27fba10d8fb4dcc28173f7a45151f"},
]
[package.extras]
@@ -1734,13 +1734,13 @@ windows-terminal = ["colorama (>=0.4.6)"]
[[package]]
name = "pymdown-extensions"
-version = "10.13"
+version = "10.14.1"
description = "Extension pack for Python Markdown."
optional = false
python-versions = ">=3.8"
files = [
- {file = "pymdown_extensions-10.13-py3-none-any.whl", hash = "sha256:80bc33d715eec68e683e04298946d47d78c7739e79d808203df278ee8ef89428"},
- {file = "pymdown_extensions-10.13.tar.gz", hash = "sha256:e0b351494dc0d8d14a1f52b39b1499a00ef1566b4ba23dc74f1eba75c736f5dd"},
+ {file = "pymdown_extensions-10.14.1-py3-none-any.whl", hash = "sha256:637951cbfbe9874ba28134fb3ce4b8bcadd6aca89ac4998ec29dcbafd554ae08"},
+ {file = "pymdown_extensions-10.14.1.tar.gz", hash = "sha256:b65801996a0cd4f42a3110810c306c45b7313c09b0610a6f773730f2a9e3c96b"},
]
[package.dependencies]
@@ -1748,7 +1748,7 @@ markdown = ">=3.6"
pyyaml = "*"
[package.extras]
-extra = ["pygments (>=2.12)"]
+extra = ["pygments (>=2.19.1)"]
[[package]]
name = "pytest"
@@ -2142,13 +2142,13 @@ files = [
[[package]]
name = "syrupy"
-version = "4.8.0"
+version = "4.8.1"
description = "Pytest Snapshot Test Utility"
optional = false
python-versions = ">=3.8.1"
files = [
- {file = "syrupy-4.8.0-py3-none-any.whl", hash = "sha256:544f4ec6306f4b1c460fdab48fd60b2c7fe54a6c0a8243aeea15f9ad9c638c3f"},
- {file = "syrupy-4.8.0.tar.gz", hash = "sha256:648f0e9303aaa8387c8365d7314784c09a6bab0a407455c6a01d6a4f5c6a8ede"},
+ {file = "syrupy-4.8.1-py3-none-any.whl", hash = "sha256:274f97cbaf44175f5e478a2f3a53559d31f41c66c6bf28131695f94ac893ea00"},
+ {file = "syrupy-4.8.1.tar.gz", hash = "sha256:8da8c0311e6d92de0b15767768c6ab98982b7b4a4c67083c08fbac3fbad4d44c"},
]
[package.dependencies]
@@ -2308,19 +2308,19 @@ core = ["tree-sitter (>=0.22,<1.0)"]
[[package]]
name = "tree-sitter-css"
-version = "0.23.1"
+version = "0.23.2"
description = "CSS grammar for tree-sitter"
optional = true
python-versions = ">=3.9"
files = [
- {file = "tree_sitter_css-0.23.1-cp39-abi3-macosx_10_9_x86_64.whl", hash = "sha256:6b38462fef7d14b0bfa6e542faab7d3cfd267b8dc138efcf6e2cee11f6988084"},
- {file = "tree_sitter_css-0.23.1-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:33239e810c518b27fa7b4592d31f6cb63c43d4ea55532b4eb346ac4c9974a7f4"},
- {file = "tree_sitter_css-0.23.1-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8c4cefceb654f89de8e79563d960f87b9a4680f288d87e20bacca7c339392070"},
- {file = "tree_sitter_css-0.23.1-cp39-abi3-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6524a5d097224128c9cda797c09f5704af0705e0ff272cf2f41ec192aa06aa62"},
- {file = "tree_sitter_css-0.23.1-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:2999be3784999ced8b8d6a4470f0aec28cdc42b31fd9861041a70c834a2c8850"},
- {file = "tree_sitter_css-0.23.1-cp39-abi3-win_amd64.whl", hash = "sha256:8824f079e7454491347eda4cdbf9cde606c4e5de518cc85bb69cd9dfd67b8982"},
- {file = "tree_sitter_css-0.23.1-cp39-abi3-win_arm64.whl", hash = "sha256:6ff44819511fe517f6d32f1d8a3563da30093ca155dd1198585819598e83d755"},
- {file = "tree_sitter_css-0.23.1.tar.gz", hash = "sha256:a5dadf23e201f05606feaa638d0e423050a3d56cea2324c8859857fbbc3f69e8"},
+ {file = "tree_sitter_css-0.23.2-cp39-abi3-macosx_10_9_x86_64.whl", hash = "sha256:62b9eadb8f47c666a36a2ead96d17c2a01d7599e1f13f69c617f08e4acf62bf0"},
+ {file = "tree_sitter_css-0.23.2-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:0be54e07f90679173bb06a8ecf483a7d79eaa6d236419b5baa6ce02401ea31a9"},
+ {file = "tree_sitter_css-0.23.2-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a4ac53c7d74fbb88196301f998a3ab06325447175374500aa477211a59372da2"},
+ {file = "tree_sitter_css-0.23.2-cp39-abi3-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0f51bf93f607581ec08c30c591a9274fb29b4b59a1bde4adee7d395de7687285"},
+ {file = "tree_sitter_css-0.23.2-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:6d13c0683d259d82bed17d00d788a8af026ffb3f412337e9971324742dcf2cc8"},
+ {file = "tree_sitter_css-0.23.2-cp39-abi3-win_amd64.whl", hash = "sha256:78236683eb974cc738969f70f1fb6d978ae375139b89cfe8efeaca4b865055be"},
+ {file = "tree_sitter_css-0.23.2-cp39-abi3-win_arm64.whl", hash = "sha256:4b95b7f53142029fca2abd3fcb635e3eb952bc198f340be5c429040c791f9c00"},
+ {file = "tree_sitter_css-0.23.2.tar.gz", hash = "sha256:04198e9f4dee4935dbf17fdd7f534be8b9a2dd3a4b44a3ca481d3e8c15f10dca"},
]
[package.extras]
@@ -2653,13 +2653,13 @@ zstd = ["zstandard (>=0.18.0)"]
[[package]]
name = "virtualenv"
-version = "20.28.1"
+version = "20.29.1"
description = "Virtual Python Environment builder"
optional = false
python-versions = ">=3.8"
files = [
- {file = "virtualenv-20.28.1-py3-none-any.whl", hash = "sha256:412773c85d4dab0409b83ec36f7a6499e72eaf08c80e81e9576bca61831c71cb"},
- {file = "virtualenv-20.28.1.tar.gz", hash = "sha256:5d34ab240fdb5d21549b76f9e8ff3af28252f5499fb6d6f031adac4e5a8c5329"},
+ {file = "virtualenv-20.29.1-py3-none-any.whl", hash = "sha256:4e4cb403c0b0da39e13b46b1b2476e505cb0046b25f242bee80f62bf990b2779"},
+ {file = "virtualenv-20.29.1.tar.gz", hash = "sha256:b8b8970138d32fb606192cb97f6cd4bb644fa486be9308fb9b63f81091b5dc35"},
]
[package.dependencies]
diff --git a/src/textual/_border.py b/src/textual/_border.py
index 7e35d7fc54..70d04aa95d 100644
--- a/src/textual/_border.py
+++ b/src/textual/_border.py
@@ -3,17 +3,17 @@
from functools import lru_cache
from typing import TYPE_CHECKING, Iterable, Tuple, cast
-from rich.console import Console
from rich.segment import Segment
-from rich.style import Style
-from rich.text import Text
from textual.color import Color
from textual.css.types import AlignHorizontal, EdgeStyle, EdgeType
+from textual.style import Style
if TYPE_CHECKING:
from typing_extensions import TypeAlias
+ from textual.content import Content
+
INNER = 1
OUTER = 2
@@ -288,10 +288,10 @@ def get_box(
outer = outer_style + style
styles = (
- inner,
- outer,
- Style.from_color(inner.color, outer.bgcolor) + REVERSE_STYLE,
- Style.from_color(outer.color, inner.bgcolor) + REVERSE_STYLE,
+ inner.rich_style,
+ outer.rich_style,
+ Style(outer.background, inner.foreground, reverse=True).rich_style,
+ Style(inner.background, outer.foreground, reverse=True).rich_style,
)
return (
@@ -314,14 +314,13 @@ def get_box(
def render_border_label(
- label: tuple[Text, Style],
+ label: tuple[Content, Style],
is_title: bool,
name: EdgeType,
width: int,
inner_style: Style,
outer_style: Style,
style: Style,
- console: Console,
has_left_corner: bool,
has_right_corner: bool,
) -> Iterable[Segment]:
@@ -357,16 +356,15 @@ def render_border_label(
text_label, label_style = label
- if not text_label.cell_len or width <= cells_reserved:
+ if not text_label.cell_length or width <= cells_reserved:
return
- text_label = text_label.copy()
- text_label.truncate(width - cells_reserved, overflow="ellipsis")
+ text_label = text_label.truncate(width - cells_reserved, ellipsis=True)
if has_left_corner:
- text_label.pad_left(1)
+ text_label = text_label.pad_left(1)
if has_right_corner:
- text_label.pad_right(1)
- text_label.stylize_before(label_style)
+ text_label = text_label.pad_right(1)
+ text_label = text_label.stylize_before(label_style)
label_style_location = BORDER_LABEL_LOCATIONS[name][0 if is_title else 1]
flip_top, flip_bottom = BORDER_TITLE_FLIP.get(name, (False, False))
@@ -380,19 +378,16 @@ def render_border_label(
elif label_style_location == 1:
base_style = outer
elif label_style_location == 2:
- base_style = Style.from_color(inner.color, outer.bgcolor) + REVERSE_STYLE
+ base_style = Style(outer.background, inner.foreground, reverse=True)
elif label_style_location == 3:
- base_style = Style.from_color(outer.color, inner.bgcolor) + REVERSE_STYLE
+ base_style = Style(inner.background, outer.foreground, reverse=True)
else:
assert False
if (flip_top and is_title) or (flip_bottom and not is_title):
- base_style = base_style.without_color + Style.from_color(
- base_style.bgcolor, base_style.color
- )
+ base_style = base_style.without_color + Style(reverse=True)
- text_label.stylize_before(base_style + label_style)
- segments = text_label.render(console)
+ segments = text_label.render_segments(base_style)
yield from segments
diff --git a/src/textual/_compositor.py b/src/textual/_compositor.py
index acea86e8b5..c2e1446371 100644
--- a/src/textual/_compositor.py
+++ b/src/textual/_compositor.py
@@ -896,22 +896,20 @@ def get_widget_and_offset_at(
if y >= widget.content_region.bottom:
x, y = widget.content_region.bottom_right_inclusive
- x -= region.x
- y -= region.y
+ gutter_left, gutter_right = widget.gutter.top_left
+ x -= region.x + gutter_left
+ y -= region.y + gutter_right
visible_screen_stack.set(widget.app._background_screens)
- lines = widget.render_lines(Region(0, y, region.width, 1))
+ line = widget.render_line(y)
- if not lines:
- return widget, None
end = 0
start = 0
-
offset_y: int | None = None
offset_x = 0
offset_x2 = 0
- for segment in lines[0]:
+ for segment in line:
end += len(segment.text)
style = segment.style
if style is not None and style._meta is not None:
diff --git a/src/textual/_doc.py b/src/textual/_doc.py
index c51772d160..c952b82d1b 100644
--- a/src/textual/_doc.py
+++ b/src/textual/_doc.py
@@ -24,7 +24,10 @@ def format_svg(source, language, css_class, options, md, attrs, **kwargs) -> str
path = cmd[0]
_press = attrs.get("press", None)
+ _type = attrs.get("type", None)
press = [*_press.split(",")] if _press else []
+ if _type is not None:
+ press.extend(_type.replace("\\t", "\t"))
title = attrs.get("title")
print(f"screenshotting {path!r}")
diff --git a/src/textual/_markup_playground.py b/src/textual/_markup_playground.py
new file mode 100644
index 0000000000..720e2d68d4
--- /dev/null
+++ b/src/textual/_markup_playground.py
@@ -0,0 +1,122 @@
+import json
+
+from textual import containers, events, on
+from textual.app import App, ComposeResult
+from textual.content import Content
+from textual.reactive import reactive
+from textual.widgets import Static, TextArea
+
+
+class MarkupPlayground(App):
+
+ TITLE = "Markup Playground"
+ CSS = """
+ Screen {
+ & > * {
+ margin: 0 1;
+ height: 1fr;
+ }
+ layout: vertical;
+ #editor {
+ width: 2fr;
+ height: 1fr;
+ border: tab $foreground 50%;
+ padding: 1;
+ margin: 1 1 0 0;
+ &:focus {
+ border: tab $primary;
+ }
+
+ }
+ #variables {
+ width: 1fr;
+ height: 1fr;
+ border: tab $foreground 50%;
+ padding: 1;
+ margin: 1 0 0 1;
+ &:focus {
+ border: tab $primary;
+ }
+ }
+ #variables.-bad-json {
+ border: tab $error;
+ }
+ #results-container {
+ border: tab $success;
+ &.-error {
+ border: tab $error;
+ }
+ overflow-y: auto;
+ }
+ #results {
+
+ padding: 1 1;
+ }
+ }
+ """
+ AUTO_FOCUS = "#editor"
+
+ variables: reactive[dict[str, object]] = reactive({})
+
+ def compose(self) -> ComposeResult:
+ with containers.HorizontalGroup():
+ yield (editor := TextArea(id="editor"))
+ yield (variables := TextArea("", id="variables", language="json"))
+ editor.border_title = "Markup"
+ variables.border_title = "Variables (JSON)"
+
+ with containers.VerticalScroll(
+ id="results-container", can_focus=False
+ ) as container:
+ yield Static(id="results")
+ container.border_title = "Output"
+
+ @on(TextArea.Changed, "#editor")
+ def on_markup_changed(self, event: TextArea.Changed) -> None:
+ self.update_markup()
+
+ def update_markup(self) -> None:
+ results = self.query_one("#results", Static)
+ editor = self.query_one("#editor", TextArea)
+ try:
+ content = Content.from_markup(editor.text, **self.variables)
+ results.update(content)
+ except Exception as error:
+ from rich.traceback import Traceback
+
+ results.update(Traceback())
+
+ self.query_one("#results-container").add_class("-error").scroll_end(
+ animate=False
+ )
+ else:
+ self.query_one("#results-container").remove_class("-error")
+
+ def watch_variables(self, variables: dict[str, object]) -> None:
+ self.update_markup()
+
+ @on(TextArea.Changed, "#variables")
+ def on_variables_change(self, event: TextArea.Changed) -> None:
+ variables_text_area = self.query_one("#variables", TextArea)
+ try:
+ variables = json.loads(variables_text_area.text)
+ except Exception as error:
+ variables_text_area.add_class("-bad-json")
+ self.variables = {}
+ else:
+ variables_text_area.remove_class("-bad-json")
+ self.variables = variables
+
+ @on(events.DescendantBlur, "#variables")
+ def on_variables_blur(self) -> None:
+ variables_text_area = self.query_one("#variables", TextArea)
+ try:
+ variables = json.loads(variables_text_area.text)
+ except Exception as error:
+ if not variables_text_area.has_class("-bad-json"):
+ self.notify(f"Bad JSON: ${error}", title="Variables", severity="error")
+ variables_text_area.add_class("-bad-json")
+ else:
+ variables_text_area.remove_class("-bad-json")
+ variables_text_area.text = json.dumps(variables, indent=4)
+ self.variables = variables
diff --git a/src/textual/_styles_cache.py b/src/textual/_styles_cache.py
index 660bea52fb..262bb097ff 100644
--- a/src/textual/_styles_cache.py
+++ b/src/textual/_styles_cache.py
@@ -4,11 +4,10 @@
from sys import intern
from typing import TYPE_CHECKING, Callable, Iterable, Sequence
-from rich.console import Console
+import rich.repr
from rich.segment import Segment
-from rich.style import Style
+from rich.style import Style as RichStyle
from rich.terminal_theme import TerminalTheme
-from rich.text import Text
from textual import log
from textual._ansi_theme import DEFAULT_TERMINAL_THEME
@@ -16,13 +15,15 @@
from textual._context import active_app
from textual._opacity import _apply_opacity
from textual._segment_tools import apply_hatch, line_pad, line_trim
-from textual.color import Color
+from textual.color import TRANSPARENT, Color
from textual.constants import DEBUG
+from textual.content import Content
from textual.filter import LineFilter
from textual.geometry import Region, Size, Spacing
from textual.renderables.text_opacity import TextOpacity
from textual.renderables.tint import Tint
from textual.strip import Strip
+from textual.style import Style
if TYPE_CHECKING:
from typing_extensions import TypeAlias
@@ -34,7 +35,7 @@
@lru_cache(1024 * 8)
-def make_blank(width, style: Style) -> Segment:
+def make_blank(width, style: RichStyle) -> Segment:
"""Make a blank segment.
Args:
@@ -47,6 +48,7 @@ def make_blank(width, style: Style) -> Segment:
return Segment(intern(" " * width), style)
+@rich.repr.auto(angular=True)
class StylesCache:
"""Responsible for rendering CSS Styles and keeping a cache of rendered lines.
@@ -77,6 +79,11 @@ def __init__(self) -> None:
self._dirty_lines: set[int] = set()
self._width = 1
+ def __rich_repr__(self) -> rich.repr.Result:
+ if self._dirty_lines:
+ yield "dirty", self._dirty_lines
+ yield "width", self._width, 1
+
def set_dirty(self, *regions: Region) -> None:
"""Add a dirty regions."""
if regions:
@@ -123,7 +130,6 @@ def render_widget(self, widget: Widget, crop: Region) -> list[Strip]:
base_background,
background,
widget.render_line,
- widget.app.console,
(
None
if border_title is None
@@ -147,6 +153,7 @@ def render_widget(self, widget: Widget, crop: Region) -> list[Strip]:
opacity=widget.opacity,
ansi_theme=widget.app.ansi_theme,
)
+
if widget.auto_links:
hover_style = widget.hover_style
if (
@@ -170,9 +177,8 @@ def render(
base_background: Color,
background: Color,
render_content_line: RenderLineCallback,
- console: Console,
- border_title: tuple[Text, Color, Color, Style] | None,
- border_subtitle: tuple[Text, Color, Color, Style] | None,
+ border_title: tuple[Content, Color, Color, Style] | None,
+ border_subtitle: tuple[Content, Color, Color, Style] | None,
content_size: Size | None = None,
padding: Spacing | None = None,
crop: Region | None = None,
@@ -230,7 +236,6 @@ def render(
base_background,
background,
render_content_line,
- console,
border_title,
border_subtitle,
opacity,
@@ -267,9 +272,8 @@ def render_line(
base_background: Color,
background: Color,
render_content_line: Callable[[int], Strip],
- console: Console,
- border_title: tuple[Text, Color, Color, Style] | None,
- border_subtitle: tuple[Text, Color, Color, Style] | None,
+ border_title: tuple[Content, Color, Color, Style] | None,
+ border_subtitle: tuple[Content, Color, Color, Style] | None,
opacity: float,
ansi_theme: TerminalTheme,
) -> Strip:
@@ -313,17 +317,17 @@ def render_line(
(outline_left, outline_left_color),
) = styles.outline
- from_color = Style.from_color
+ from_color = RichStyle.from_color
- inner = from_color(bgcolor=(base_background + background).rich_color)
- outer = from_color(bgcolor=base_background.rich_color)
+ inner = Style(background=(base_background + background))
+ outer = Style(background=base_background)
def line_post(segments: Iterable[Segment]) -> Iterable[Segment]:
"""Apply effects to segments inside the border."""
if styles.has_rule("hatch") and styles.hatch != "none":
character, color = styles.hatch
if character != " " and color.a > 0:
- hatch_style = Style.from_color(
+ hatch_style = from_color(
(background + color).rich_color, background.rich_color
)
return apply_hatch(segments, character, hatch_style)
@@ -354,11 +358,12 @@ def post(segments: Iterable[Segment]) -> Iterable[Segment]:
line: Iterable[Segment]
# Draw top or bottom borders (A)
if (border_top and y == 0) or (border_bottom and y == height - 1):
+
is_top = y == 0
border_color = base_background + (
border_top_color if is_top else border_bottom_color
).multiply_alpha(opacity)
- border_color_as_style = from_color(color=border_color.rich_color)
+ border_color_as_style = Style(foreground=border_color)
border_edge_type = border_top if is_top else border_bottom
has_left = border_left != ""
has_right = border_right != ""
@@ -368,19 +373,16 @@ def post(segments: Iterable[Segment]) -> Iterable[Segment]:
else:
label, label_color, label_background, style = border_label
base_label_background = base_background + background
- style += Style.from_color(
- (
- (base_label_background + label_color).rich_color
- if label_color.a
- else None
- ),
+ style += Style(
(
- (base_label_background + label_background).rich_color
+ (base_label_background + label_background)
if label_background.a
- else None
+ else TRANSPARENT
),
+ (base_label_background + border_color + label_color),
)
render_label = (label, style)
+
# Try to save time with expensive call to `render_border_label`:
if render_label:
label_segments = render_border_label(
@@ -391,7 +393,6 @@ def post(segments: Iterable[Segment]) -> Iterable[Segment]:
inner,
outer,
border_color_as_style,
- console,
has_left,
has_right,
)
@@ -419,27 +420,23 @@ def post(segments: Iterable[Segment]) -> Iterable[Segment]:
elif (pad_top and y < gutter.top) or (
pad_bottom and y >= height - gutter.bottom
):
- background_style = from_color(bgcolor=background.rich_color)
- left_style = from_color(
- color=(
- base_background + border_left_color.multiply_alpha(opacity)
- ).rich_color
+ background_rich_style = from_color(bgcolor=background.rich_color)
+ left_style = Style(
+ foreground=base_background + border_left_color.multiply_alpha(opacity)
)
left = get_box(border_left, inner, outer, left_style)[1][0]
- right_style = from_color(
- color=(
- base_background + border_right_color.multiply_alpha(opacity)
- ).rich_color
+ right_style = Style(
+ foreground=base_background + border_right_color.multiply_alpha(opacity)
)
right = get_box(border_right, inner, outer, right_style)[1][2]
if border_left and border_right:
- line = [left, make_blank(width - 2, background_style), right]
+ line = [left, make_blank(width - 2, background_rich_style), right]
elif border_left:
- line = [left, make_blank(width - 1, background_style)]
+ line = [left, make_blank(width - 1, background_rich_style)]
elif border_right:
- line = [make_blank(width - 1, background_style), right]
+ line = [make_blank(width - 1, background_rich_style), right]
else:
- line = [make_blank(width, background_style)]
+ line = [make_blank(width, background_rich_style)]
line = line_post(line)
else:
# Content with border and padding (C)
@@ -448,27 +445,25 @@ def post(segments: Iterable[Segment]) -> Iterable[Segment]:
line = render_content_line(y - gutter.top)
line = line.adjust_cell_length(content_width)
else:
- line = [make_blank(content_width, inner)]
+ line = [make_blank(content_width, inner.rich_style)]
if inner:
- line = Segment.apply_style(line, inner)
+ line = Segment.apply_style(line, inner.rich_style)
if styles.text_opacity != 1.0:
line = TextOpacity.process_segments(
line, styles.text_opacity, ansi_theme
)
- line = line_post(line_pad(line, pad_left, pad_right, inner))
+ line = line_post(line_pad(line, pad_left, pad_right, inner.rich_style))
if border_left or border_right:
# Add left / right border
- left_style = from_color(
- (
- base_background + border_left_color.multiply_alpha(opacity)
- ).rich_color
+ left_style = Style(
+ foreground=base_background
+ + border_left_color.multiply_alpha(opacity)
)
left = get_box(border_left, inner, outer, left_style)[1][0]
- right_style = from_color(
- (
- base_background + border_right_color.multiply_alpha(opacity)
- ).rich_color
+ right_style = Style(
+ foreground=base_background
+ + border_right_color.multiply_alpha(opacity)
)
right = get_box(border_right, inner, outer, right_style)[1][2]
@@ -487,7 +482,7 @@ def post(segments: Iterable[Segment]) -> Iterable[Segment]:
outline_top if y == 0 else outline_bottom,
inner,
outer,
- from_color(color=(base_background + outline_color).rich_color),
+ Style(foreground=base_background + outline_color),
)
line = render_row(
box_segments[0 if y == 0 else 2],
@@ -499,9 +494,9 @@ def post(segments: Iterable[Segment]) -> Iterable[Segment]:
elif outline_left or outline_right:
# Lines in side outline
- left_style = from_color((base_background + outline_left_color).rich_color)
+ left_style = Style(foreground=(base_background + outline_left_color))
left = get_box(outline_left, inner, outer, left_style)[1][0]
- right_style = from_color((base_background + outline_right_color).rich_color)
+ right_style = Style(foreground=(base_background + outline_right_color))
right = get_box(outline_right, inner, outer, right_style)[1][2]
line = line_trim(list(line), outline_left != "", outline_right != "")
if outline_left and outline_right:
diff --git a/src/textual/app.py b/src/textual/app.py
index 30a286b715..3c773f7837 100644
--- a/src/textual/app.py
+++ b/src/textual/app.py
@@ -4198,6 +4198,12 @@ def action_show_help_panel(self) -> None:
except NoMatches:
self.mount(HelpPanel())
+ def action_notify(
+ self, message: str, title: str = "", severity: str = "information"
+ ) -> None:
+ """Show a notification."""
+ self.notify(message, title=title, severity=severity)
+
def _on_terminal_supports_synchronized_output(
self, message: messages.TerminalSupportsSynchronizedOutput
) -> None:
diff --git a/src/textual/color.py b/src/textual/color.py
index d9f0903e25..fe73b06943 100644
--- a/src/textual/color.py
+++ b/src/textual/color.py
@@ -250,7 +250,7 @@ def rich_color(self) -> RichColor:
Returns:
A color object as used by Rich.
"""
- r, g, b, _a, ansi, _ = self
+ r, g, b, a, ansi, _ = self
if ansi is not None:
return RichColor.parse("default") if ansi < 0 else RichColor.from_ansi(ansi)
return RichColor(
@@ -330,6 +330,8 @@ def css(self) -> str:
r, g, b, a, ansi, auto = self
if auto:
alpha_percentage = clamp(a, 0.0, 1.0) * 100.0
+ if alpha_percentage == 100:
+ return "auto"
if not alpha_percentage % 1:
return f"auto {int(alpha_percentage)}%"
return f"auto {alpha_percentage:.1f}%"
@@ -380,8 +382,8 @@ def multiply_alpha(self, alpha: float) -> Color:
"""
if self.ansi is not None:
return self
- r, g, b, a, _, _ = self
- return Color(r, g, b, a * alpha)
+ r, g, b, a, _ansi, auto = self
+ return Color(r, g, b, a * alpha, auto=auto)
@lru_cache(maxsize=1024)
def blend(
@@ -453,6 +455,15 @@ def tint(self, color: Color) -> Color:
def __add__(self, other: object) -> Color:
if isinstance(other, Color):
return self.blend(other, other.a, 1.0)
+ elif other is None:
+ return self
+ return NotImplemented
+
+ def __radd__(self, other: object) -> Color:
+ if isinstance(other, Color):
+ return self.blend(other, other.a, 1.0)
+ elif other is None:
+ return self
return NotImplemented
@classmethod
diff --git a/src/textual/command.py b/src/textual/command.py
index 5833477234..0131f2c5c9 100644
--- a/src/textual/command.py
+++ b/src/textual/command.py
@@ -48,9 +48,9 @@
from textual.message import Message
from textual.reactive import var
from textual.screen import Screen, SystemModalScreen
+from textual.style import Style as VisualStyle
from textual.timer import Timer
from textual.types import IgnoreReturnCallbackType
-from textual.visual import Style as VisualStyle
from textual.visual import VisualType
from textual.widget import Widget
from textual.widgets import Button, Input, LoadingIndicator, OptionList, Static
@@ -1101,13 +1101,16 @@ async def _gather_commands(self, search_value: str) -> None:
def build_prompt() -> Iterable[Content]:
"""Generator for prompt content."""
assert hit is not None
- yield Content.from_rich_text(hit.prompt)
+ if isinstance(hit.prompt, Text):
+ yield Content.from_rich_text(hit.prompt)
+ else:
+ yield Content.from_markup(hit.prompt)
# Optional help text
if hit.help:
help_style = VisualStyle.from_styles(
self.get_component_styles("command-palette--help-text")
)
- yield Content.from_rich_text(hit.help).stylize_before(help_style)
+ yield Content.from_markup(hit.help).stylize_before(help_style)
prompt = Content("\n").join(build_prompt())
diff --git a/src/textual/containers.py b/src/textual/containers.py
index 55cc12e435..4f6cb0bde9 100644
--- a/src/textual/containers.py
+++ b/src/textual/containers.py
@@ -85,6 +85,7 @@ def __init__(
can_maximize: bool | None = None,
) -> None:
"""
+ Construct a scrollable container.
Args:
*children: Child widgets.
@@ -242,7 +243,7 @@ class Grid(Widget, inherit_bindings=False):
class ItemGrid(Widget, inherit_bindings=False):
- """A container with grid layout."""
+ """A container with grid layout and automatic columns."""
DEFAULT_CSS = """
ItemGrid {
@@ -268,6 +269,7 @@ def __init__(
regular: bool = False,
) -> None:
"""
+ Construct a ItemGrid.
Args:
*children: Child widgets.
diff --git a/src/textual/content.py b/src/textual/content.py
index 1aa943ed98..2d54291937 100644
--- a/src/textual/content.py
+++ b/src/textual/content.py
@@ -1,51 +1,81 @@
"""
-Content is Textual's equivalent to Rich's Text object, with support for transparency.
+Content is a container for text, with spans marked up with color / style.
+If is equivalent to Rich's Text object, with support for more of Textual features.
-The interface is (will be) similar, with the major difference that is *immutable*.
-This will make some operations slower, but dramatically improve cache-ability.
-
-TBD: Is this a public facing API or an internal one?
+Unlike Rich Text, Content is *immutable* so you can't modify it in place, and most methods will return a new Content instance.
+This is more like the builtin str, and allows Textual to make some significant optimizations.
"""
from __future__ import annotations
import re
-from functools import lru_cache
+from functools import cached_property, total_ordering
from operator import itemgetter
-from typing import TYPE_CHECKING, Callable, Iterable, NamedTuple, Sequence
+from typing import Callable, Iterable, NamedTuple, Sequence, Union
import rich.repr
from rich._wrap import divide_line
from rich.cells import set_cell_size
-from rich.console import OverflowMethod
-from rich.errors import MissingStyle
-from rich.segment import Segment, Segments
+from rich.console import Console
+from rich.segment import Segment
+from rich.style import Style as RichStyle
from rich.terminal_theme import TerminalTheme
from rich.text import Text
+from typing_extensions import Final, TypeAlias
from textual._cells import cell_len
from textual._context import active_app
from textual._loop import loop_last
from textual.color import Color
-from textual.css.types import TextAlign
+from textual.css.types import TextAlign, TextOverflow
from textual.selection import Selection
from textual.strip import Strip
-from textual.visual import Style, Visual
-
-if TYPE_CHECKING:
- from textual.widget import Widget
+from textual.style import Style
+from textual.visual import RulesMap, Visual
-_re_whitespace = re.compile(r"\s+$")
+__all__ = ["ContentType", "Content", "Span"]
+ContentType: TypeAlias = Union["Content", str]
+"""Type alias used where content and a str are interchangeable in a function."""
ANSI_DEFAULT = Style(
- background=Color(0, 0, 0, 0, ansi=-1), foreground=Color(0, 0, 0, 0, ansi=-1)
+ background=Color(0, 0, 0, 0, ansi=-1),
+ foreground=Color(0, 0, 0, 0, ansi=-1),
)
+"""A Style for ansi default background and foreground."""
TRANSPARENT_STYLE = Style()
+"""A null style."""
+
+_re_whitespace = re.compile(r"\s+$")
+_STRIP_CONTROL_CODES: Final = [
+ 7, # Bell
+ 8, # Backspace
+ 11, # Vertical tab
+ 12, # Form feed
+ 13, # Carriage return
+]
+_CONTROL_STRIP_TRANSLATE: Final = {
+ _codepoint: None for _codepoint in _STRIP_CONTROL_CODES
+}
+
+
+def _strip_control_codes(
+ text: str, _translate_table: dict[int, None] = _CONTROL_STRIP_TRANSLATE
+) -> str:
+ """Remove control codes from text.
+
+ Args:
+ text (str): A string possibly contain control codes.
+
+ Returns:
+ str: String with control codes removed.
+ """
+ return text.translate(_translate_table)
+@rich.repr.auto
class Span(NamedTuple):
"""A style applied to a range of character offsets."""
@@ -70,11 +100,11 @@ def extend(self, cells: int) -> "Span":
if cells:
start, end, style = self
return Span(start, end + cells, style)
- else:
- return self
+ return self
@rich.repr.auto
+@total_ordering
class Content(Visual):
"""Text content with marked up spans.
@@ -83,7 +113,7 @@ class Content(Visual):
"""
- __slots__ = ["_text", "_spans", "_cell_length", "_align", "_no_wrap", "_ellipsis"]
+ __slots__ = ["_text", "_spans", "_cell_length"]
_NORMALIZE_TEXT_ALIGN = {"start": "left", "end": "right", "justify": "full"}
@@ -92,9 +122,6 @@ def __init__(
text: str,
spans: list[Span] | None = None,
cell_length: int | None = None,
- align: TextAlign = "left",
- no_wrap: bool = False,
- ellipsis: bool = False,
) -> None:
"""
@@ -102,35 +129,83 @@ def __init__(
text: text content.
spans: Optional list of spans.
cell_length: Cell length of text if known, otherwise `None`.
- align: Align method.
- no_wrap: Disable wrapping.
- ellipsis: Add ellipsis when wrapping is disabled and text is cropped.
"""
- self._text: str = text
+ self._text: str = _strip_control_codes(text)
self._spans: list[Span] = [] if spans is None else spans
self._cell_length = cell_length
- self._align = align
- self._no_wrap = no_wrap
- self._ellipsis = ellipsis
def __str__(self) -> str:
return self._text
+ @cached_property
+ def markup(self) -> str:
+ """Get Content markup to render this Text.
+
+ Returns:
+ str: A string potentially creating markup tags.
+ """
+ from textual.markup import escape
+
+ output: list[str] = []
+
+ plain = self.plain
+ markup_spans = [
+ (0, False, None),
+ *((span.start, False, span.style) for span in self._spans),
+ *((span.end, True, span.style) for span in self._spans),
+ (len(plain), True, None),
+ ]
+ markup_spans.sort(key=itemgetter(0, 1))
+ position = 0
+ append = output.append
+ for offset, closing, style in markup_spans:
+ if offset > position:
+ append(escape(plain[position:offset]))
+ position = offset
+ if style:
+ append(f"[/{style}]" if closing else f"[{style}]")
+ markup = "".join(output)
+ return markup
+
+ @classmethod
+ def from_markup(cls, markup: str | Content, **variables: object) -> Content:
+ """Create content from Textual markup, optionally combined with template variables.
+
+ If `markup` is already a Content instance, it will be returned unmodified.
+
+ See the guide on [Content](../guide/content.md#content-class) for more details.
+
+
+ Example:
+ ```python
+ content = Content.from_markup("Hello, [b]$name[/b]!", name="Will")
+ ```
+
+ Args:
+ markup: Textual markup, or Content.
+ **variables: Optional template variables used
+
+ Returns:
+ New Content instance.
+ """
+ _rich_traceback_omit = True
+ if isinstance(markup, Content):
+ if variables:
+ raise ValueError("A literal string is require to substitute variables.")
+ return markup
+ from textual.markup import to_content
+
+ content = to_content(markup, template_variables=variables or None)
+ return content
+
@classmethod
def from_rich_text(
- cls,
- text: str | Text,
- align: TextAlign = "left",
- no_wrap: bool = False,
- ellipsis: bool = False,
+ cls, text: str | Text, console: Console | None = None
) -> Content:
"""Create equivalent Visual Content for str or Text.
Args:
text: String or Rich Text.
- align: Align method.
- no_wrap: Disable wrapping.
- ellipsis: Add ellipsis when wrapping is disabled and text is cropped.
Returns:
New Content.
@@ -140,6 +215,11 @@ def from_rich_text(
ansi_theme: TerminalTheme | None = None
+ if console is not None:
+ get_style = console.get_style
+ else:
+ get_style = RichStyle.parse
+
if text._spans:
try:
ansi_theme = active_app.get().ansi_theme
@@ -150,7 +230,7 @@ def from_rich_text(
start,
end,
(
- style
+ Style.from_rich_style(get_style(style), ansi_theme)
if isinstance(style, str)
else Style.from_rich_style(style, ansi_theme)
),
@@ -160,13 +240,7 @@ def from_rich_text(
else:
spans = []
- content = cls(
- text.plain,
- spans,
- align=align,
- no_wrap=no_wrap,
- ellipsis=ellipsis,
- )
+ content = cls(text.plain, spans)
if text.style:
try:
ansi_theme = active_app.get().ansi_theme
@@ -185,50 +259,102 @@ def styled(
text: str,
style: Style | str = "",
cell_length: int | None = None,
- align: TextAlign = "left",
- no_wrap: bool = False,
- ellipsis: bool = False,
) -> Content:
- """Create a Content instance from a single styled piece of text.
+ """Create a Content instance from text and an optional style.
Args:
text: String content.
style: Desired style.
cell_length: Cell length of text if known, otherwise `None`.
- align: Text alignment.
- no_wrap: Disable wrapping.
- ellipsis: Add ellipsis when wrapping is disabled and text is cropped.
Returns:
New Content instance.
"""
if not text:
- return Content("", align=align, no_wrap=no_wrap, ellipsis=ellipsis)
+ return Content("")
span_length = cell_len(text) if cell_length is None else cell_length
- new_content = cls(
- text,
- [Span(0, span_length, style)],
- span_length,
- align=align,
- no_wrap=no_wrap,
- ellipsis=ellipsis,
- )
+ new_content = cls(text, [Span(0, span_length, style)], span_length)
return new_content
- def get_optimal_width(self, container_width: int) -> int:
+ def __eq__(self, other: object) -> bool:
+ """Compares text only, so that markup doesn't effect sorting."""
+ if isinstance(other, str):
+ return self.plain == other
+ elif isinstance(other, Content):
+ return self.plain == other.plain
+ return NotImplemented
+
+ def __lt__(self, other: object) -> bool:
+ if isinstance(other, str):
+ return self.plain < other
+ if isinstance(other, Content):
+ return self.plain < other.plain
+ return NotImplemented
+
+ def is_same(self, content: Content) -> bool:
+ """Compare to another Content object.
+
+ Two Content objects are the same if their text *and* spans match.
+ Note that if you use the `==` operator to compare Content instances, it will only consider
+ the plain text portion of the content (and not the spans).
+
+ Args:
+ content: Content instance.
+
+ Returns:
+ `True` if this is identical to `content`, otherwise `False`.
+ """
+ if self is content:
+ return True
+ if self.plain != content.plain:
+ return False
+ return self.spans == content.spans
+
+ def get_optimal_width(
+ self,
+ rules: RulesMap,
+ container_width: int,
+ ) -> int:
+ """Get optimal width of the visual to display its content. Part of the Textual Visual protocol.
+
+ Args:
+ widget: Parent widget.
+ container_size: The size of the container.
+
+ Returns:
+ A width in cells.
+
+ """
lines = self.without_spans.split("\n")
return max(line.cell_length for line in lines)
+ def get_height(self, rules: RulesMap, width: int) -> int:
+ """Get the height of the visual if rendered with the given width. Part of the Textual Visual protocol.
+
+ Args:
+ widget: Parent widget.
+ width: Width of visual.
+
+ Returns:
+ A height in lines.
+ """
+ lines = self.without_spans._wrap_and_format(
+ width,
+ overflow=rules.get("text_overflow", "fold"),
+ no_wrap=rules.get("text_wrap") == "nowrap",
+ )
+ return len(lines)
+
def _wrap_and_format(
self,
width: int,
align: TextAlign = "left",
- overflow: OverflowMethod = "fold",
+ overflow: TextOverflow = "fold",
no_wrap: bool = False,
tab_size: int = 8,
selection: Selection | None = None,
selection_style: Style | None = None,
- ) -> list[FormattedLine]:
+ ) -> list[_FormattedLine]:
"""Wraps the text and applies formatting.
Args:
@@ -243,7 +369,7 @@ def _wrap_and_format(
Returns:
List of formatted lines.
"""
- output_lines: list[FormattedLine] = []
+ output_lines: list[_FormattedLine] = []
if selection is not None:
get_span = selection.get_span
@@ -259,17 +385,29 @@ def get_span(y: int) -> tuple[int, int] | None:
end = len(line.plain)
line = line.stylize(selection_style, start, end)
- content_line = FormattedLine(
- line.expand_tabs(tab_size), width, y=y, align=align
- )
+ line = line.expand_tabs(tab_size)
- if no_wrap:
+ if no_wrap and overflow == "fold":
+ cuts = list(range(0, line.cell_length, width))[1:]
+ new_lines = [
+ _FormattedLine(line, width, y=y, align=align)
+ for line in line.divide(cuts)
+ ]
+ elif no_wrap:
+ if overflow == "ellipsis" and no_wrap:
+ line = line.truncate(width, ellipsis=True)
+ content_line = _FormattedLine(line, width, y=y, align=align)
new_lines = [content_line]
else:
+ content_line = _FormattedLine(line, width, y=y, align=align)
offsets = divide_line(line.plain, width, fold=overflow == "fold")
divided_lines = content_line.content.divide(offsets)
+ divided_lines = [
+ line.truncate(width, ellipsis=overflow == "ellipsis")
+ for line in divided_lines
+ ]
new_lines = [
- FormattedLine(
+ _FormattedLine(
content.rstrip_end(width), width, offset, y, align=align
)
for content, offset in zip(divided_lines, [0, *offsets])
@@ -282,46 +420,45 @@ def get_span(y: int) -> tuple[int, int] | None:
def render_strips(
self,
- widget: Widget,
+ rules: RulesMap,
width: int,
height: int | None,
style: Style,
+ selection: Selection | None = None,
+ selection_style: Style | None = None,
) -> list[Strip]:
- if not width:
- return []
+ """Render the visual into an iterable of strips. Part of the Visual protocol.
- selection = widget.selection
- if selection is not None:
- selection_style = Style.from_rich_style(
- widget.screen.get_component_rich_style("screen--selection")
- )
+ Args:
+ rules: A mapping of style rules, such as the Widgets `styles` object.
+ width: Width of desired render.
+ height: Height of desired render or `None` for any height.
+ style: The base style to render on top of.
+ selection: Selection information, if applicable, otherwise `None`.
+ selection_style: Selection style if `selection` is not `None`.
- else:
- selection_style = None
+ Returns:
+ An list of Strips.
+ """
+ if not width:
+ return []
- align = self._align
lines = self._wrap_and_format(
width,
- align=align,
- overflow=(
- ("ellipsis" if self._ellipsis else "crop") if self._no_wrap else "fold"
- ),
- no_wrap=False,
+ align=rules.get("text_align", "left"),
+ overflow=rules.get("text_overflow", "fold"),
+ no_wrap=rules.get("text_wrap", "wrap") == "nowrap",
tab_size=8,
- selection=widget.selection,
+ selection=selection,
selection_style=selection_style,
)
if height is not None:
lines = lines[:height]
- strip_lines = [line.to_strip(widget, style) for line in lines]
+ strip_lines = [Strip(*line.to_strip(style)) for line in lines]
return strip_lines
- def get_height(self, width: int) -> int:
- lines = self._wrap_and_format(width)
- return len(lines)
-
def __len__(self) -> int:
return len(self.plain)
@@ -332,31 +469,31 @@ def __hash__(self) -> int:
return hash(self._text)
def __rich_repr__(self) -> rich.repr.Result:
- yield self._text
- yield "spans", self._spans, []
+ try:
+ yield self._text
+ yield "spans", self._spans, []
+ except AttributeError:
+ pass
+
+ @property
+ def spans(self) -> Sequence[Span]:
+ """A sequence of spans used to markup regions of the content.
+
+ !!! warning
+ Never attempt to mutate the spans, as this would certainly break the output--possibly
+ in quite subtle ways!
+
+ """
+ return self._spans
@property
def cell_length(self) -> int:
"""The cell length of the content."""
+ # Calculated on demand
if self._cell_length is None:
self._cell_length = cell_len(self.plain)
return self._cell_length
- @property
- def align(self) -> TextAlign:
- """Text alignment."""
- return self._align
-
- @property
- def no_wrap(self) -> bool:
- """Disable text wrapping?"""
- return self._no_wrap
-
- @property
- def ellipsis(self) -> bool:
- """Crop text with ellipsis?"""
- return self._ellipsis
-
@property
def plain(self) -> str:
"""Get the text as a single string."""
@@ -365,14 +502,7 @@ def plain(self) -> str:
@property
def without_spans(self) -> Content:
"""The content with no spans"""
- return Content(
- self.plain,
- [],
- self._cell_length,
- align=self._align,
- no_wrap=self._no_wrap,
- ellipsis=self._ellipsis,
- )
+ return Content(self.plain, [], self._cell_length)
def __getitem__(self, slice: int | slice) -> Content:
def get_text_at(offset: int) -> "Content":
@@ -399,9 +529,9 @@ def get_text_at(offset: int) -> "Content":
# For now, its not required
raise TypeError("slices with step!=1 are not supported")
- def __add__(self, other: object) -> Content:
+ def __add__(self, other: Content | str) -> Content:
if isinstance(other, str):
- return Content(self._text + other, self._spans.copy())
+ return Content(self._text + other, self._spans)
if isinstance(other, Content):
offset = len(self.plain)
content = Content(
@@ -422,6 +552,11 @@ def __add__(self, other: object) -> Content:
return content
return NotImplemented
+ def __radd__(self, other: Content | str) -> Content:
+ if not isinstance(other, (Content, str)):
+ return NotImplemented
+ return self + other
+
@classmethod
def _trim_spans(cls, text: str, spans: list[Span]) -> list[Span]:
"""Remove or modify any spans that are over the end of the text."""
@@ -458,20 +593,29 @@ def append(self, content: Content | str) -> Content:
if self._cell_length is None
else self._cell_length + cell_len(content)
),
- align=self.align,
- no_wrap=self.no_wrap,
- ellipsis=self.ellipsis,
)
return Content("").join([self, content])
def append_text(self, text: str, style: Style | str = "") -> Content:
+ """Append text give as a string, with an optional style.
+
+ Args:
+ text: Text to append.
+ style: Optional style for new text.
+
+ Returns:
+ New content.
+ """
return self.append(Content.styled(text, style))
- def join(self, lines: Iterable[Content]) -> Content:
- """Join an iterable of content.
+ def join(self, lines: Iterable[Content | str]) -> Content:
+ """Join an iterable of content or strings.
+
+ This works much like the join method on `str` objects.
+ Self is the separator (which maybe empty) placed between each string or Content.
Args:
- lines (_type_): An iterable of content instances.
+ lines: An iterable of other Content instances or or strings.
Returns:
A single Content instance, containing all of the lines.
@@ -484,11 +628,12 @@ def iter_content() -> Iterable[Content]:
"""Iterate the lines, optionally inserting the separator."""
if self.plain:
for last, line in loop_last(lines):
- yield line
+ yield line if isinstance(line, Content) else Content(line)
if not last:
yield self
else:
- yield from lines
+ for line in lines:
+ yield line if isinstance(line, Content) else Content(line)
extend_text = text.extend
extend_spans = spans.extend
@@ -536,24 +681,35 @@ def truncate(
self,
max_width: int,
*,
- overflow: OverflowMethod = "fold",
+ ellipsis=False,
pad: bool = False,
) -> Content:
- if overflow == "ignore":
+ """Truncate the content at a given cell width.
+
+ Args:
+ max_width: The maximum width in cells.
+ ellipsis: Insert an ellipsis when cropped.
+ pad: Pad the content if less than `max_width`.
+
+ Returns:
+ New Content.
+ """
+
+ length = self.cell_length
+ if length == max_width:
return self
- length = cell_len(self.plain)
text = self.plain
- if length > max_width:
- if overflow == "ellipsis":
- text = set_cell_size(self.plain, max_width - 1) + "…"
- else:
- text = set_cell_size(self.plain, max_width)
+ spans = self._spans
if pad and length < max_width:
spaces = max_width - length
text = f"{self.plain}{' ' * spaces}"
- length = len(self.plain)
- spans = self._trim_spans(text, self._spans)
+ elif length > max_width:
+ if ellipsis and max_width:
+ text = set_cell_size(self.plain, max_width - 1) + "…"
+ else:
+ text = set_cell_size(self.plain, max_width)
+ spans = self._trim_spans(text, self._spans)
return Content(text, spans)
def pad_left(self, count: int, character: str = " ") -> Content:
@@ -629,9 +785,7 @@ def center(self, width: int, ellipsis: bool = False) -> Content:
Returns:
New line Content.
"""
- content = self.rstrip().truncate(
- width, overflow="ellipsis" if ellipsis else "fold"
- )
+ content = self.rstrip().truncate(width, ellipsis=ellipsis)
left = (width - content.cell_length) // 2
right = width - left
content = content.pad_left(left).pad_right(right)
@@ -647,14 +801,20 @@ def right(self, width: int, ellipsis: bool = False) -> Content:
Returns:
New line Content.
"""
- content = self.rstrip().truncate(
- width, overflow="ellipsis" if ellipsis else "fold"
- )
+ content = self.rstrip().truncate(width, ellipsis=ellipsis)
content = content.pad_left(width - content.cell_length)
return content
def right_crop(self, amount: int = 1) -> Content:
- """Remove a number of characters from the end of the text."""
+ """Remove a number of characters from the end of the text.
+
+ Args:
+ amount: Number of characters to crop.
+
+ Returns:
+ New Content
+
+ """
max_offset = len(self.plain) - amount
_Span = Span
spans = [
@@ -703,7 +863,9 @@ def stylize_before(
start: int = 0,
end: int | None = None,
) -> Content:
- """Apply a style to the text, or a portion of the text. Styles will be applied before other styles already present.
+ """Apply a style to the text, or a portion of the text.
+
+ Styles applies with this method will be applied *before* other styles already present.
Args:
style (Union[str, Style]): Style instance or style definition to apply.
@@ -729,28 +891,40 @@ def stylize_before(
def render(
self,
- base_style: Style,
+ base_style: Style = Style.null(),
end: str = "\n",
parse_style: Callable[[str], Style] | None = None,
) -> Iterable[tuple[str, Style]]:
+ """Render Content in to an iterable of strings and styles.
+
+ This is typically called by Textual when displaying Content, but may be used if you want to do more advanced
+ processing of the output.
+
+ Args:
+ base_style: The style used as a base. This will typically be the style of the widget underneath the content.
+ end: Text to end the output, such as a new line.
+ parse_style: Method to parse a style. Use `App.parse_style` to apply CSS variables in styles.
+
+ Returns:
+ An iterable of string and styles, which make up the content.
+
+ """
+
if not self._spans:
yield (self._text, base_style)
if end:
yield end, base_style
return
+ get_style: Callable[[str], Style]
if parse_style is None:
- app = active_app.get()
- # TODO: Update when we add Content.from_markup
- @lru_cache(maxsize=1024)
def get_style(style: str, /) -> Style:
+ """The default get_style method."""
try:
- visual_style = Style.from_rich_style(
- app.console.get_style(style), app.ansi_theme
- )
- except MissingStyle:
- visual_style = Style()
+ visual_style = Style.parse(style)
+ except Exception:
+ visual_style = Style.null()
return visual_style
else:
@@ -803,19 +977,42 @@ def get_current_style() -> Style:
if end:
yield end, base_style
- def render_segments(self, base_style: Style, end: str = "") -> list[Segment]:
+ def render_segments(
+ self, base_style: Style = Style.null(), end: str = ""
+ ) -> list[Segment]:
+ """Render the Content in to a list of segments.
+
+ Args:
+ base_style: Base style for render (style under the content). Defaults to Style.null().
+ end: Character to end the segments with. Defaults to "".
+
+ Returns:
+ A list of segments.
+ """
_Segment = Segment
- render = list(self.render(base_style, end))
- segments = [_Segment(text, style.get_rich_style()) for text, style in render]
+ segments = [
+ _Segment(text, (style.rich_style if style else None))
+ for text, style in self.render(base_style, end)
+ ]
return segments
- def divide(
- self,
- offsets: Sequence[int],
- ) -> list[Content]:
+ def divide(self, offsets: Sequence[int]) -> list[Content]:
+ """Divide the content at the given offsets.
+
+ This will cut the content in to pieces, and return those pieces. Note that the number of pieces
+ return will be one greater than the number of cuts.
+
+ Args:
+ offsets: Sequence of offsets (in characters) of where to apply the cuts.
+
+ Returns:
+ List of Content instances which combined would be equal to the whole.
+ """
if not offsets:
return [self]
+ offsets = sorted(offsets)
+
text = self.plain
text_length = len(text)
divide_offsets = [0, *offsets, text_length]
@@ -939,6 +1136,9 @@ def extend_style(self, spaces: int) -> Content:
Args:
spaces (int): Number of spaces to add to the Text.
+
+ Returns:
+ New content with additional spaces at the end.
"""
if spaces <= 0:
return self
@@ -992,24 +1192,45 @@ def expand_tabs(self, tab_size: int = 8) -> Content:
def highlight_regex(
self,
- re_highlight: re.Pattern[str] | str,
+ highlight_regex: re.Pattern[str] | str,
+ *,
style: Style,
+ maximum_highlights: int | None = None,
) -> Content:
+ """Apply a style to text that matches a regular expression.
+
+ Args:
+ highlight_regex: Regular expression as a string, or compiled.
+ style: Style to apply.
+ maximum_highlights: Maximum number of matches to highlight, or `None` for no maximum.
+
+ Returns:
+ new content.
+ """
spans: list[Span] = self._spans.copy()
append_span = spans.append
_Span = Span
plain = self.plain
- if isinstance(re_highlight, str):
- re_highlight = re.compile(re_highlight)
+ if isinstance(highlight_regex, str):
+ re_highlight = re.compile(highlight_regex)
+ count = 0
for match in re_highlight.finditer(plain):
start, end = match.span()
if end > start:
append_span(_Span(start, end, style))
+ if (
+ maximum_highlights is not None
+ and (count := count + 1) >= maximum_highlights
+ ):
+ break
return Content(self._text, spans)
-class FormattedLine:
- """A line of content with additional formatting information."""
+class _FormattedLine:
+ """A line of content with additional formatting information.
+
+ This class is used internally within Content, and you are unlikely to need it an an app.
+ """
def __init__(
self,
@@ -1033,7 +1254,7 @@ def __init__(
def plain(self) -> str:
return self.content.plain
- def to_strip(self, widget: Widget, style: Style) -> Strip:
+ def to_strip(self, style: Style) -> tuple[list[Segment], int]:
_Segment = Segment
align = self.align
width = self.width
@@ -1079,8 +1300,7 @@ def to_strip(self, widget: Widget, style: Style) -> Strip:
if index < len(spaces) and (pad := spaces[index]):
add_segment(_Segment(" " * pad, (style + text_style).rich_style))
- strip = Strip(self._apply_link_style(widget, segments), width)
- return strip
+ return segments, width
segments = (
[Segment(" " * pad_left, style.background_style.rich_style)]
@@ -1098,16 +1318,13 @@ def to_strip(self, widget: Widget, style: Style) -> Strip:
segments.append(
_Segment(" " * pad_right, style.background_style.rich_style)
)
- strip = Strip(
- self._apply_link_style(widget, segments),
- content.cell_length + pad_left + pad_right,
- )
- return strip
+
+ return (segments, content.cell_length + pad_left + pad_right)
def _apply_link_style(
- self, widget: Widget, segments: list[Segment]
+ self, link_style: RichStyle, segments: list[Segment]
) -> list[Segment]:
- link_style = widget.link_style
+
_Segment = Segment
segments = [
_Segment(
@@ -1123,43 +1340,3 @@ def _apply_link_style(
if style is not None
]
return segments
-
-
-if __name__ == "__main__":
- from rich import print
-
- TEXT = """I must not fear.
-Fear is the mind-killer.
-Fear is the little-death that brings total obliteration.
-I will face my fear.
-I will permit it to pass over me and through me.
-And when it has gone past, I will turn the inner eye to see its path.
-Where the fear has gone there will be nothing. Only I will remain."""
-
- content = Content(TEXT)
- content = content.stylize(
- Style(Color.parse("rgb(50,50,80)"), Color.parse("rgba(255,255,255,0.7)"))
- )
-
- content = content.highlight_regex(
- "F..r", Style(background=Color.parse("rgba(255, 255, 255, 0.3)"))
- )
-
- content = content.highlight_regex(
- "is", Style(background=Color.parse("rgba(20, 255, 255, 0.3)"))
- )
-
- content = content.highlight_regex(
- "the", Style(background=Color.parse("rgba(255, 20, 255, 0.3)"))
- )
-
- content = content.highlight_regex(
- "will", Style(background=Color.parse("rgba(255, 255, 20, 0.3)"))
- )
-
- lines = content._wrap_and_format(40, align="full")
- print(lines)
- print("x" * 40)
- for line in lines:
- segments = Segments(line.render_segments(ANSI_DEFAULT, end="\n"))
- print(segments)
diff --git a/src/textual/css/_styles_builder.py b/src/textual/css/_styles_builder.py
index b5ccc4802d..668a760661 100644
--- a/src/textual/css/_styles_builder.py
+++ b/src/textual/css/_styles_builder.py
@@ -52,6 +52,8 @@
VALID_SCROLLBAR_GUTTER,
VALID_STYLE_FLAGS,
VALID_TEXT_ALIGN,
+ VALID_TEXT_OVERFLOW,
+ VALID_TEXT_WRAP,
VALID_VISIBILITY,
)
from textual.css.errors import DeclarationError, StyleValueError
@@ -67,7 +69,15 @@
from textual.css.styles import Styles
from textual.css.tokenize import Token
from textual.css.transition import Transition
-from textual.css.types import BoxSizing, Display, EdgeType, Overflow, Visibility
+from textual.css.types import (
+ BoxSizing,
+ Display,
+ EdgeType,
+ Overflow,
+ TextOverflow,
+ TextWrap,
+ Visibility,
+)
from textual.geometry import Spacing, SpacingDimensions, clamp
from textual.suggestions import get_suggestion
@@ -353,6 +363,52 @@ def process_visibility(self, name: str, tokens: list[Token]) -> None:
"visibility", valid_values=list(VALID_VISIBILITY), context="css"
)
+ def process_text_wrap(self, name: str, tokens: list[Token]) -> None:
+ for token in tokens:
+ name, value, _, _, location, _ = token
+ if name == "token":
+ value = value.lower()
+ if value in VALID_TEXT_WRAP:
+ self.styles._rules["text_wrap"] = cast(TextWrap, value)
+ else:
+ self.error(
+ name,
+ token,
+ string_enum_help_text(
+ "text-wrap",
+ valid_values=list(VALID_TEXT_WRAP),
+ context="css",
+ ),
+ )
+ else:
+ string_enum_help_text(
+ "text-wrap", valid_values=list(VALID_TEXT_WRAP), context="css"
+ )
+
+ def process_text_overflow(self, name: str, tokens: list[Token]) -> None:
+ for token in tokens:
+ name, value, _, _, location, _ = token
+ if name == "token":
+ value = value.lower()
+ if value in VALID_TEXT_OVERFLOW:
+ self.styles._rules["text_overflow"] = cast(TextOverflow, value)
+ else:
+ self.error(
+ name,
+ token,
+ string_enum_help_text(
+ "text-overflow",
+ valid_values=list(VALID_TEXT_OVERFLOW),
+ context="css",
+ ),
+ )
+ else:
+ string_enum_help_text(
+ "text-overflow",
+ valid_values=list(VALID_TEXT_OVERFLOW),
+ context="css",
+ )
+
def _process_fractional(self, name: str, tokens: list[Token]) -> None:
if not tokens:
return
diff --git a/src/textual/css/constants.py b/src/textual/css/constants.py
index 160ff03330..e2cd109dcd 100644
--- a/src/textual/css/constants.py
+++ b/src/textual/css/constants.py
@@ -83,6 +83,9 @@
VALID_CONSTRAIN: Final = {"inflect", "inside", "none"}
VALID_KEYLINE: Final = {"none", "thin", "heavy", "double"}
VALID_HATCH: Final = {"left", "right", "cross", "vertical", "horizontal"}
+VALID_TEXT_WRAP: Final = {"wrap", "nowrap"}
+VALID_TEXT_OVERFLOW: Final = {"clip", "fold", "ellipsis"}
+
HATCHES: Final = {
"left": "╲",
"right": "╱",
diff --git a/src/textual/css/styles.py b/src/textual/css/styles.py
index 68a7375783..bdba408071 100644
--- a/src/textual/css/styles.py
+++ b/src/textual/css/styles.py
@@ -3,7 +3,7 @@
from dataclasses import dataclass, field
from functools import partial
from operator import attrgetter
-from typing import TYPE_CHECKING, Any, Callable, Iterable, Literal, cast
+from typing import TYPE_CHECKING, Any, Callable, Iterable, Iterator, Literal, cast
import rich.repr
from rich.style import Style
@@ -48,6 +48,8 @@
VALID_POSITION,
VALID_SCROLLBAR_GUTTER,
VALID_TEXT_ALIGN,
+ VALID_TEXT_OVERFLOW,
+ VALID_TEXT_WRAP,
VALID_VISIBILITY,
)
from textual.css.scalar import Scalar, ScalarOffset, Unit
@@ -65,6 +67,8 @@
Specificity3,
Specificity6,
TextAlign,
+ TextOverflow,
+ TextWrap,
Visibility,
)
from textual.geometry import Offset, Spacing
@@ -197,6 +201,9 @@ class RulesMap(TypedDict, total=False):
constrain_x: Constrain
constrain_y: Constrain
+ text_wrap: TextWrap
+ text_overflow: TextOverflow
+
RULE_NAMES = list(RulesMap.__annotations__.keys())
RULE_NAMES_SET = frozenset(RULE_NAMES)
@@ -234,6 +241,8 @@ class StylesBase:
"link_background",
"link_color_hover",
"link_background_hover",
+ "text_wrap",
+ "text_overflow",
}
node: DOMNode | None = None
@@ -437,7 +446,9 @@ class StylesBase:
row_span = IntegerProperty(default=1, layout=True)
column_span = IntegerProperty(default=1, layout=True)
- text_align = StringEnumProperty(VALID_TEXT_ALIGN, "start")
+ text_align: StringEnumProperty[TextAlign] = StringEnumProperty(
+ VALID_TEXT_ALIGN, "start"
+ )
link_color = ColorProperty("transparent")
auto_link_color = BooleanProperty(False)
@@ -472,6 +483,12 @@ class StylesBase:
constrain_y: StringEnumProperty[Constrain] = StringEnumProperty(
VALID_CONSTRAIN, "none"
)
+ text_wrap: StringEnumProperty[TextWrap] = StringEnumProperty(
+ VALID_TEXT_WRAP, "wrap"
+ )
+ text_overflow: StringEnumProperty[TextOverflow] = StringEnumProperty(
+ VALID_TEXT_OVERFLOW, "fold"
+ )
def __textual_animation__(
self,
@@ -525,6 +542,35 @@ def __eq__(self, styles: object) -> bool:
return NotImplemented
return self.get_rules() == styles.get_rules()
+ def __getitem__(self, key: str) -> object:
+ if key not in RULE_NAMES_SET:
+ raise KeyError(key)
+ return getattr(self, key)
+
+ def get(self, key: str, default: object | None = None) -> object:
+ return getattr(self, key) if key in RULE_NAMES_SET else default
+
+ def __len__(self) -> int:
+ return len(RULE_NAMES)
+
+ def __iter__(self) -> Iterator[str]:
+ return iter(RULE_NAMES)
+
+ def __contains__(self, key: object) -> bool:
+ return key in RULE_NAMES_SET
+
+ def keys(self) -> Iterable[str]:
+ return RULE_NAMES
+
+ def values(self) -> Iterable[object]:
+ for key in RULE_NAMES:
+ yield getattr(self, key)
+
+ def items(self) -> Iterable[tuple[str, object]]:
+ get_rule = self.get_rule
+ for key in RULE_NAMES:
+ yield (key, getattr(self, key))
+
@property
def gutter(self) -> Spacing:
"""Get space around widget.
@@ -1234,6 +1280,10 @@ def append_declaration(name: str, value: str) -> None:
if "hatch" in rules:
hatch_character, hatch_color = self.hatch
append_declaration("hatch", f'"{hatch_character}" {hatch_color.css}')
+ if "text_wrap" in rules:
+ append_declaration("text-wrap", self.text_wrap)
+ if "text_overflow" in rules:
+ append_declaration("text-overflow", self.text_overflow)
lines.sort()
return lines
diff --git a/src/textual/css/stylesheet.py b/src/textual/css/stylesheet.py
index 0bea5504e2..a715162f32 100644
--- a/src/textual/css/stylesheet.py
+++ b/src/textual/css/stylesheet.py
@@ -24,6 +24,8 @@
from textual.css.tokenizer import TokenError
from textual.css.types import CSSLocation, Specificity3, Specificity6
from textual.dom import DOMNode
+from textual.markup import parse_style
+from textual.style import Style
from textual.widget import Widget
_DEFAULT_STYLES = Styles()
@@ -149,6 +151,7 @@ def __init__(self, *, variables: dict[str, str] | None = None) -> None:
self._require_parse = False
self._invalid_css: set[str] = set()
self._parse_cache: LRUCache[tuple, list[RuleSet]] = LRUCache(64)
+ self._style_parse_cache: LRUCache[str, Style] = LRUCache(1024 * 4)
def __rich_repr__(self) -> rich.repr.Result:
yield list(self.source.keys())
@@ -215,6 +218,22 @@ def set_variables(self, variables: dict[str, str]) -> None:
self.__variable_tokens = None
self._invalid_css = set()
self._parse_cache.clear()
+ self._style_parse_cache.clear()
+
+ def parse_style(self, style_text: str) -> Style:
+ """Parse a (visual) Style.
+
+ Args:
+ style_text: Visual style, such as "bold white 90% on $primary"
+
+ Returns:
+ New Style instance.
+ """
+ if style_text in self._style_parse_cache:
+ return self._style_parse_cache[style_text]
+ style = parse_style(style_text)
+ self._style_parse_cache[style_text] = style
+ return style
def _parse_rules(
self,
diff --git a/src/textual/css/tokenize.py b/src/textual/css/tokenize.py
index bbb8347158..df14368b61 100644
--- a/src/textual/css/tokenize.py
+++ b/src/textual/css/tokenize.py
@@ -1,7 +1,7 @@
from __future__ import annotations
import re
-from typing import TYPE_CHECKING, Iterable
+from typing import TYPE_CHECKING, ClassVar, Iterable
from textual.css.tokenizer import Expect, Token, Tokenizer
@@ -176,6 +176,48 @@
class TokenizerState:
+ EXPECT: ClassVar[Expect] = expect_root_scope
+ STATE_MAP: ClassVar[dict[str, Expect]] = {}
+ STATE_PUSH: ClassVar[dict[str, Expect]] = {}
+ STATE_POP: ClassVar[dict[str, str]] = {}
+
+ def __init__(self) -> None:
+ self._expect: Expect = self.EXPECT
+ super().__init__()
+
+ def expect(self, expect: Expect) -> None:
+ self._expect = expect
+
+ def __call__(self, code: str, read_from: CSSLocation) -> Iterable[Token]:
+ tokenizer = Tokenizer(code, read_from=read_from)
+ get_token = tokenizer.get_token
+ get_state = self.STATE_MAP.get
+ state_stack: list[Expect] = []
+
+ while True:
+ expect = self._expect
+ token = get_token(expect)
+ name = token.name
+ if name in self.STATE_MAP:
+ self._expect = get_state(token.name, expect)
+ elif name in self.STATE_PUSH:
+ self._expect = self.STATE_PUSH[name]
+ state_stack.append(expect)
+ elif name in self.STATE_POP:
+ if state_stack:
+ self._expect = state_stack.pop()
+ else:
+ self._expect = self.EXPECT
+ token = token._replace(name="end_tag")
+ yield token
+ continue
+
+ yield token
+ if name == "eof":
+ break
+
+
+class TCSSTokenizerState:
"""State machine for the tokenizer.
Attributes:
@@ -232,7 +274,7 @@ def __call__(self, code: str, read_from: CSSLocation) -> Iterable[Token]:
yield token
-class DeclarationTokenizerState(TokenizerState):
+class DeclarationTokenizerState(TCSSTokenizerState):
EXPECT = expect_declaration_solo
STATE_MAP = {
"declaration_name": expect_declaration_content,
@@ -240,13 +282,32 @@ class DeclarationTokenizerState(TokenizerState):
}
-class ValueTokenizerState(TokenizerState):
+class ValueTokenizerState(TCSSTokenizerState):
EXPECT = expect_declaration_content_solo
-tokenize = TokenizerState()
+class StyleTokenizerState(TCSSTokenizerState):
+ EXPECT = (
+ Expect(
+ "style token",
+ key_value=r"[@a-zA-Z_-][a-zA-Z0-9_-]*=.*",
+ key_value_quote=r"[@a-zA-Z_-][a-zA-Z0-9_-]*='.*'",
+ key_value_double_quote=r"""[@a-zA-Z_-][a-zA-Z0-9_-]*=".*\"""",
+ percent=PERCENT,
+ color=COLOR,
+ token=TOKEN,
+ variable_ref=VARIABLE_REF,
+ whitespace=r"\s+",
+ )
+ .expect_eof(True)
+ .expect_semicolon(False)
+ )
+
+
+tokenize = TCSSTokenizerState()
tokenize_declarations = DeclarationTokenizerState()
tokenize_value = ValueTokenizerState()
+tokenize_style = StyleTokenizerState()
def tokenize_values(values: dict[str, str]) -> dict[str, list[Token]]:
@@ -264,3 +325,25 @@ def tokenize_values(values: dict[str, str]) -> dict[str, list[Token]]:
for name, value in values.items()
}
return value_tokens
+
+
+if __name__ == "__main__":
+ text = "[@click=app.notify(['foo', 500])] Click me! [/] :-)"
+
+ # text = "[@click=hello]Click"
+ from rich.console import Console
+
+ c = Console(markup=False)
+
+ from textual._profile import timer
+
+ with timer("tokenize"):
+ list(tokenize_markup(text, read_from=("", "")))
+
+ from textual.markup import _parse
+
+ with timer("_parse"):
+ list(_parse(text))
+
+ for token in tokenize_markup(text, read_from=("", "")):
+ c.print(repr(token))
diff --git a/src/textual/css/tokenizer.py b/src/textual/css/tokenizer.py
index 42c29cb9e6..770b3379ff 100644
--- a/src/textual/css/tokenizer.py
+++ b/src/textual/css/tokenizer.py
@@ -127,11 +127,23 @@ def __init__(self, description: str, **tokens: str) -> None:
self.match = self._regex.match
self.search = self._regex.search
self._expect_eof = False
+ self._expect_semicolon = True
+ self._extract_text = False
- def expect_eof(self, eof: bool) -> Expect:
+ def expect_eof(self, eof: bool = True) -> Expect:
+ """Expect an end of file."""
self._expect_eof = eof
return self
+ def expect_semicolon(self, semicolon: bool = True) -> Expect:
+ """Tokenizer expects text to be terminated with a semi-colon."""
+ self._expect_semicolon = semicolon
+ return self
+
+ def extract_text(self, extract: bool = True) -> Expect:
+ self._extract_text = extract
+ return self
+
def __rich_repr__(self) -> rich.repr.Result:
yield from zip(self.names, self.regexes)
@@ -246,22 +258,44 @@ def get_token(self, expect: Expect) -> Token:
"Unexpected end of file; did you forget a '}' ?",
)
line = self.lines[line_no]
- match = expect.match(line, col_no)
+ preceding_text: str = ""
+ if expect._extract_text:
+ match = expect.search(line, col_no)
+ if match is None:
+ preceding_text = line[self.col_no :]
+ self.line_no += 1
+ self.col_no = 0
+ else:
+ col_no = match.start()
+ preceding_text = line[self.col_no : col_no]
+ self.col_no = col_no
+ if preceding_text:
+ token = Token(
+ "text",
+ preceding_text,
+ self.read_from,
+ self.code,
+ (line_no, col_no),
+ referenced_by=None,
+ )
+
+ return token
+
+ else:
+ match = expect.match(line, col_no)
+
if match is None:
- error_line = line[col_no:].rstrip()
+ error_line = line[col_no:]
error_message = (
f"{expect.description} (found {error_line.split(';')[0]!r})."
)
- if not error_line.endswith(";"):
+ if expect._expect_semicolon and not error_line.endswith(";"):
error_message += "; Did you forget a semicolon at the end of a line?"
raise TokenError(
self.read_from, self.code, (line_no + 1, col_no + 1), error_message
)
- iter_groups = iter(match.groups())
-
- next(iter_groups)
- for name, value in zip(expect.names, iter_groups):
+ for name, value in zip(expect.names, match.groups()[1:]):
if value is not None:
break
else:
diff --git a/src/textual/css/types.py b/src/textual/css/types.py
index a8f6b1f799..9fda239590 100644
--- a/src/textual/css/types.py
+++ b/src/textual/css/types.py
@@ -40,6 +40,8 @@
Constrain = Literal["none", "inflect", "inside"]
Overlay = Literal["none", "screen"]
Position = Literal["relative", "absolute"]
+TextWrap = Literal["wrap", "nowrap"]
+TextOverflow = Literal["clip", "fold", "ellipsis"]
Specificity3 = Tuple[int, int, int]
Specificity6 = Tuple[int, int, int, int, int, int]
diff --git a/src/textual/dom.py b/src/textual/dom.py
index 3bfa75b301..9587bca586 100644
--- a/src/textual/dom.py
+++ b/src/textual/dom.py
@@ -47,6 +47,7 @@
from textual.css.tokenizer import TokenError
from textual.message_pump import MessagePump
from textual.reactive import Reactive, ReactiveError, _Mutated, _watch
+from textual.style import Style as VisualStyle
from textual.timer import Timer
from textual.walk import walk_breadth_first, walk_depth_first
from textual.worker_manager import WorkerManager
@@ -1087,8 +1088,8 @@ def check_consume_key(self, key: str, character: str | None) -> bool:
def _get_title_style_information(
self, background: Color
- ) -> tuple[Color, Color, Style]:
- """Get a Rich Style object for for titles.
+ ) -> tuple[Color, Color, VisualStyle]:
+ """Get a Visual Style object for for titles.
Args:
background: The background color.
@@ -1096,6 +1097,7 @@ def _get_title_style_information(
Returns:
A Rich style.
"""
+
styles = self.styles
if styles.auto_border_title_color:
color = background.get_contrast_text(styles.border_title_color.a)
@@ -1104,12 +1106,12 @@ def _get_title_style_information(
return (
color,
styles.border_title_background,
- styles.border_title_style,
+ VisualStyle.from_rich_style(styles.border_title_style),
)
def _get_subtitle_style_information(
self, background: Color
- ) -> tuple[Color, Color, Style]:
+ ) -> tuple[Color, Color, VisualStyle]:
"""Get a Rich Style object for for titles.
Args:
@@ -1126,7 +1128,7 @@ def _get_subtitle_style_information(
return (
color,
styles.border_subtitle_background,
- styles.border_subtitle_style,
+ VisualStyle.from_rich_style(styles.border_subtitle_style),
)
@property
diff --git a/src/textual/keys.py b/src/textual/keys.py
index 22572e117f..b4eb6236d0 100644
--- a/src/textual/keys.py
+++ b/src/textual/keys.py
@@ -270,6 +270,9 @@ def value(self) -> str:
}
+ASCII_KEY_NAMES = {"\t": "tab"}
+
+
def _get_unicode_name_from_key(key: str) -> str:
"""Get the best guess for the Unicode name of the char corresponding to the key.
@@ -341,7 +344,12 @@ def _character_to_key(character: str) -> str:
This transformation can be undone by the function `_get_unicode_name_from_key`.
"""
if not character.isalnum():
- key = unicodedata.name(character).lower().replace("-", "_").replace(" ", "_")
+ try:
+ key = (
+ unicodedata.name(character).lower().replace("-", "_").replace(" ", "_")
+ )
+ except ValueError:
+ key = ASCII_KEY_NAMES.get(character, character)
else:
key = character
key = KEY_NAME_REPLACEMENTS.get(key, key)
diff --git a/src/textual/markup.py b/src/textual/markup.py
new file mode 100644
index 0000000000..fcd7f29ea4
--- /dev/null
+++ b/src/textual/markup.py
@@ -0,0 +1,411 @@
+from __future__ import annotations
+
+from textual.css.parse import substitute_references
+
+__all__ = ["MarkupError", "escape", "to_content"]
+
+import re
+from string import Template
+from typing import TYPE_CHECKING, Callable, Mapping, Match
+
+from textual._context import active_app
+from textual.color import Color
+from textual.css.tokenize import (
+ COLOR,
+ PERCENT,
+ TOKEN,
+ VARIABLE_REF,
+ Expect,
+ TokenizerState,
+ tokenize_values,
+)
+from textual.style import Style
+
+if TYPE_CHECKING:
+ from textual.content import Content
+
+
+class MarkupError(Exception):
+ """An error occurred parsing Textual markup."""
+
+
+expect_markup_tag = (
+ Expect(
+ "markup style value",
+ end_tag=r"(? str:
+ """Escapes text so that it won't be interpreted as markup.
+
+ Args:
+ markup (str): Content to be inserted in to markup.
+
+ Returns:
+ str: Markup with square brackets escaped.
+ """
+
+ def escape_backslashes(match: Match[str]) -> str:
+ """Called by re.sub replace matches."""
+ backslashes, text = match.groups()
+ return f"{backslashes}{backslashes}\\{text}"
+
+ markup = _escape(escape_backslashes, markup)
+ if markup.endswith("\\") and not markup.endswith("\\\\"):
+ return markup + "\\"
+
+ return markup
+
+
+def parse_style(style: str, variables: dict[str, str] | None = None) -> Style:
+ """Parse an encoded style.
+
+ Args:
+ style: Style encoded in a string.
+ variables: Mapping of variables, or `None` to import from active app.
+
+ Returns:
+ A Style object.
+ """
+
+ styles: dict[str, bool | None] = {}
+ color: Color | None = None
+ background: Color | None = None
+ is_background: bool = False
+ style_state: bool = True
+
+ tokenizer = StyleTokenizer()
+ meta = {}
+
+ if variables is None:
+ try:
+ app = active_app.get()
+ except LookupError:
+ reference_tokens = {}
+ else:
+ reference_tokens = app.stylesheet._variable_tokens
+ else:
+ reference_tokens = tokenize_values(variables)
+
+ iter_tokens = iter(
+ substitute_references(
+ tokenizer(style, ("inline style", "")),
+ reference_tokens,
+ )
+ )
+
+ for token in iter_tokens:
+ token_name = token.name
+ token_value = token.value
+ if token_name == "key":
+ key = token_value.rstrip("=")
+ parenthesis: list[str] = []
+ value_text: list[str] = []
+ first_token = next(iter_tokens)
+ if first_token.name in {"double_string", "single_string"}:
+ meta[key] = first_token.value[1:-1]
+ break
+ else:
+ value_text.append(first_token.value)
+ for token in iter_tokens:
+ if token.name == "whitespace" and not parenthesis:
+ break
+ value_text.append(token.value)
+ if token.name in {"round_start", "square_start", "curly_start"}:
+ parenthesis.append(token.value)
+ elif token.name in {"round_end", "square_end", "curly_end"}:
+ parenthesis.pop()
+ if not parenthesis:
+ break
+ tokenizer.expect(StyleTokenizer.EXPECT)
+
+ value = "".join(value_text)
+ meta[key] = value
+
+ elif token_name == "color":
+ if is_background:
+ background = Color.parse(token.value)
+ else:
+ color = Color.parse(token.value)
+
+ elif token_name == "token":
+ if token_value == "link":
+ if "link" not in meta:
+ meta["link"] = ""
+ elif token_value == "on":
+ is_background = True
+ elif token_value == "auto":
+ if is_background:
+ background = Color.automatic()
+ else:
+ color = Color.automatic()
+ elif token_value == "not":
+ style_state = False
+ elif token_value in STYLES:
+ styles[token_value] = style_state
+ style_state = True
+ elif token_value in STYLE_ABBREVIATIONS:
+ styles[STYLE_ABBREVIATIONS[token_value]] = style_state
+ style_state = True
+ else:
+ if is_background:
+ background = Color.parse(token_value)
+ else:
+ color = Color.parse(token_value)
+
+ elif token_name == "percent":
+ percent = int(token_value.rstrip("%")) / 100.0
+ if is_background:
+ if background is not None:
+ background = background.multiply_alpha(percent)
+ else:
+ if color is not None:
+ color = color.multiply_alpha(percent)
+
+ parsed_style = Style(background, color, link=meta.pop("link", None), **styles)
+
+ if meta:
+ parsed_style += Style.from_meta(meta)
+ return parsed_style
+
+
+def to_content(
+ markup: str,
+ style: str | Style = "",
+ template_variables: Mapping[str, object] | None = None,
+) -> Content:
+ """Convert markup to Content.
+
+ Args:
+ markup: String containing markup.
+ style: Optional base style.
+ template_variables: Mapping of string.Template variables
+
+ Raises:
+ MarkupError: If the markup is invalid.
+
+ Returns:
+ Content that renders the markup.
+ """
+ _rich_traceback_omit = True
+ try:
+ return _to_content(markup, style, template_variables)
+ except Exception as error:
+ # Ensure all errors are wrapped in a MarkupError
+ raise MarkupError(str(error)) from None
+
+
+def _to_content(
+ markup: str,
+ style: str | Style = "",
+ template_variables: Mapping[str, object] | None = None,
+) -> Content:
+ """Internal function to convert markup to Content.
+
+ Args:
+ markup: String containing markup.
+ style: Optional base style.
+ template_variables: Mapping of string.Template variables
+
+ Raises:
+ MarkupError: If the markup is invalid.
+
+ Returns:
+ Content that renders the markup.
+ """
+
+ from textual.content import Content, Span
+
+ tokenizer = MarkupTokenizer()
+ text: list[str] = []
+ text_append = text.append
+ iter_tokens = iter(tokenizer(markup, ("inline", "")))
+
+ style_stack: list[tuple[int, str, str]] = []
+
+ spans: list[Span] = []
+
+ position = 0
+ tag_text: list[str]
+
+ normalize_markup_tag = Style._normalize_markup_tag
+
+ if template_variables is None:
+ process_text = lambda text: text
+
+ else:
+
+ def process_text(template_text: str, /) -> str:
+ if "$" in template_text:
+ return Template(template_text).safe_substitute(template_variables)
+ return template_text
+
+ for token in iter_tokens:
+
+ token_name = token.name
+ if token_name == "text":
+ value = process_text(token.value.replace("\\[", "["))
+ text_append(value)
+ position += len(value)
+
+ elif token_name == "open_tag":
+ tag_text = []
+ for token in iter_tokens:
+ if token.name == "end_tag":
+ break
+ tag_text.append(token.value)
+ opening_tag = "".join(tag_text).strip()
+ style_stack.append(
+ (position, opening_tag, normalize_markup_tag(opening_tag))
+ )
+
+ elif token_name == "open_closing_tag":
+ tag_text = []
+ for token in iter_tokens:
+ if token.name == "end_tag":
+ break
+ tag_text.append(token.value)
+ closing_tag = "".join(tag_text).strip()
+ normalized_closing_tag = normalize_markup_tag(closing_tag)
+ if normalized_closing_tag:
+ for index, (tag_position, tag_body, normalized_tag_body) in enumerate(
+ reversed(style_stack), 1
+ ):
+ if normalized_tag_body == normalized_closing_tag:
+ style_stack.pop(-index)
+ if tag_position != position:
+ spans.append(Span(tag_position, position, tag_body))
+ break
+ else:
+ raise MarkupError(
+ f"closing tag '[/{closing_tag}]' does not match any open tag"
+ )
+
+ else:
+ if not style_stack:
+ raise MarkupError("auto closing tag ('[/]') has nothing to close")
+ open_position, tag_body, _ = style_stack.pop()
+ spans.append(Span(open_position, position, tag_body))
+
+ content_text = "".join(text)
+ text_length = len(content_text)
+ while style_stack:
+ position, tag_body, _ = style_stack.pop()
+ spans.append(Span(position, text_length, tag_body))
+
+ if style:
+ content = Content(content_text, [Span(0, len(content_text), style), *spans])
+ else:
+ content = Content(content_text, spans)
+
+ return content
+
+
+if __name__ == "__main__": # pragma: no cover
+ from textual._markup_playground import MarkupPlayground
+
+ app = MarkupPlayground()
+ app.run()
diff --git a/src/textual/strip.py b/src/textual/strip.py
index 7549168055..2b2697a17d 100644
--- a/src/textual/strip.py
+++ b/src/textual/strip.py
@@ -112,8 +112,11 @@ def __init__(
assert get_line_length(self._segments) == cell_length
def __rich_repr__(self) -> rich.repr.Result:
- yield self._segments
- yield self.cell_length
+ try:
+ yield self._segments
+ yield self.cell_length
+ except AttributeError:
+ pass
@property
def text(self) -> str:
@@ -341,6 +344,9 @@ def adjust_cell_length(self, cell_length: int, style: Style | None = None) -> St
A new strip with the supplied cell length.
"""
+ if self.cell_length == cell_length:
+ return self
+
cache_key = (cell_length, style)
cached_strip = self._line_length_cache.get(cache_key)
if cached_strip is not None:
@@ -593,6 +599,27 @@ def apply_style(self, style: Style) -> Strip:
self._style_cache[style] = styled_strip
return styled_strip
+ def _apply_link_style(self, link_style: Style) -> Strip:
+ segments = self._segments
+ _Segment = Segment
+ segments = [
+ (
+ _Segment(
+ text,
+ (
+ style
+ if style._meta is None
+ else (style + link_style if "@click" in style.meta else style)
+ ),
+ control,
+ )
+ if style
+ else _Segment(text)
+ )
+ for text, style, control in segments
+ ]
+ return Strip(segments, self._cell_length)
+
def render(self, console: Console) -> str:
"""Render the strip into terminal sequences.
diff --git a/src/textual/style.py b/src/textual/style.py
new file mode 100644
index 0000000000..7403a283e8
--- /dev/null
+++ b/src/textual/style.py
@@ -0,0 +1,394 @@
+"""
+The Style class contains all the information needed to generate styled terminal output.
+
+You won't often need to create Style objects directly, if you are using [Content][textual.content.Content] for output.
+But you might want to use styles for more customized widgets.
+"""
+
+from __future__ import annotations
+
+from dataclasses import dataclass
+from functools import cached_property, lru_cache
+from marshal import dumps, loads
+from typing import TYPE_CHECKING, Any, Iterable, Mapping
+
+import rich.repr
+from rich.style import Style as RichStyle
+from rich.terminal_theme import TerminalTheme
+
+from textual._context import active_app
+from textual.color import Color
+
+if TYPE_CHECKING:
+ from textual.css.styles import StylesBase
+
+
+@rich.repr.auto(angular=True)
+@dataclass(frozen=True)
+class Style:
+ """Represents a style in the Visual interface (color and other attributes).
+
+ Styles may be added together, which combines their style attributes.
+
+ """
+
+ background: Color | None = None
+ foreground: Color | None = None
+ bold: bool | None = None
+ dim: bool | None = None
+ italic: bool | None = None
+ underline: bool | None = None
+ reverse: bool | None = None
+ strike: bool | None = None
+ link: str | None = None
+ _meta: bytes | None = None
+ auto_color: bool = False
+
+ def __rich_repr__(self) -> rich.repr.Result:
+ yield "background", self.background, None
+ yield "foreground", self.foreground, None
+ yield "bold", self.bold, None
+ yield "dim", self.dim, None
+ yield "italic", self.italic, None
+ yield "underline", self.underline, None
+ yield "reverse", self.reverse, None
+ yield "strike", self.strike, None
+ yield "link", self.link, None
+
+ if self._meta is not None:
+ yield "meta", self.meta
+
+ @cached_property
+ def _is_null(self) -> bool:
+ return (
+ self.foreground is None
+ and self.background is None
+ and self.bold is None
+ and self.dim is None
+ and self.italic is None
+ and self.underline is None
+ and self.reverse is None
+ and self.strike is None
+ and self.link is None
+ and self._meta is None
+ )
+
+ @cached_property
+ def hash(self) -> int:
+ return hash(
+ (
+ self.background,
+ self.foreground,
+ self.bold,
+ self.dim,
+ self.italic,
+ self.underline,
+ self.reverse,
+ self.strike,
+ self.link,
+ self.auto_color,
+ self._meta,
+ )
+ )
+
+ def __hash__(self) -> int:
+ return self.hash
+
+ def __eq__(self, other: Any) -> bool:
+ if not isinstance(other, Style):
+ return NotImplemented
+ return self.hash == other.hash
+
+ def __bool__(self) -> bool:
+ return not self._is_null
+
+ def __str__(self) -> str:
+ return self.style_definition
+
+ @cached_property
+ def style_definition(self) -> str:
+ """Style encoded in a string (may be parsed from `Style.parse`)."""
+ output: list[str] = []
+ output_append = output.append
+ if self.foreground is not None:
+ output_append(self.foreground.css)
+ if self.background is not None:
+ output_append(f"on {self.background.css}")
+ if self.bold is not None:
+ output_append("bold" if self.bold else "not bold")
+ if self.dim is not None:
+ output_append("dim" if self.dim else "not dim")
+ if self.italic is not None:
+ output_append("italic" if self.italic else "not italic")
+ if self.underline is not None:
+ output_append("underline" if self.underline else "not underline")
+ if self.strike is not None:
+ output_append("strike" if self.strike else "not strike")
+ if self.link is not None:
+ if "'" not in self.link:
+ output_append(f"link='{self.link}'")
+ elif '"' not in self.link:
+ output_append(f'link="{self.link}"')
+ if self._meta is not None:
+ for key, value in self.meta.items():
+ if isinstance(value, str):
+ if "'" not in key:
+ output_append(f"{key}='{value}'")
+ elif '"' not in key:
+ output_append(f'{key}="{value}"')
+ else:
+ output_append(f"{key}={value!r}")
+ else:
+ output_append(f"{key}={value!r}")
+
+ return " ".join(output)
+
+ @cached_property
+ def markup_tag(self) -> str:
+ """Identifier used to close tags in markup."""
+ output: list[str] = []
+ output_append = output.append
+ if self.foreground is not None:
+ output_append(self.foreground.css)
+ if self.background is not None:
+ output_append(f"on {self.background.css}")
+ if self.bold is not None:
+ output_append("bold" if self.bold else "not bold")
+ if self.dim is not None:
+ output_append("dim" if self.dim else "not dim")
+ if self.italic is not None:
+ output_append("italic" if self.italic else "not italic")
+ if self.underline is not None:
+ output_append("underline" if self.underline else "not underline")
+ if self.strike is not None:
+ output_append("strike" if self.strike else "not strike")
+ if self.link is not None:
+ output_append("link")
+ if self._meta is not None:
+ for key, value in self.meta.items():
+ if isinstance(value, str):
+ output_append(f"{key}=")
+
+ return " ".join(output)
+
+ @lru_cache(maxsize=1024 * 4)
+ def __add__(self, other: object | None) -> Style:
+ if isinstance(other, Style):
+ new_style = Style(
+ (
+ other.background
+ if self.background is None
+ else self.background + other.background
+ ),
+ self.foreground if other.foreground is None else other.foreground,
+ self.bold if other.bold is None else other.bold,
+ self.dim if other.dim is None else other.dim,
+ self.italic if other.italic is None else other.italic,
+ self.underline if other.underline is None else other.underline,
+ self.reverse if other.reverse is None else other.reverse,
+ self.strike if other.strike is None else other.strike,
+ self.link if other.link is None else other.link,
+ (
+ dumps({**self.meta, **other.meta})
+ if self._meta is not None and other._meta is not None
+ else (self._meta if other._meta is None else other._meta)
+ ),
+ )
+ return new_style
+ elif other is None:
+ return self
+ else:
+ return NotImplemented
+
+ __radd__ = __add__
+
+ @classmethod
+ def null(cls) -> Style:
+ """Get a null (no color or style) style."""
+ return NULL_STYLE
+
+ @classmethod
+ def parse(cls, text_style: str, variables: dict[str, str] | None = None) -> Style:
+ """Parse a style from text.
+
+ Args:
+ text_style: A style encoded in a string.
+ variables: Optional mapping of CSS variables. `None` to get variables from the app.
+
+ Returns:
+ New style.
+ """
+ from textual.markup import parse_style
+
+ try:
+ app = active_app.get()
+ except LookupError:
+ return parse_style(text_style, variables)
+ return app.stylesheet.parse_style(text_style)
+
+ @classmethod
+ def _normalize_markup_tag(cls, text_style: str) -> str:
+ """Produces a normalized from of a style, used to match closing tags with opening tags.
+
+ Args:
+ text_style: Style to normalize.
+
+ Returns:
+ Normalized markup tag.
+ """
+ try:
+ style = cls.parse(text_style)
+ except Exception:
+ return text_style.strip()
+ return style.markup_tag
+
+ @classmethod
+ def from_rich_style(
+ cls, rich_style: RichStyle, theme: TerminalTheme | None = None
+ ) -> Style:
+ """Build a Style from a (Rich) Style.
+
+ Args:
+ rich_style: A Rich Style object.
+ theme: Optional Rich [terminal theme][rich.terminal_theme.TerminalTheme].
+
+ Returns:
+ New Style.
+ """
+
+ return Style(
+ (
+ None
+ if rich_style.bgcolor is None
+ else Color.from_rich_color(rich_style.bgcolor, theme)
+ ),
+ (
+ None
+ if rich_style.color is None
+ else Color.from_rich_color(rich_style.color, theme)
+ ),
+ bold=rich_style.bold,
+ dim=rich_style.dim,
+ italic=rich_style.italic,
+ underline=rich_style.underline,
+ reverse=rich_style.reverse,
+ strike=rich_style.strike,
+ link=rich_style.link,
+ _meta=rich_style._meta,
+ )
+
+ @classmethod
+ def from_styles(cls, styles: StylesBase) -> Style:
+ """Create a Visual Style from a Textual styles object.
+
+ Args:
+ styles: A Styles object, such as `my_widget.styles`.
+
+ """
+ text_style = styles.text_style
+ return Style(
+ styles.background,
+ (
+ Color(0, 0, 0, styles.color.a, auto=True)
+ if styles.auto_color
+ else styles.color
+ ),
+ bold=text_style.bold,
+ dim=text_style.italic,
+ italic=text_style.italic,
+ underline=text_style.underline,
+ reverse=text_style.reverse,
+ strike=text_style.strike,
+ auto_color=styles.auto_color,
+ )
+
+ @classmethod
+ def from_meta(cls, meta: dict[str, str]) -> Style:
+ """Create a Visual Style containing meta information.
+
+ Args:
+ meta: A dictionary of meta information.
+
+ Returns:
+ A new Style.
+ """
+ return Style(_meta=dumps({**meta}))
+
+ @cached_property
+ def rich_style(self) -> RichStyle:
+ """Convert this Styles into a Rich style.
+
+ Returns:
+ A Rich style object.
+ """
+ color = None if self.foreground is None else self.background + self.foreground
+ return RichStyle(
+ color=None if color is None else color.rich_color,
+ bgcolor=None if self.background is None else self.background.rich_color,
+ bold=self.bold,
+ dim=self.dim,
+ italic=self.italic,
+ underline=self.underline,
+ reverse=self.reverse,
+ strike=self.strike,
+ link=self.link,
+ meta=None if self._meta is None else self.meta,
+ )
+
+ def rich_style_with_offset(self, x: int, y: int) -> RichStyle:
+ """Get a Rich style with the given offset included in meta.
+
+ This is used in text seleciton.
+
+ Args:
+ x: X coordinate.
+ y: Y coordinate.
+
+ Returns:
+ A Rich Style object.
+ """
+ color = None if self.foreground is None else self.background + self.foreground
+ return RichStyle(
+ color=None if color is None else color.rich_color,
+ bgcolor=None if self.background is None else self.background.rich_color,
+ bold=self.bold,
+ dim=self.dim,
+ italic=self.italic,
+ underline=self.underline,
+ reverse=self.reverse,
+ strike=self.strike,
+ link=self.link,
+ meta={**self.meta, "offset": (x, y)},
+ )
+
+ @cached_property
+ def without_color(self) -> Style:
+ """The style without any colors."""
+ return Style(
+ bold=self.bold,
+ dim=self.dim,
+ italic=self.italic,
+ reverse=self.reverse,
+ strike=self.strike,
+ link=self.link,
+ _meta=self._meta,
+ )
+
+ @cached_property
+ def background_style(self) -> Style:
+ """Just the background color, with no other attributes."""
+ return Style(self.background, _meta=self._meta)
+
+ @classmethod
+ def combine(cls, styles: Iterable[Style]) -> Style:
+ """Add a number of styles and get the result."""
+ iter_styles = iter(styles)
+ return sum(iter_styles, next(iter_styles))
+
+ @cached_property
+ def meta(self) -> Mapping[str, Any]:
+ """Get meta information (can not be changed after construction)."""
+ return {} if self._meta is None else loads(self._meta)
+
+
+NULL_STYLE = Style()
diff --git a/src/textual/visual.py b/src/textual/visual.py
index c6de6d680c..af58d5ef0a 100644
--- a/src/textual/visual.py
+++ b/src/textual/visual.py
@@ -1,11 +1,8 @@
from __future__ import annotations
from abc import ABC, abstractmethod
-from dataclasses import dataclass
-from functools import cached_property, lru_cache
from itertools import islice
-from marshal import dumps, loads
-from typing import TYPE_CHECKING, Any, Iterable, Protocol
+from typing import TYPE_CHECKING, Protocol
import rich.repr
from rich.console import Console, ConsoleOptions, RenderableType
@@ -13,23 +10,21 @@
from rich.protocol import is_renderable, rich_cast
from rich.segment import Segment
from rich.style import Style as RichStyle
-from rich.terminal_theme import TerminalTheme
from rich.text import Text
from textual._context import active_app
-from textual.color import TRANSPARENT, Color
-from textual.css.styles import StylesBase
+from textual.css.styles import RulesMap
from textual.geometry import Spacing
from textual.render import measure
+from textual.selection import Selection
from textual.strip import Strip
+from textual.style import Style
if TYPE_CHECKING:
from typing_extensions import TypeAlias
from textual.widget import Widget
-_NULL_RICH_STYLE = RichStyle()
-
def is_visual(obj: object) -> bool:
"""Check if the given object is a Visual or supports the Visual protocol."""
@@ -71,9 +66,13 @@ def visualize(widget: Widget, obj: object, markup: bool = True) -> Visual:
obj: An object.
markup: Enable markup.
+ Raises:
+ VisualError: If there is no Visual could be returned to render `obj`.
+
Returns:
A Visual instance to render the object, or `None` if there is no associated visual.
"""
+ _rich_traceback_omit = True
if isinstance(obj, Visual):
# Already a visual
return obj
@@ -83,15 +82,12 @@ def visualize(widget: Widget, obj: object, markup: bool = True) -> Visual:
# Doesn't expose the textualize protocol
from textual.content import Content
- if is_renderable(obj):
- # If it is a string, render it to Text
- if isinstance(obj, str):
- obj = widget.render_str(obj) if markup else Text(obj)
+ if isinstance(obj, str):
+ return Content.from_markup(obj) if markup else Content(obj)
+ if is_renderable(obj):
if isinstance(obj, Text) and widget.allow_select:
- return Content.from_rich_text(
- obj, align=obj.justify or widget.styles.text_align
- )
+ return Content.from_rich_text(obj, console=widget.app.console)
# If its is a Rich renderable, wrap it with a RichVisual
return RichVisual(widget, rich_cast(obj))
@@ -107,199 +103,8 @@ def visualize(widget: Widget, obj: object, markup: bool = True) -> Visual:
return visual
-@rich.repr.auto
-@dataclass(frozen=True)
-class Style:
- """Represents a style in the Visual interface (color and other attributes)."""
-
- background: Color = TRANSPARENT
- foreground: Color = TRANSPARENT
- bold: bool | None = None
- dim: bool | None = None
- italic: bool | None = None
- underline: bool | None = None
- reverse: bool | None = None
- strike: bool | None = None
- link: str | None = None
- _meta: bytes | None = None
- auto_color: bool = False
-
- def __rich_repr__(self) -> rich.repr.Result:
- yield None, self.background, TRANSPARENT
- yield None, self.foreground, TRANSPARENT
- yield "bold", self.bold, None
- yield "dim", self.dim, None
- yield "italic", self.italic, None
- yield "underline", self.underline, None
- yield "reverse", self.reverse, None
- yield "strike", self.strike, None
- yield "link", self.link, None
-
- if self._meta is not None:
- yield "meta", self.meta
-
- @lru_cache(maxsize=1024)
- def __add__(self, other: object) -> Style:
- if not isinstance(other, Style):
- return NotImplemented
- new_style = Style(
- self.background + other.background,
- self.foreground if other.foreground.is_transparent else other.foreground,
- self.bold if other.bold is None else other.bold,
- self.dim if other.dim is None else other.dim,
- self.italic if other.italic is None else other.italic,
- self.underline if other.underline is None else other.underline,
- self.reverse if other.reverse is None else other.reverse,
- self.strike if other.strike is None else other.strike,
- self.link if other.link is None else other.link,
- self._meta if other._meta is None else other._meta,
- )
- return new_style
-
- @classmethod
- def from_rich_style(
- cls, rich_style: RichStyle, theme: TerminalTheme | None = None
- ) -> Style:
- """Build a Style from a (Rich) Style.
-
- Args:
- rich_style: A Rich Style object.
- theme: Optional Rich [terminal theme][rich.terminal_theme.TerminalTheme].
-
- Returns:
- New Style.
- """
- return Style(
- Color.from_rich_color(rich_style.bgcolor, theme),
- Color.from_rich_color(rich_style.color, theme),
- bold=rich_style.bold,
- dim=rich_style.dim,
- italic=rich_style.italic,
- underline=rich_style.underline,
- reverse=rich_style.reverse,
- strike=rich_style.strike,
- link=rich_style.link,
- _meta=rich_style._meta,
- )
-
- @classmethod
- def from_styles(cls, styles: StylesBase) -> Style:
- """Create a Visual Style from a Textual styles object.
-
- Args:
- styles: A Styles object, such as `my_widget.styles`.
-
- """
- text_style = styles.text_style
- return Style(
- styles.background,
- (
- Color(0, 0, 0, styles.color.a, auto=True)
- if styles.auto_color
- else styles.color
- ),
- bold=text_style.bold,
- dim=text_style.italic,
- italic=text_style.italic,
- underline=text_style.underline,
- reverse=text_style.reverse,
- strike=text_style.strike,
- auto_color=styles.auto_color,
- )
-
- @classmethod
- def from_meta(cls, meta: dict[str, object]) -> Style:
- """Create a Visual Style containing meta information.
-
- Args:
- meta: A dictionary of meta information.
-
- Returns:
- A new Style.
- """
- return Style(_meta=dumps(meta))
-
- @cached_property
- def rich_style(self) -> RichStyle:
- """Convert this Styles into a Rich style.
-
- Returns:
- A Rich style object.
- """
- return RichStyle(
- color=(self.background + self.foreground).rich_color,
- bgcolor=self.background.rich_color,
- bold=self.bold,
- dim=self.dim,
- italic=self.italic,
- underline=self.underline,
- reverse=self.reverse,
- strike=self.strike,
- link=self.link,
- meta=self.meta,
- )
-
- def rich_style_with_offset(self, x: int, y: int) -> RichStyle:
- return RichStyle(
- color=(self.background + self.foreground).rich_color,
- bgcolor=self.background.rich_color,
- bold=self.bold,
- dim=self.dim,
- italic=self.italic,
- underline=self.underline,
- reverse=self.reverse,
- strike=self.strike,
- link=self.link,
- meta={**self.meta, "offset": (x, y)},
- )
-
- def get_rich_style(self) -> RichStyle:
- rich_style = RichStyle(
- color=(self.background + self.foreground).rich_color,
- bgcolor=self.background.rich_color,
- bold=self.bold,
- dim=self.dim,
- italic=self.italic,
- underline=self.underline,
- reverse=self.reverse,
- strike=self.strike,
- link=self.link,
- meta=self.meta,
- )
- return rich_style
-
- @cached_property
- def without_color(self) -> Style:
- """The style with no color."""
- return Style(
- bold=self.bold,
- dim=self.dim,
- italic=self.italic,
- reverse=self.reverse,
- strike=self.strike,
- link=self.link,
- _meta=self._meta,
- )
-
- @cached_property
- def background_style(self) -> Style:
- """Just the background color, with no other attributes."""
- return Style(self.background, _meta=self._meta)
-
- @classmethod
- def combine(cls, styles: Iterable[Style]) -> Style:
- """Add a number of styles and get the result."""
- iter_styles = iter(styles)
- return sum(iter_styles, next(iter_styles))
-
- @property
- def meta(self) -> dict[str, Any]:
- """Get meta information (can not be changed after construction)."""
- return {} if self._meta is None else loads(self._meta)
-
-
class Visual(ABC):
- """A Textual 'visual' object.
+ """A Textual 'Visual' object.
Analogous to a Rich renderable, but with support for transparency.
@@ -308,29 +113,38 @@ class Visual(ABC):
@abstractmethod
def render_strips(
self,
- widget: Widget,
+ rules: RulesMap,
width: int,
height: int | None,
style: Style,
+ selection: Selection | None = None,
+ selection_style: Style | None = None,
) -> list[Strip]:
- """Render the visual into an iterable of strips.
+ """Render the Visual into an iterable of strips.
Args:
- base_style: The base style.
+ rules: A mapping of style rules, such as the Widgets `styles` object.
width: Width of desired render.
height: Height of desired render or `None` for any height.
- style: A Visual Style.
+ style: The base style to render on top of.
+ selection: Selection information, if applicable, otherwise `None`.
+ selection_style: Selection style if `selection` is not `None`.
Returns:
An list of Strips.
"""
@abstractmethod
- def get_optimal_width(self, container_width: int) -> int:
- """Get ideal width of the renderable to display its content.
+ def get_optimal_width(self, rules: RulesMap, container_width: int) -> int:
+ """Get optimal width of the Visual to display its content.
+
+ The exact definition of "optimal width" is dependant on the Visual, but
+ will typically be wide enough to display output without cropping or wrapping,
+ and without superfluous space.
Args:
- container_size: The size of the container.
+ rules: A mapping of style rules, such as the Widgets `styles` object.
+ container_width: The size of the container in cells.
Returns:
A width in cells.
@@ -338,8 +152,12 @@ def get_optimal_width(self, container_width: int) -> int:
"""
@abstractmethod
- def get_height(self, width: int) -> int:
- """Get the height of the visual if rendered with the given width.
+ def get_height(self, rules: RulesMap, width: int) -> int:
+ """Get the height of the Visual if rendered at the given width.
+
+ Args:
+ rules: A mapping of style rules, such as the Widgets `styles` object.
+ width: Width of visual in cells.
Returns:
A height in lines.
@@ -369,7 +187,25 @@ def to_strips(
Returns:
A list of Strips containing the render.
"""
- strips = visual.render_strips(widget, width, height, style)
+
+ selection = widget.text_selection
+ if selection is not None:
+ selection_style: Style | None = Style.from_rich_style(
+ widget.screen.get_component_rich_style("screen--selection")
+ )
+ else:
+ selection_style = None
+
+ strips = visual.render_strips(
+ widget.styles,
+ width,
+ height,
+ style,
+ selection,
+ selection_style,
+ )
+ strips = [strip._apply_link_style(widget.link_style) for strip in strips]
+
if height is None:
height = len(strips)
rich_style = style.rich_style
@@ -420,7 +256,7 @@ def _measure(self, console: Console, options: ConsoleOptions) -> Measurement:
)
return self._measurement
- def get_optimal_width(self, container_width: int) -> int:
+ def get_optimal_width(self, rules: RulesMap, container_width: int) -> int:
console = active_app.get().console
width = measure(
console, self._renderable, container_width, container_width=container_width
@@ -428,7 +264,7 @@ def get_optimal_width(self, container_width: int) -> int:
return width
- def get_height(self, width: int) -> int:
+ def get_height(self, rules: RulesMap, width: int) -> int:
console = active_app.get().console
renderable = self._renderable
if isinstance(renderable, Text):
@@ -450,10 +286,12 @@ def get_height(self, width: int) -> int:
def render_strips(
self,
- widget: Widget,
+ rules: RulesMap,
width: int,
height: int | None,
style: Style,
+ selection: Selection | None = None,
+ selection_style: Style | None = None,
) -> list[Strip]:
console = active_app.get().console
options = console.options.update(
@@ -462,7 +300,7 @@ def render_strips(
height=height,
)
rich_style = style.rich_style
- renderable = widget.post_render(self._renderable, rich_style)
+ renderable = self._widget.post_render(self._renderable, rich_style)
segments = console.render(renderable, options.update_width(width))
strips = [
Strip(line)
@@ -482,7 +320,7 @@ def render_strips(
class Padding(Visual):
"""A Visual to pad another visual."""
- def __init__(self, visual: Visual, spacing: Spacing):
+ def __init__(self, visual: Visual, spacing: Spacing) -> None:
"""
Args:
@@ -496,18 +334,22 @@ def __rich_repr__(self) -> rich.repr.Result:
yield self._visual
yield self._spacing
- def get_optimal_width(self, container_width: int) -> int:
- return self._visual.get_optimal_width(container_width) + self._spacing.width
+ def get_optimal_width(self, rules: RulesMap, container_width: int) -> int:
+ return (
+ self._visual.get_optimal_width(rules, container_width) + self._spacing.width
+ )
- def get_height(self, width: int) -> int:
- return self._visual.get_height(width) + self._spacing.height
+ def get_height(self, rules: RulesMap, width: int) -> int:
+ return self._visual.get_height(rules, width) + self._spacing.height
def render_strips(
self,
- widget: Widget,
+ rules: RulesMap,
width: int,
height: int | None,
style: Style,
+ selection: Selection | None = None,
+ selection_style: Style | None = None,
) -> list[Strip]:
padding = self._spacing
top, right, bottom, left = self._spacing
@@ -516,10 +358,12 @@ def render_strips(
return []
strips = self._visual.render_strips(
- widget,
+ rules,
render_width,
None if height is None else height - padding.height,
style,
+ selection,
+ selection_style,
)
if padding:
@@ -536,19 +380,3 @@ def render_strips(
]
return strips
-
-
-def pick_bool(*values: bool | None) -> bool:
- """Pick the first non-none bool or return the last value.
-
- Args:
- *values (bool): Any number of boolean or None values.
-
- Returns:
- bool: First non-none boolean.
- """
- assert values, "1 or more values required"
- for value in values:
- if value is not None:
- return value
- return bool(value)
diff --git a/src/textual/widget.py b/src/textual/widget.py
index 297cbff8e2..51e63d0104 100644
--- a/src/textual/widget.py
+++ b/src/textual/widget.py
@@ -61,7 +61,7 @@
from textual.box_model import BoxModel
from textual.cache import FIFOCache
from textual.color import Color
-from textual.content import Content
+from textual.content import Content, ContentType
from textual.css.match import match
from textual.css.parse import parse_selectors
from textual.css.query import NoMatches, WrongType
@@ -87,7 +87,7 @@
from textual.rlock import RLock
from textual.selection import Selection
from textual.strip import Strip
-from textual.visual import Style as VisualStyle
+from textual.style import Style as VisualStyle
from textual.visual import Visual, visualize
if TYPE_CHECKING:
@@ -244,14 +244,15 @@ def __set_name__(self, owner: Widget, name: str) -> None:
# The private name where we store the real data.
self._internal_name = f"_{name}"
- def __set__(self, obj: Widget, title: str | Text | None) -> None:
+ def __set__(self, obj: Widget, title: Text | ContentType | None) -> None:
"""Setting a title accepts a str, Text, or None."""
+ if isinstance(title, Text):
+ title = Content.from_rich_text(title)
if title is None:
setattr(obj, self._internal_name, None)
else:
# We store the title as Text
- new_title = obj.render_str(title)
- new_title.expand_tabs(4)
+ new_title = obj.render_str(title).expand_tabs(4)
new_title = new_title.split()[0]
setattr(obj, self._internal_name, new_title)
obj.refresh()
@@ -364,9 +365,9 @@ class Widget(DOMNode):
show_horizontal_scrollbar: Reactive[bool] = Reactive(False, layout=True)
"""Show a horizontal scrollbar?"""
- border_title: str | Text | None = _BorderTitle() # type: ignore
+ border_title = _BorderTitle() # type: ignore
"""A title to show in the top border (if there is one)."""
- border_subtitle: str | Text | None = _BorderTitle() # type: ignore
+ border_subtitle = _BorderTitle()
"""A title to show in the bottom border (if there is one)."""
# Default sort order, incremented by constructor
@@ -398,6 +399,7 @@ def __init__(
id: str | None = None,
classes: str | None = None,
disabled: bool = False,
+ markup: bool = True,
) -> None:
"""Initialize a Widget.
@@ -408,6 +410,7 @@ def __init__(
classes: The CSS classes for the widget.
disabled: Whether the widget is disabled or not.
"""
+ self._render_markup = markup
_null_size = NULL_SIZE
self._size = _null_size
self._container_size = _null_size
@@ -425,8 +428,8 @@ def __init__(
self._horizontal_scrollbar: ScrollBar | None = None
self._scrollbar_corner: ScrollBarCorner | None = None
- self._border_title: Text | None = None
- self._border_subtitle: Text | None = None
+ self._border_title: Content | None = None
+ self._border_subtitle: Content | None = None
self._layout_cache: dict[str, object] = {}
"""A dict that is refreshed when the widget is resized / refreshed."""
@@ -641,7 +644,7 @@ def _render_widget(self) -> Widget:
return self._cover_widget if self._cover_widget is not None else self
@property
- def selection(self) -> Selection | None:
+ def text_selection(self) -> Selection | None:
"""Text selection information, or `None` if no text is selected in this widget."""
return self.screen.selections.get(self, None)
@@ -1093,23 +1096,26 @@ def iter_styles() -> Iterable[StylesBase]:
return visual_style
- def render_str(self, text_content: str | Text) -> Text:
- """Convert str into a Text object.
+ @overload
+ def render_str(self, text_content: str) -> Content: ...
- If you pass in an existing Text object it will be returned unaltered.
+ @overload
+ def render_str(self, text_content: Content) -> Content: ...
+
+ def render_str(self, text_content: str | Content) -> Content | Text:
+ """Convert str into a [Content][textual.content.Content] instance.
+
+ If you pass in an existing Content instance it will be returned unaltered.
Args:
- text_content: Text or str.
+ text_content: Content or str.
Returns:
- A text object.
+ Content object.
"""
- text = (
- Text.from_markup(text_content)
- if isinstance(text_content, str)
- else text_content
- )
- return text
+ if isinstance(text_content, Content):
+ return text_content
+ return Content.from_markup(text_content)
def _arrange(self, size: Size) -> DockArrangeResult:
"""Arrange children.
@@ -1624,7 +1630,7 @@ def get_content_width(self, container: Size, viewport: Size) -> int:
return self._content_width_cache[1]
visual = self._render()
- width = visual.get_optimal_width(container.width)
+ width = visual.get_optimal_width(self, container.width)
if self.expand:
width = max(container.width, width)
@@ -1663,7 +1669,7 @@ def get_content_height(self, container: Size, viewport: Size, width: int) -> int
return self._content_height_cache[1]
visual = self._render()
- height = visual.get_height(width)
+ height = visual.get_height(self.styles, width)
self._content_height_cache = (cache_key, height)
return height
@@ -1671,6 +1677,8 @@ def get_content_height(self, container: Size, viewport: Size, width: int) -> int
def watch_hover_style(
self, previous_hover_style: Style, hover_style: Style
) -> None:
+ # TODO: This will cause the widget to refresh, even when there are no links
+ # Can we avoid this?
if self.auto_links:
self.highlight_link_id = hover_style.link_id
@@ -3863,6 +3871,7 @@ def render_line(self, y: int) -> Strip:
line = self._render_cache.lines[y]
except IndexError:
line = Strip.blank(self.size.width, self.rich_style)
+
return line
def render_lines(self, crop: Region) -> list[Strip]:
@@ -3942,7 +3951,7 @@ def refresh(
Returns:
The `Widget` instance.
"""
- self._layout_cache.clear()
+
if layout:
self._layout_required = True
for ancestor in self.ancestors:
@@ -3960,6 +3969,7 @@ def refresh(
self.check_idle()
return self
+ self._layout_cache.clear()
if repaint:
self._set_dirty(*regions)
self.clear_cached_dimensions()
@@ -4026,22 +4036,24 @@ async def batch(self) -> AsyncGenerator[None, None]:
yield
def render(self) -> RenderResult:
- """Get text or Rich renderable for this widget.
+ """Get [content](/guide/content) for the widget.
+
+ Implement this method in a subclass for custom widgets.
- Implement this for custom widgets.
+ This method should return [markup](/guide/content#markup), a [Content][textual.content.Content] object, or a [Rich](https://github.com/Textualize/rich) renderable.
Example:
```python
- from textual.app import RenderableType
+ from textual.app import RenderResult
from textual.widget import Widget
class CustomWidget(Widget):
- def render(self) -> RenderableType:
+ def render(self) -> RenderResult:
return "Welcome to [bold red]Textual[/]!"
```
Returns:
- Any renderable.
+ A string or object to render as the widget's content.
"""
if self.is_container:
@@ -4062,7 +4074,7 @@ def _render(self) -> Visual:
if cached_visual is not None:
assert isinstance(cached_visual, Visual)
return cached_visual
- visual = visualize(self, self.render())
+ visual = visualize(self, self.render(), markup=self._render_markup)
self._layout_cache[cache_key] = visual
return visual
diff --git a/src/textual/widgets/_digits.py b/src/textual/widgets/_digits.py
index 431e196847..e3c6e13c31 100644
--- a/src/textual/widgets/_digits.py
+++ b/src/textual/widgets/_digits.py
@@ -77,7 +77,7 @@ def update(self, value: str) -> None:
def render(self) -> RenderResult:
"""Render digits."""
rich_style = self.rich_style
- if self.selection:
+ if self.text_selection:
rich_style += self.selection_style
digits = DigitsRenderable(self._value, rich_style)
text_align = self.styles.text_align
diff --git a/src/textual/widgets/_label.py b/src/textual/widgets/_label.py
index 90fefa7294..9cd739e729 100644
--- a/src/textual/widgets/_label.py
+++ b/src/textual/widgets/_label.py
@@ -6,6 +6,7 @@
from rich.console import RenderableType
+from textual.visual import SupportsVisual
from textual.widgets._static import Static
LabelVariant = Literal["success", "error", "warning", "primary", "secondary", "accent"]
@@ -49,7 +50,7 @@ class Label(Static):
def __init__(
self,
- renderable: RenderableType = "",
+ renderable: RenderableType | SupportsVisual = "",
*,
variant: LabelVariant | None = None,
expand: bool = False,
diff --git a/src/textual/widgets/_log.py b/src/textual/widgets/_log.py
index 360fdbf821..2504aff6ab 100644
--- a/src/textual/widgets/_log.py
+++ b/src/textual/widgets/_log.py
@@ -320,8 +320,8 @@ def _render_line_strip(self, y: int, rich_style: Style) -> Strip:
Returns:
An uncropped Strip.
"""
- selection = self.selection
- if y in self._render_line_cache and self.selection is None:
+ selection = self.text_selection
+ if y in self._render_line_cache and selection is None:
return self._render_line_cache[y]
_line = self._process_line(self._lines[y])
diff --git a/src/textual/widgets/_static.py b/src/textual/widgets/_static.py
index 607c57c499..7a354dab73 100644
--- a/src/textual/widgets/_static.py
+++ b/src/textual/widgets/_static.py
@@ -4,13 +4,12 @@
from rich.console import RenderableType
from rich.protocol import is_renderable
-from rich.text import Text
if TYPE_CHECKING:
from textual.app import RenderResult
from textual.errors import RenderError
-from textual.visual import SupportsVisual, Visual, visualize
+from textual.visual import SupportsVisual, Visual, VisualType, visualize
from textual.widget import Widget
@@ -64,17 +63,18 @@ def __init__(
classes: str | None = None,
disabled: bool = False,
) -> None:
- super().__init__(name=name, id=id, classes=classes, disabled=disabled)
+ super().__init__(
+ name=name, id=id, classes=classes, disabled=disabled, markup=markup
+ )
self.expand = expand
self.shrink = shrink
- self.markup = markup
self._content = content
self._visual: Visual | None = None
@property
def visual(self) -> Visual:
if self._visual is None:
- self._visual = visualize(self, self._content, markup=self.markup)
+ self._visual = visualize(self, self._content, markup=self._render_markup)
return self._visual
@property
@@ -83,13 +83,7 @@ def renderable(self) -> RenderableType | SupportsVisual:
@renderable.setter
def renderable(self, renderable: RenderableType | SupportsVisual) -> None:
- if isinstance(renderable, str):
- if self.markup:
- self._renderable = Text.from_markup(renderable)
- else:
- self._renderable = Text(renderable)
- else:
- self._renderable = renderable
+ self._renderable = renderable
self._visual = None
self.clear_cached_dimensions()
@@ -101,7 +95,7 @@ def render(self) -> RenderResult:
"""
return self.visual
- def update(self, content: RenderableType | SupportsVisual = "") -> None:
+ def update(self, content: VisualType = "") -> None:
"""Update the widget's content area with new text or Rich renderable.
Args:
@@ -109,5 +103,5 @@ def update(self, content: RenderableType | SupportsVisual = "") -> None:
"""
self._content = content
- self._visual = visualize(self, content, markup=self.markup)
+ self._visual = visualize(self, content, markup=self._render_markup)
self.refresh(layout=True)
diff --git a/src/textual/widgets/_tabbed_content.py b/src/textual/widgets/_tabbed_content.py
index 2d625e7cd0..cca8528090 100644
--- a/src/textual/widgets/_tabbed_content.py
+++ b/src/textual/widgets/_tabbed_content.py
@@ -6,12 +6,13 @@
from typing import Awaitable
from rich.repr import Result
-from rich.text import Text, TextType
+from rich.text import TextType
from typing_extensions import Final
from textual import events
from textual.app import ComposeResult
from textual.await_complete import AwaitComplete
+from textual.content import ContentType
from textual.css.query import NoMatches
from textual.message import Message
from textual.reactive import reactive
@@ -60,7 +61,9 @@ def sans_prefix(cls, content_id: str) -> str:
else content_id
)
- def __init__(self, label: Text, content_id: str, disabled: bool = False) -> None:
+ def __init__(
+ self, label: ContentType, content_id: str, disabled: bool = False
+ ) -> None:
"""Initialize a ContentTab.
Args:
@@ -202,7 +205,7 @@ class Focused(TabPaneMessage):
def __init__(
self,
- title: TextType,
+ title: ContentType,
*children: Widget,
name: str | None = None,
id: str | None = None,
@@ -312,7 +315,7 @@ def control(self) -> TabbedContent:
def __init__(
self,
- *titles: TextType,
+ *titles: ContentType,
initial: str = "",
name: str | None = None,
id: str | None = None,
diff --git a/src/textual/widgets/_tabs.py b/src/textual/widgets/_tabs.py
index c347bf3521..a5a1f071fb 100644
--- a/src/textual/widgets/_tabs.py
+++ b/src/textual/widgets/_tabs.py
@@ -1,10 +1,9 @@
from __future__ import annotations
from dataclasses import dataclass
-from typing import ClassVar
+from typing import TYPE_CHECKING, ClassVar
import rich.repr
-from rich.console import RenderableType
from rich.style import Style
from rich.text import Text, TextType
@@ -22,6 +21,9 @@
from textual.widget import Widget
from textual.widgets import Static
+if TYPE_CHECKING:
+ from textual.content import Content, ContentType
+
class Underline(Widget):
"""The animated underline beneath tabs."""
@@ -148,7 +150,7 @@ class Relabelled(TabMessage):
def __init__(
self,
- label: TextType,
+ label: ContentType,
*,
id: str | None = None,
classes: str | None = None,
@@ -163,23 +165,23 @@ def __init__(
disabled: Whether the tab is disabled or not.
"""
super().__init__(id=id, classes=classes, disabled=disabled)
- self._label: Text
+ self._label: Content
# Setter takes Text or str
self.label = label # type: ignore[assignment]
@property
- def label(self) -> Text:
+ def label(self) -> Content:
"""The label for the tab."""
return self._label
@label.setter
- def label(self, label: TextType) -> None:
- self._label = Text.from_markup(label) if isinstance(label, str) else label
+ def label(self, label: ContentType) -> None:
+ self._label = self.render_str(label)
self.update(self._label)
- def update(self, renderable: RenderableType = "") -> None:
+ def update(self, content: ContentType = "") -> None:
self.post_message(self.Relabelled(self))
- return super().update(renderable)
+ return super().update(self.render_str(content))
@property
def label_text(self) -> str:
diff --git a/tests/snapshot_tests/__snapshots__/test_snapshots/test_arbitrary_selection.svg b/tests/snapshot_tests/__snapshots__/test_snapshots/test_arbitrary_selection.svg
index d667dba2cd..83d061fec4 100644
--- a/tests/snapshot_tests/__snapshots__/test_snapshots/test_arbitrary_selection.svg
+++ b/tests/snapshot_tests/__snapshots__/test_snapshots/test_arbitrary_selection.svg
@@ -19,237 +19,236 @@
font-weight: 700;
}
- .terminal-1196196748-matrix {
+ .terminal-2713772870-matrix {
font-family: Fira Code, monospace;
font-size: 20px;
line-height: 24.4px;
font-variant-east-asian: full-width;
}
- .terminal-1196196748-title {
+ .terminal-2713772870-title {
font-size: 18px;
font-weight: bold;
font-family: arial;
}
- .terminal-1196196748-r1 { fill: #ff0000 }
-.terminal-1196196748-r2 { fill: #c5c8c6 }
-.terminal-1196196748-r3 { fill: #e0e0e0 }
-.terminal-1196196748-r4 { fill: #121212 }
+ .terminal-2713772870-r1 { fill: #ff0000 }
+.terminal-2713772870-r2 { fill: #c5c8c6 }
+.terminal-2713772870-r3 { fill: #e0e0e0 }
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
- MyApp
+ MyApp
-
-
-
- ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
-┃┃┃┃┃┃
-┃┃┃┃┃┃
-┃I must not fear.┃┃I must not fear.┃┃I must not fear.┃
-┃Fear is the mind-killer.┃┃Fear is the mind-killer.┃┃Fear is the mind-killer.┃
-┃Fear is the little-death that brings total ┃┃Fear is the little-death that brings total ┃┃Fear is the little-death that brings total ┃
-┃obliteration.┃┃obliteration.┃┃obliteration.┃
-┃I will face my fear.┃┃I will face my fear.┃┃I will face my fear.┃
-┃I will permit it to pass over me and through me.┃┃I will permit it to pass over me and through me.┃┃I will permit it to pass over me and through me.┃
-┃And when it has gone past, I will turn the inner┃┃And when it has gone past, I will turn the inner┃┃And when it has gone past, I will turn the inner ┃
-┃eye to see its path.┃┃eye to see its path.┃┃eye to see its path.┃
-┃Where the fear has gone there will be nothing. ┃┃Where the fear has gone there will be nothing. ┃┃Where the fear has gone there will be nothing. ┃
-┃Only I will remain.┃┃Only I will remain.┃┃Only I will remain.┃
-┃┃┃┃┃┃
-┃┃┃┃┃┃
-┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
-┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
-┃┃┃┃┃┃
-┃┃┃┃┃┃
-┃I must not fear.┃┃I must not fear.┃┃I must not fear.┃
-┃Fear is the mind-killer.┃┃Fear is the mind-killer.┃┃Fear is the mind-killer.┃
-┃Fear is the little-death that brings total ┃┃Fear is the little-death that brings total ┃┃Fear is the little-death that brings total ┃
-┃obliteration.┃┃obliteration.┃┃obliteration.┃
-┃I will face my fear.┃┃I will face my fear.┃┃I will face my fear.┃
-┃I will permit it to pass over me and through me.┃┃I will permit it to pass over me and through me.┃┃I will permit it to pass over me and through me.┃
-┃And when it has gone past, I will turn the inner┃┃And when it has gone past, I will turn the inner┃┃And when it has gone past, I will turn the inner ┃
-┃eye to see its path.┃┃eye to see its path.┃┃eye to see its path.┃
-┃Where the fear has gone there will be nothing. ┃┃Where the fear has gone there will be nothing. ┃┃Where the fear has gone there will be nothing. ┃
-┃Only I will remain.┃┃Only I will remain.┃┃Only I will remain.┃
-┃┃┃┃┃┃
-┃┃┃┃┃┃
-┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
-┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
-┃┃┃┃┃┃
-┃┃┃┃┃┃
-┃I must not fear.┃┃I must not fear.┃┃I must not fear.┃
-┃Fear is the mind-killer.┃┃Fear is the mind-killer.┃┃Fear is the mind-killer.┃
-┃Fearisthelittle-deaththatbringstotal┃┃Fearisthelittle-deaththatbringstotal┃┃Fearisthelittle-deaththatbringstotal┃
-┃obliteration.┃┃obliteration.┃┃obliteration.┃
-┃I will face my fear.┃┃I will face my fear.┃┃I will face my fear.┃
-┃I will permit it to pass over me and through me.┃┃I will permit it to pass over me and through me.┃┃I will permit it to pass over me and through me.┃
-┃Andwhenithasgonepast,Iwillturntheinner┃┃Andwhenithasgonepast,Iwillturntheinner┃┃Andwhenithasgonepast,Iwillturntheinner┃
-┃eye to see its path.┃┃eye to see its path.┃┃eye to see its path.┃
-┃Wherethefearhasgonetherewillbenothing.┃┃Wherethefearhasgonetherewillbenothing.┃┃Wherethefearhasgonetherewillbenothing.┃
-┃Only I will remain.┃┃Only I will remain.┃┃Only I will remain.┃
-┃┃┃┃┃┃
-┃┃┃┃┃┃
-┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
-
+
+
+
+ ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
+┃┃┃┃┃┃
+┃┃┃┃┃┃
+┃I must not fear.┃┃I must not fear.┃┃I must not fear.┃
+┃Fear is the mind-killer.┃┃Fear is the mind-killer.┃┃Fear is the mind-killer.┃
+┃Fear is the little-death that brings total ┃┃Fear is the little-death that brings total ┃┃Fear is the little-death that brings total ┃
+┃obliteration.┃┃obliteration.┃┃obliteration.┃
+┃I will face my fear.┃┃I will face my fear.┃┃I will face my fear.┃
+┃I will permit it to pass over me and through me.┃┃I will permit it to pass over me and through me.┃┃I will permit it to pass over me and through me.┃
+┃And when it has gone past, I will turn the inner┃┃And when it has gone past, I will turn the inner┃┃And when it has gone past, I will turn the inner ┃
+┃eye to see its path.┃┃eye to see its path.┃┃eye to see its path.┃
+┃Where the fear has gone there will be nothing. ┃┃Where the fear has gone there will be nothing. ┃┃Where the fear has gone there will be nothing. ┃
+┃Only I will remain.┃┃Only I will remain.┃┃Only I will remain.┃
+┃┃┃┃┃┃
+┃┃┃┃┃┃
+┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
+┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
+┃┃┃┃┃┃
+┃┃┃┃┃┃
+┃I must not fear.┃┃I must not fear.┃┃I must not fear.┃
+┃Fear is the mind-killer.┃┃Fear is the mind-killer.┃┃Fear is the mind-killer.┃
+┃Fear is the little-death that brings total ┃┃Fear is the little-death that brings total ┃┃Fear is the little-death that brings total ┃
+┃obliteration.┃┃obliteration.┃┃obliteration.┃
+┃I will face my fear.┃┃I will face my fear.┃┃I will face my fear.┃
+┃I will permit it to pass over me and through me.┃┃I will permit it to pass over me and through me.┃┃I will permit it to pass over me and through me.┃
+┃And when it has gone past, I will turn the inner┃┃And when it has gone past, I will turn the inner┃┃And when it has gone past, I will turn the inner ┃
+┃eye to see its path.┃┃eye to see its path.┃┃eye to see its path.┃
+┃Where the fear has gone there will be nothing. ┃┃Where the fear has gone there will be nothing. ┃┃Where the fear has gone there will be nothing. ┃
+┃Only I will remain.┃┃Only I will remain.┃┃Only I will remain.┃
+┃┃┃┃┃┃
+┃┃┃┃┃┃
+┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
+┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
+┃┃┃┃┃┃
+┃┃┃┃┃┃
+┃I must not fear.┃┃I must not fear.┃┃I must not fear.┃
+┃Fear is the mind-killer.┃┃Fear is the mind-killer.┃┃Fear is the mind-killer.┃
+┃Fearisthelittle-deaththatbringstotal┃┃Fearisthelittle-deaththatbringstotal┃┃Fearisthelittle-deaththatbringstotal┃
+┃obliteration.┃┃obliteration.┃┃obliteration.┃
+┃I will face my fear.┃┃I will face my fear.┃┃I will face my fear.┃
+┃I will permit it to pass over me and through me.┃┃I will permit it to pass over me and through me.┃┃I will permit it to pass over me and through me.┃
+┃Andwhenithasgonepast,Iwillturntheinner┃┃Andwhenithasgonepast,Iwillturntheinner┃┃Andwhenithasgonepast,Iwillturntheinner┃
+┃eye to see its path.┃┃eye to see its path.┃┃eye to see its path.┃
+┃Wherethefearhasgonetherewillbenothing.┃┃Wherethefearhasgonetherewillbenothing.┃┃Wherethefearhasgonetherewillbenothing.┃
+┃Only I will remain.┃┃Only I will remain.┃┃Only I will remain.┃
+┃┃┃┃┃┃
+┃┃┃┃┃┃
+┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
+
diff --git a/tests/snapshot_tests/__snapshots__/test_snapshots/test_border_tab.svg b/tests/snapshot_tests/__snapshots__/test_snapshots/test_border_tab.svg
index 043d051f83..0f4dfacb77 100644
--- a/tests/snapshot_tests/__snapshots__/test_snapshots/test_border_tab.svg
+++ b/tests/snapshot_tests/__snapshots__/test_snapshots/test_border_tab.svg
@@ -19,133 +19,134 @@
font-weight: 700;
}
- .terminal-1882069762-matrix {
+ .terminal-898769892-matrix {
font-family: Fira Code, monospace;
font-size: 20px;
line-height: 24.4px;
font-variant-east-asian: full-width;
}
- .terminal-1882069762-title {
+ .terminal-898769892-title {
font-size: 18px;
font-weight: bold;
font-family: arial;
}
- .terminal-1882069762-r1 { fill: #e0e0e0 }
-.terminal-1882069762-r2 { fill: #c5c8c6 }
-.terminal-1882069762-r3 { fill: #0178d4 }
-.terminal-1882069762-r4 { fill: #121212 }
+ .terminal-898769892-r1 { fill: #e0e0e0 }
+.terminal-898769892-r2 { fill: #c5c8c6 }
+.terminal-898769892-r3 { fill: #0178d4 }
+.terminal-898769892-r4 { fill: #000000 }
+.terminal-898769892-r5 { fill: #121212 }
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
- TabApp
+ TabApp
-
+
-
-
-
-
-
-
-
-
-
-▁▁ Tab Border ▁▁▁▁▁▁▁▁
-▎▊
-▎▊
-▎Hello, World▊
-▎▊
-▎▊
-▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ :-) ▔▔
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+▁▁ Tab Border ▁▁▁▁▁▁▁▁
+▎▊
+▎▊
+▎Hello, World▊
+▎▊
+▎▊
+▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ :-) ▔▔
+
+
+
+
+
+
+
+
diff --git a/tests/snapshot_tests/__snapshots__/test_snapshots/test_css_property[border.py].svg b/tests/snapshot_tests/__snapshots__/test_snapshots/test_css_property[border.py].svg
index e8bc237cc1..617fb7de84 100644
--- a/tests/snapshot_tests/__snapshots__/test_snapshots/test_css_property[border.py].svg
+++ b/tests/snapshot_tests/__snapshots__/test_snapshots/test_css_property[border.py].svg
@@ -19,135 +19,135 @@
font-weight: 700;
}
- .terminal-2006576005-matrix {
+ .terminal-3499344120-matrix {
font-family: Fira Code, monospace;
font-size: 20px;
line-height: 24.4px;
font-variant-east-asian: full-width;
}
- .terminal-2006576005-title {
+ .terminal-3499344120-title {
font-size: 18px;
font-weight: bold;
font-family: arial;
}
- .terminal-2006576005-r1 { fill: #e0e0e0 }
-.terminal-2006576005-r2 { fill: #c5c8c6 }
-.terminal-2006576005-r3 { fill: #ff0000 }
-.terminal-2006576005-r4 { fill: #008000 }
-.terminal-2006576005-r5 { fill: #ffffff }
-.terminal-2006576005-r6 { fill: #0000ff }
+ .terminal-3499344120-r1 { fill: #e0e0e0 }
+.terminal-3499344120-r2 { fill: #c5c8c6 }
+.terminal-3499344120-r3 { fill: #ff0000 }
+.terminal-3499344120-r4 { fill: #008000 }
+.terminal-3499344120-r5 { fill: #ffffff }
+.terminal-3499344120-r6 { fill: #0000ff }
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
- BorderApp
+ BorderApp
-
-
-
-
-┌────────────────────────────────────────────────────────────────────────────┐
-││
-│My border is solid red│
-││
-└────────────────────────────────────────────────────────────────────────────┘
-
-┏╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍┓
-╏╏
-╏My border is dashed green╏
-╏╏
-┗╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍┛
-
-▊▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▎
-▊▎
-▊My border is tall blue▎
-▊▎
-▊▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▎
-
-
-
-
-
+
+
+
+
+┌────────────────────────────────────────────────────────────────────────────┐
+│ │
+│ My border is solid red │
+│ │
+└────────────────────────────────────────────────────────────────────────────┘
+
+┏╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍┓
+╏ ╏
+╏ My border is dashed green ╏
+╏ ╏
+┗╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍┛
+
+▊▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▎
+▊▎
+▊My border is tall blue▎
+▊▎
+▊▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▎
+
+
+
+
+
diff --git a/tests/snapshot_tests/__snapshots__/test_snapshots/test_css_property[border_sub_title_align_all.py].svg b/tests/snapshot_tests/__snapshots__/test_snapshots/test_css_property[border_sub_title_align_all.py].svg
index bfe9daa325..232b2adf3d 100644
--- a/tests/snapshot_tests/__snapshots__/test_snapshots/test_css_property[border_sub_title_align_all.py].svg
+++ b/tests/snapshot_tests/__snapshots__/test_snapshots/test_css_property[border_sub_title_align_all.py].svg
@@ -19,141 +19,141 @@
font-weight: 700;
}
- .terminal-2613505504-matrix {
+ .terminal-345372795-matrix {
font-family: Fira Code, monospace;
font-size: 20px;
line-height: 24.4px;
font-variant-east-asian: full-width;
}
- .terminal-2613505504-title {
+ .terminal-345372795-title {
font-size: 18px;
font-weight: bold;
font-family: arial;
}
- .terminal-2613505504-r1 { fill: #e0e0e0 }
-.terminal-2613505504-r2 { fill: #c5c8c6 }
-.terminal-2613505504-r3 { fill: #004578 }
-.terminal-2613505504-r4 { fill: #004578;font-weight: bold }
-.terminal-2613505504-r5 { fill: #004578;font-weight: bold;font-style: italic; }
-.terminal-2613505504-r6 { fill: #f4005f;font-weight: bold }
-.terminal-2613505504-r7 { fill: #121212 }
-.terminal-2613505504-r8 { fill: #121212;text-decoration: underline; }
-.terminal-2613505504-r9 { fill: #004578;text-decoration: underline; }
-.terminal-2613505504-r10 { fill: #1a1a1a;text-decoration: underline; }
-.terminal-2613505504-r11 { fill: #4ebf71 }
-.terminal-2613505504-r12 { fill: #b93c5b }
+ .terminal-345372795-r1 { fill: #e0e0e0 }
+.terminal-345372795-r2 { fill: #c5c8c6 }
+.terminal-345372795-r3 { fill: #004578 }
+.terminal-345372795-r4 { fill: #004578;font-weight: bold }
+.terminal-345372795-r5 { fill: #004578;font-weight: bold;font-style: italic; }
+.terminal-345372795-r6 { fill: #ff0000;font-weight: bold }
+.terminal-345372795-r7 { fill: #121212 }
+.terminal-345372795-r8 { fill: #121212;text-decoration: underline; }
+.terminal-345372795-r9 { fill: #004578;text-decoration: underline; }
+.terminal-345372795-r10 { fill: #000000;text-decoration: underline; }
+.terminal-345372795-r11 { fill: #4ebf71 }
+.terminal-345372795-r12 { fill: #b93c5b }
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
- BorderSubTitleAlignAll
+ BorderSubTitleAlignAll
-
-
-
-
-
-▏ Border title ▕╭─ Lef… ─╮▁▁▁▁▁ Left ▁▁▁▁▁
-▏This is the story of▕│a Python│▎developer that▊
-▏ Border subtitle ▕╰─ Cen… ─╯▔▔▔▔▔ @@@ ▔▔▔▔▔▔
-
-
-
-
-
-+--------------+─Title─────────────────
-|had to fill up|nine labelsand ended up redoing it
-+- Left -------+──────────────Subtitle─
-
-
-
-
-─Title, but really looo…─
-─Title, but r…──Title, but reall…─
-because the first tryhad some labelsthat were too long.
-─Subtitle, bu…──Subtitle, but re…─
-─Subtitle, but really l…─
-
+
+
+
+
+
+▏ Border title ▕╭─ Lef… ─╮▁▁▁▁▁ Left ▁▁▁▁▁
+▏This is the story of▕│a Python│▎developer that▊
+▏ Border subtitle ▕╰─ Cen… ─╯▔▔▔▔▔ @@@ ▔▔▔▔▔▔
+
+
+
+
+
++--------------+─Title─────────────────
+|had to fill up|nine labelsand ended up redoing it
++- Left -------+──────────────Subtitle─
+
+
+
+
+─Title, but really looo…─
+─Title, but r…──Title, but reall…─
+because the first tryhad some labelsthat were too long.
+─Subtitle, bu…──Subtitle, but re…─
+─Subtitle, but really l…─
+
diff --git a/tests/snapshot_tests/__snapshots__/test_snapshots/test_css_property[box_sizing.py].svg b/tests/snapshot_tests/__snapshots__/test_snapshots/test_css_property[box_sizing.py].svg
index 930ae1e8cd..7418a75b59 100644
--- a/tests/snapshot_tests/__snapshots__/test_snapshots/test_css_property[box_sizing.py].svg
+++ b/tests/snapshot_tests/__snapshots__/test_snapshots/test_css_property[box_sizing.py].svg
@@ -19,132 +19,132 @@
font-weight: 700;
}
- .terminal-1936270938-matrix {
+ .terminal-1980698020-matrix {
font-family: Fira Code, monospace;
font-size: 20px;
line-height: 24.4px;
font-variant-east-asian: full-width;
}
- .terminal-1936270938-title {
+ .terminal-1980698020-title {
font-size: 18px;
font-weight: bold;
font-family: arial;
}
- .terminal-1936270938-r1 { fill: #000000 }
-.terminal-1936270938-r2 { fill: #c5c8c6 }
-.terminal-1936270938-r3 { fill: #ccccff }
+ .terminal-1980698020-r1 { fill: #000000 }
+.terminal-1980698020-r2 { fill: #c5c8c6 }
+.terminal-1980698020-r3 { fill: #ccccff }
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
- BoxSizingApp
+ BoxSizingApp
-
-
-
-
-
-▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁
-▎▊
-▎I'm using border-box!▊
-▎▊
-▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔
-
-
-▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁
-▎▊
-▎I'm using content-box!▊
-▎▊
-▎▊
-▎▊
-▎▊
-▎▊
-▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔
-
-
-
-
-
+
+
+
+
+
+ ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁
+▎▊
+▎I'm using border-box!▊
+▎▊
+ ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔
+
+
+ ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁
+▎▊
+▎I'm using content-box!▊
+▎▊
+▎▊
+▎▊
+▎▊
+▎▊
+ ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔
+
+
+
+
+
diff --git a/tests/snapshot_tests/__snapshots__/test_snapshots/test_css_property[offset.py].svg b/tests/snapshot_tests/__snapshots__/test_snapshots/test_css_property[offset.py].svg
index 41bf4b6eb7..1dd0fb3eb7 100644
--- a/tests/snapshot_tests/__snapshots__/test_snapshots/test_css_property[offset.py].svg
+++ b/tests/snapshot_tests/__snapshots__/test_snapshots/test_css_property[offset.py].svg
@@ -19,134 +19,134 @@
font-weight: 700;
}
- .terminal-1133023764-matrix {
+ .terminal-2976121018-matrix {
font-family: Fira Code, monospace;
font-size: 20px;
line-height: 24.4px;
font-variant-east-asian: full-width;
}
- .terminal-1133023764-title {
+ .terminal-2976121018-title {
font-size: 18px;
font-weight: bold;
font-family: arial;
}
- .terminal-1133023764-r1 { fill: #000000 }
-.terminal-1133023764-r2 { fill: #0000ff }
-.terminal-1133023764-r3 { fill: #c5c8c6 }
-.terminal-1133023764-r4 { fill: #ff0000 }
-.terminal-1133023764-r5 { fill: #008000 }
+ .terminal-2976121018-r1 { fill: #000000 }
+.terminal-2976121018-r2 { fill: #0000ff }
+.terminal-2976121018-r3 { fill: #c5c8c6 }
+.terminal-2976121018-r4 { fill: #ff0000 }
+.terminal-2976121018-r5 { fill: #008000 }
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
- OffsetApp
+ OffsetApp
-
-
-
- ▌▐
-▌Chani (offset 0 ▐
-▛▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▜▌-3)▐
-▌▐▌▐
-▌▐▌▐
-▌▐▌▐
-▌Paul (offset 8 2)▐▙▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▟
-▌▐
-▌▐
-▌▐
-▌▛▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▜
-▙▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▌▐
-▌▐
-▌▐
-▌Duncan (offset 4 ▐
-▌10)▐
-▌▐
-▌▐
-▌▐
-▙▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▟
-
-
-
+
+
+
+ ▌ ▐
+▌ Chani (offset 0 ▐
+▛▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▜▌ -3) ▐
+▌ ▐▌ ▐
+▌ ▐▌ ▐
+▌ ▐▌ ▐
+▌Paul (offset 8 2) ▐▙▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▟
+▌ ▐
+▌ ▐
+▌ ▐
+▌ ▛▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▜
+▙▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▌ ▐
+▌ ▐
+▌ ▐
+▌Duncan (offset 4 ▐
+▌10) ▐
+▌ ▐
+▌ ▐
+▌ ▐
+▙▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▟
+
+
+
diff --git a/tests/snapshot_tests/__snapshots__/test_snapshots/test_css_property[text_align.py].svg b/tests/snapshot_tests/__snapshots__/test_snapshots/test_css_property[text_align.py].svg
index adc6dab610..23446d5b4e 100644
--- a/tests/snapshot_tests/__snapshots__/test_snapshots/test_css_property[text_align.py].svg
+++ b/tests/snapshot_tests/__snapshots__/test_snapshots/test_css_property[text_align.py].svg
@@ -19,136 +19,134 @@
font-weight: 700;
}
- .terminal-1108791783-matrix {
+ .terminal-1859079648-matrix {
font-family: Fira Code, monospace;
font-size: 20px;
line-height: 24.4px;
font-variant-east-asian: full-width;
}
- .terminal-1108791783-title {
+ .terminal-1859079648-title {
font-size: 18px;
font-weight: bold;
font-family: arial;
}
- .terminal-1108791783-r1 { fill: #c5c8c6 }
-.terminal-1108791783-r2 { fill: #000000;font-weight: bold }
-.terminal-1108791783-r3 { fill: #cd5c5c }
-.terminal-1108791783-r4 { fill: #ffffff;font-weight: bold }
-.terminal-1108791783-r5 { fill: #000000 }
-.terminal-1108791783-r6 { fill: #ffffff }
-.terminal-1108791783-r7 { fill: #98fb98 }
+ .terminal-1859079648-r1 { fill: #c5c8c6 }
+.terminal-1859079648-r2 { fill: #000000;font-weight: bold }
+.terminal-1859079648-r3 { fill: #ffffff;font-weight: bold }
+.terminal-1859079648-r4 { fill: #000000 }
+.terminal-1859079648-r5 { fill: #ffffff }
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
- TextAlign
+ TextAlign
-
-
-
-
-Left alignedCenter aligned
-I must not fear. Fear is the I must not fear. Fear is the
-mind-killer. Fear is the mind-killer. Fear is the
-little-death that brings total little-death that brings total
-obliteration. I will face my fear. Iobliteration. I will face my fear. I
-will permit it to pass over me and will permit it to pass over me and
-through me.through me.
-
-
-
-
-
-Right alignedJustified
-I must not fear. Fear is the Imustnotfear.Fearisthe
-mind-killer. Fear is the mind-killer.Fearisthe
-little-death that brings total little-deaththatbringstotal
-obliteration. I will face my fear. Iobliteration.Iwillfacemyfear.I
-will permit it to pass over me and willpermitittopassovermeand
-through me.through me.
-
-
-
+
+
+
+
+Left alignedCenter aligned
+I must not fear. Fear is the I must not fear. Fear is the
+mind-killer. Fear is the mind-killer. Fear is the
+little-death that brings total little-death that brings total
+obliteration. I will face my fear. Iobliteration. I will face my fear. I
+will permit it to pass over me and will permit it to pass over me and
+through me.through me.
+
+
+
+
+
+Right alignedJustified
+I must not fear. Fear is the Imustnotfear.Fearisthe
+mind-killer. Fear is the mind-killer.Fearisthe
+little-death that brings total little-deaththatbringstotal
+obliteration. I will face my fear. Iobliteration.Iwillfacemyfear.I
+will permit it to pass over me and willpermitittopassovermeand
+through me.through me.
+
+
+
diff --git a/tests/snapshot_tests/__snapshots__/test_snapshots/test_css_property[text_opacity.py].svg b/tests/snapshot_tests/__snapshots__/test_snapshots/test_css_property[text_opacity.py].svg
index 9dcb1aef5f..02ced56515 100644
--- a/tests/snapshot_tests/__snapshots__/test_snapshots/test_css_property[text_opacity.py].svg
+++ b/tests/snapshot_tests/__snapshots__/test_snapshots/test_css_property[text_opacity.py].svg
@@ -19,135 +19,134 @@
font-weight: 700;
}
- .terminal-2545191953-matrix {
+ .terminal-3799424682-matrix {
font-family: Fira Code, monospace;
font-size: 20px;
line-height: 24.4px;
font-variant-east-asian: full-width;
}
- .terminal-2545191953-title {
+ .terminal-3799424682-title {
font-size: 18px;
font-weight: bold;
font-family: arial;
}
- .terminal-2545191953-r1 { fill: #c5c8c6 }
-.terminal-2545191953-r2 { fill: #121212 }
-.terminal-2545191953-r3 { fill: #454545;font-weight: bold }
-.terminal-2545191953-r4 { fill: #797979;font-weight: bold }
-.terminal-2545191953-r5 { fill: #acacac;font-weight: bold }
-.terminal-2545191953-r6 { fill: #e0e0e0;font-weight: bold }
+ .terminal-3799424682-r1 { fill: #c5c8c6 }
+.terminal-3799424682-r2 { fill: #454545;font-weight: bold }
+.terminal-3799424682-r3 { fill: #797979;font-weight: bold }
+.terminal-3799424682-r4 { fill: #acacac;font-weight: bold }
+.terminal-3799424682-r5 { fill: #e0e0e0;font-weight: bold }
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
- TextOpacityApp
+ TextOpacityApp
-
+
-
-
-
-
-
-text-opacity: 25%
-
-
-
-
-text-opacity: 50%
-
-
-
-
-text-opacity: 75%
-
-
-
-
-text-opacity: 100%
-
-
-
+
+
+
+
+
+text-opacity: 25%
+
+
+
+
+text-opacity: 50%
+
+
+
+
+text-opacity: 75%
+
+
+
+
+text-opacity: 100%
+
+
+
diff --git a/tests/snapshot_tests/__snapshots__/test_snapshots/test_css_property[text_overflow.py].svg b/tests/snapshot_tests/__snapshots__/test_snapshots/test_css_property[text_overflow.py].svg
new file mode 100644
index 0000000000..a8d5c3dd05
--- /dev/null
+++ b/tests/snapshot_tests/__snapshots__/test_snapshots/test_css_property[text_overflow.py].svg
@@ -0,0 +1,150 @@
+
diff --git a/tests/snapshot_tests/__snapshots__/test_snapshots/test_css_property[text_wrap.py].svg b/tests/snapshot_tests/__snapshots__/test_snapshots/test_css_property[text_wrap.py].svg
new file mode 100644
index 0000000000..07ac7d9e00
--- /dev/null
+++ b/tests/snapshot_tests/__snapshots__/test_snapshots/test_css_property[text_wrap.py].svg
@@ -0,0 +1,150 @@
+
diff --git a/tests/snapshot_tests/__snapshots__/test_snapshots/test_example_merlin.svg b/tests/snapshot_tests/__snapshots__/test_snapshots/test_example_merlin.svg
index afa0eb7789..51839391e9 100644
--- a/tests/snapshot_tests/__snapshots__/test_snapshots/test_example_merlin.svg
+++ b/tests/snapshot_tests/__snapshots__/test_snapshots/test_example_merlin.svg
@@ -19,141 +19,141 @@
font-weight: 700;
}
- .terminal-532493047-matrix {
+ .terminal-1231369836-matrix {
font-family: Fira Code, monospace;
font-size: 20px;
line-height: 24.4px;
font-variant-east-asian: full-width;
}
- .terminal-532493047-title {
+ .terminal-1231369836-title {
font-size: 18px;
font-weight: bold;
font-family: arial;
}
- .terminal-532493047-r1 { fill: #e0e0e0 }
-.terminal-532493047-r2 { fill: #121212 }
-.terminal-532493047-r3 { fill: #c5c8c6 }
-.terminal-532493047-r4 { fill: #fea62b }
-.terminal-532493047-r5 { fill: #0178d4 }
-.terminal-532493047-r6 { fill: #1e1e1e }
-.terminal-532493047-r7 { fill: #e0e0e0;font-weight: bold }
-.terminal-532493047-r8 { fill: #191919 }
-.terminal-532493047-r9 { fill: #272727 }
-.terminal-532493047-r10 { fill: #737373;font-weight: bold }
-.terminal-532493047-r11 { fill: #000000 }
+ .terminal-1231369836-r1 { fill: #e0e0e0 }
+.terminal-1231369836-r2 { fill: #121212 }
+.terminal-1231369836-r3 { fill: #c5c8c6 }
+.terminal-1231369836-r4 { fill: #fea62b }
+.terminal-1231369836-r5 { fill: #0178d4 }
+.terminal-1231369836-r6 { fill: #e0e0e0;font-weight: bold }
+.terminal-1231369836-r7 { fill: #1e1e1e }
+.terminal-1231369836-r8 { fill: #191919 }
+.terminal-1231369836-r9 { fill: #272727 }
+.terminal-1231369836-r10 { fill: #737373;font-weight: bold }
+.terminal-1231369836-r11 { fill: #000000 }
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
- MerlinApp
+ MerlinApp
-
-
-
-
-
-╭─╮ ╭─╮╭─╮ ╭─╮╭─╮
-│ │ : │ ││ │ : │ ││ │
-╰─╯ ╰─╯╰─╯ ╰─╯╰─╯
-
-
-█▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀█
-██
-█789█
-█▊▔▔▔▔▔▔▔▔▎▊▔▔▔▔▔▔▔▔▎▊▔▔▔▔▔▔▔▔▎█
-█▊▎▊▎▊▎█
-█▊▁▁▁▁▁▁▁▁▎▊▁▁▁▁▁▁▁▁▎▊▁▁▁▁▁▁▁▁▎█
-██
-█456█
-█▊▔▔▔▔▔▔▔▔▎▊▔▔▔▔▔▔▔▔▎▊▔▔▔▔▔▔▔▔▎█
-█▊▎▊▎▊▎█
-█▊▁▁▁▁▁▁▁▁▎▊▁▁▁▁▁▁▁▁▎▊▁▁▁▁▁▁▁▁▎█
-██
-█123█
-█▊▔▔▔▔▔▔▔▔▎▊▔▔▔▔▔▔▔▔▎▊▔▔▔▔▔▔▔▔▎█
-█▊▎▊▎▊▎█
-█▊▁▁▁▁▁▁▁▁▎▊▁▁▁▁▁▁▁▁▎▊▁▁▁▁▁▁▁▁▎█
-██▇▇
+
+
+
+
+
+╭─╮ ╭─╮╭─╮ ╭─╮╭─╮
+│ │ : │ ││ │ : │ ││ │
+╰─╯ ╰─╯╰─╯ ╰─╯╰─╯
+
+
+█▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀█
+██
+█789█
+█▊▔▔▔▔▔▔▔▔▎▊▔▔▔▔▔▔▔▔▎▊▔▔▔▔▔▔▔▔▎█
+█▊▎▊▎▊▎█
+█▊▁▁▁▁▁▁▁▁▎▊▁▁▁▁▁▁▁▁▎▊▁▁▁▁▁▁▁▁▎█
+██
+█456█
+█▊▔▔▔▔▔▔▔▔▎▊▔▔▔▔▔▔▔▔▎▊▔▔▔▔▔▔▔▔▎█
+█▊▎▊▎▊▎█
+█▊▁▁▁▁▁▁▁▁▎▊▁▁▁▁▁▁▁▁▎▊▁▁▁▁▁▁▁▁▎█
+██
+█123█
+█▊▔▔▔▔▔▔▔▔▎▊▔▔▔▔▔▔▔▔▎▊▔▔▔▔▔▔▔▔▎█
+█▊▎▊▎▊▎█
+█▊▁▁▁▁▁▁▁▁▎▊▁▁▁▁▁▁▁▁▎▊▁▁▁▁▁▁▁▁▎█
+██▇▇
diff --git a/tests/snapshot_tests/__snapshots__/test_snapshots/test_markdown_theme_switching.svg b/tests/snapshot_tests/__snapshots__/test_snapshots/test_markdown_theme_switching.svg
index ae9c69de35..6d18380325 100644
--- a/tests/snapshot_tests/__snapshots__/test_snapshots/test_markdown_theme_switching.svg
+++ b/tests/snapshot_tests/__snapshots__/test_snapshots/test_markdown_theme_switching.svg
@@ -19,139 +19,140 @@
font-weight: 700;
}
- .terminal-2365147043-matrix {
+ .terminal-99830303-matrix {
font-family: Fira Code, monospace;
font-size: 20px;
line-height: 24.4px;
font-variant-east-asian: full-width;
}
- .terminal-2365147043-title {
+ .terminal-99830303-title {
font-size: 18px;
font-weight: bold;
font-family: arial;
}
- .terminal-2365147043-r1 { fill: #c5c8c6 }
-.terminal-2365147043-r2 { fill: #1f1f1f }
-.terminal-2365147043-r3 { fill: #004578;font-weight: bold }
-.terminal-2365147043-r4 { fill: #d2d2d2 }
-.terminal-2365147043-r5 { fill: #008000;font-weight: bold }
-.terminal-2365147043-r6 { fill: #000000 }
-.terminal-2365147043-r7 { fill: #0000ff }
-.terminal-2365147043-r8 { fill: #87adad;font-style: italic; }
-.terminal-2365147043-r9 { fill: #008000 }
-.terminal-2365147043-r10 { fill: #ba2121 }
+ .terminal-99830303-r1 { fill: #c5c8c6 }
+.terminal-99830303-r2 { fill: #1f1f1f }
+.terminal-99830303-r3 { fill: #004578;font-weight: bold }
+.terminal-99830303-r4 { fill: #d2d2d2 }
+.terminal-99830303-r5 { fill: #008000;font-weight: bold }
+.terminal-99830303-r6 { fill: #bbbbbb }
+.terminal-99830303-r7 { fill: #0000ff }
+.terminal-99830303-r8 { fill: #000000 }
+.terminal-99830303-r9 { fill: #87adad;font-style: italic; }
+.terminal-99830303-r10 { fill: #008000 }
+.terminal-99830303-r11 { fill: #ba2121 }
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
- MarkdownThemeSwitcherApp
+ MarkdownThemeSwitcherApp
-
+
-
-
-
-This is a H1
-
-
-defmain():
-│ print("Hello world!")
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+This is a H1
+
+
+defmain():
+│ print("Hello world!")
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/tests/snapshot_tests/__snapshots__/test_snapshots/test_markup.svg b/tests/snapshot_tests/__snapshots__/test_snapshots/test_markup.svg
new file mode 100644
index 0000000000..be5cef5577
--- /dev/null
+++ b/tests/snapshot_tests/__snapshots__/test_snapshots/test_markup.svg
@@ -0,0 +1,160 @@
+
diff --git a/tests/snapshot_tests/__snapshots__/test_snapshots/test_no_wrap.svg b/tests/snapshot_tests/__snapshots__/test_snapshots/test_no_wrap.svg
new file mode 100644
index 0000000000..334f0f7cbe
--- /dev/null
+++ b/tests/snapshot_tests/__snapshots__/test_snapshots/test_no_wrap.svg
@@ -0,0 +1,150 @@
+
diff --git a/tests/snapshot_tests/__snapshots__/test_snapshots/test_overflow.svg b/tests/snapshot_tests/__snapshots__/test_snapshots/test_overflow.svg
new file mode 100644
index 0000000000..d75b2ed717
--- /dev/null
+++ b/tests/snapshot_tests/__snapshots__/test_snapshots/test_overflow.svg
@@ -0,0 +1,150 @@
+
diff --git a/tests/snapshot_tests/__snapshots__/test_snapshots/test_rule_horizontal_rules.svg b/tests/snapshot_tests/__snapshots__/test_snapshots/test_rule_horizontal_rules.svg
index 3887e7c020..d33be2ccbd 100644
--- a/tests/snapshot_tests/__snapshots__/test_snapshots/test_rule_horizontal_rules.svg
+++ b/tests/snapshot_tests/__snapshots__/test_snapshots/test_rule_horizontal_rules.svg
@@ -19,133 +19,132 @@
font-weight: 700;
}
- .terminal-2557043516-matrix {
+ .terminal-3438346010-matrix {
font-family: Fira Code, monospace;
font-size: 20px;
line-height: 24.4px;
font-variant-east-asian: full-width;
}
- .terminal-2557043516-title {
+ .terminal-3438346010-title {
font-size: 18px;
font-weight: bold;
font-family: arial;
}
- .terminal-2557043516-r1 { fill: #e0e0e0 }
-.terminal-2557043516-r2 { fill: #121212 }
-.terminal-2557043516-r3 { fill: #c5c8c6 }
-.terminal-2557043516-r4 { fill: #004578 }
+ .terminal-3438346010-r1 { fill: #e0e0e0 }
+.terminal-3438346010-r2 { fill: #c5c8c6 }
+.terminal-3438346010-r3 { fill: #004578 }
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
- HorizontalRulesApp
+ HorizontalRulesApp
-
+
-
- solid (default)
-
-────────────────────────────────────────────────────────────────
-
-heavy
-
-━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
-
-thick
-
-████████████████████████████████████████████████████████████████
-
-dashed
-
-╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍
-
-double
-
-════════════════════════════════════════════════════════════════
-
-ascii
-
-----------------------------------------------------------------
+
+ solid (default)
+
+────────────────────────────────────────────────────────────────
+
+heavy
+
+━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+
+thick
+
+████████████████████████████████████████████████████████████████
+
+dashed
+
+╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍
+
+double
+
+════════════════════════════════════════════════════════════════
+
+ascii
+
+----------------------------------------------------------------
diff --git a/tests/snapshot_tests/__snapshots__/test_snapshots/test_rule_vertical_rules.svg b/tests/snapshot_tests/__snapshots__/test_snapshots/test_rule_vertical_rules.svg
index b09e9cb616..708639cb1b 100644
--- a/tests/snapshot_tests/__snapshots__/test_snapshots/test_rule_vertical_rules.svg
+++ b/tests/snapshot_tests/__snapshots__/test_snapshots/test_rule_vertical_rules.svg
@@ -19,133 +19,132 @@
font-weight: 700;
}
- .terminal-1062761350-matrix {
+ .terminal-2259771123-matrix {
font-family: Fira Code, monospace;
font-size: 20px;
line-height: 24.4px;
font-variant-east-asian: full-width;
}
- .terminal-1062761350-title {
+ .terminal-2259771123-title {
font-size: 18px;
font-weight: bold;
font-family: arial;
}
- .terminal-1062761350-r1 { fill: #e0e0e0 }
-.terminal-1062761350-r2 { fill: #c5c8c6 }
-.terminal-1062761350-r3 { fill: #121212 }
-.terminal-1062761350-r4 { fill: #004578 }
+ .terminal-2259771123-r1 { fill: #e0e0e0 }
+.terminal-2259771123-r2 { fill: #c5c8c6 }
+.terminal-2259771123-r3 { fill: #004578 }
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
- VerticalRulesApp
+ VerticalRulesApp
-
-
-
-
-
-solid│heavy┃thick█dashed╏double║ascii|
-│┃█╏║|
-│┃█╏║|
-│┃█╏║|
-│┃█╏║|
-│┃█╏║|
-│┃█╏║|
-│┃█╏║|
-│┃█╏║|
-│┃█╏║|
-│┃█╏║|
-│┃█╏║|
-│┃█╏║|
-│┃█╏║|
-│┃█╏║|
-│┃█╏║|
-│┃█╏║|
-│┃█╏║|
-│┃█╏║|
-
-
+
+
+
+
+
+solid│heavy┃thick█dashed╏double║ascii|
+│┃█╏║|
+│┃█╏║|
+│┃█╏║|
+│┃█╏║|
+│┃█╏║|
+│┃█╏║|
+│┃█╏║|
+│┃█╏║|
+│┃█╏║|
+│┃█╏║|
+│┃█╏║|
+│┃█╏║|
+│┃█╏║|
+│┃█╏║|
+│┃█╏║|
+│┃█╏║|
+│┃█╏║|
+│┃█╏║|
+
+
diff --git a/tests/snapshot_tests/__snapshots__/test_snapshots/test_scroll_to_center.svg b/tests/snapshot_tests/__snapshots__/test_snapshots/test_scroll_to_center.svg
index 9d3479aecf..ee7b7b637b 100644
--- a/tests/snapshot_tests/__snapshots__/test_snapshots/test_scroll_to_center.svg
+++ b/tests/snapshot_tests/__snapshots__/test_snapshots/test_scroll_to_center.svg
@@ -19,138 +19,138 @@
font-weight: 700;
}
- .terminal-167089035-matrix {
+ .terminal-2811073350-matrix {
font-family: Fira Code, monospace;
font-size: 20px;
line-height: 24.4px;
font-variant-east-asian: full-width;
}
- .terminal-167089035-title {
+ .terminal-2811073350-title {
font-size: 18px;
font-weight: bold;
font-family: arial;
}
- .terminal-167089035-r1 { fill: #e0e0e0 }
-.terminal-167089035-r2 { fill: #c5c8c6 }
-.terminal-167089035-r3 { fill: #0178d4 }
-.terminal-167089035-r4 { fill: #003054 }
-.terminal-167089035-r5 { fill: #fea62b }
-.terminal-167089035-r6 { fill: #121212 }
-.terminal-167089035-r7 { fill: #f4005f }
-.terminal-167089035-r8 { fill: #000000 }
+ .terminal-2811073350-r1 { fill: #e0e0e0 }
+.terminal-2811073350-r2 { fill: #c5c8c6 }
+.terminal-2811073350-r3 { fill: #0178d4 }
+.terminal-2811073350-r4 { fill: #003054 }
+.terminal-2811073350-r5 { fill: #fea62b }
+.terminal-2811073350-r6 { fill: #121212 }
+.terminal-2811073350-r7 { fill: #ff0000 }
+.terminal-2811073350-r8 { fill: #000000 }
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
- MyApp
+ MyApp
-
+
-
- SPAM
-╭────────────────────────────────────────────────────────────────────────────╮
-│SPAM│
-│SPAM│
-│SPAM│
-│SPAM│
-│SPAM│
-│SPAM│
-│SPAM│
-│SPAM│▁▁
-│╭────────────────────────────────────────────────────────────────────────╮│
-││@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@>>bullseye<<@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@││
-││││
-│││▄▄│
-││││▄▄
-││││
-││││
-││││
-││││
-││││
-││││
-╰────────────────────────────────────────────────────────────────────────────╯
-SPAM
-SPAM
+
+ SPAM
+╭────────────────────────────────────────────────────────────────────────────╮
+│SPAM│
+│SPAM│
+│SPAM│
+│SPAM│
+│SPAM│
+│SPAM│
+│SPAM│
+│SPAM│▁▁
+│╭────────────────────────────────────────────────────────────────────────╮│
+││@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@>>bullseye<<@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@││
+││││
+│││▄▄│
+││││▄▄
+││││
+││││
+││││
+││││
+││││
+││││
+╰────────────────────────────────────────────────────────────────────────────╯
+SPAM
+SPAM
diff --git a/tests/snapshot_tests/__snapshots__/test_snapshots/test_textual_dev_colors_preview.svg b/tests/snapshot_tests/__snapshots__/test_snapshots/test_textual_dev_colors_preview.svg
index 8662b8cc10..827c349428 100644
--- a/tests/snapshot_tests/__snapshots__/test_snapshots/test_textual_dev_colors_preview.svg
+++ b/tests/snapshot_tests/__snapshots__/test_snapshots/test_textual_dev_colors_preview.svg
@@ -19,150 +19,150 @@
font-weight: 700;
}
- .terminal-514010264-matrix {
+ .terminal-4282265730-matrix {
font-family: Fira Code, monospace;
font-size: 20px;
line-height: 24.4px;
font-variant-east-asian: full-width;
}
- .terminal-514010264-title {
+ .terminal-4282265730-title {
font-size: 18px;
font-weight: bold;
font-family: arial;
}
- .terminal-514010264-r1 { fill: #e0e0e0 }
-.terminal-514010264-r2 { fill: #c5c8c6 }
-.terminal-514010264-r3 { fill: #ddedf9;font-weight: bold }
-.terminal-514010264-r4 { fill: #797979 }
-.terminal-514010264-r5 { fill: #4f4f4f }
-.terminal-514010264-r6 { fill: #0178d4 }
-.terminal-514010264-r7 { fill: #121212 }
-.terminal-514010264-r8 { fill: #1e1e1e }
-.terminal-514010264-r9 { fill: #e1e1e1;font-weight: bold }
-.terminal-514010264-r10 { fill: #dde6f1 }
-.terminal-514010264-r11 { fill: #99b3d4 }
-.terminal-514010264-r12 { fill: #dde8f3 }
-.terminal-514010264-r13 { fill: #99badd }
-.terminal-514010264-r14 { fill: #ddeaf6 }
-.terminal-514010264-r15 { fill: #99c1e5 }
-.terminal-514010264-r16 { fill: #ddedf9 }
-.terminal-514010264-r17 { fill: #99c9ed }
-.terminal-514010264-r18 { fill: #003054 }
-.terminal-514010264-r19 { fill: #ffa62b;font-weight: bold }
-.terminal-514010264-r20 { fill: #495259 }
+ .terminal-4282265730-r1 { fill: #e0e0e0 }
+.terminal-4282265730-r2 { fill: #c5c8c6 }
+.terminal-4282265730-r3 { fill: #ddedf9;font-weight: bold }
+.terminal-4282265730-r4 { fill: #797979 }
+.terminal-4282265730-r5 { fill: #4f4f4f }
+.terminal-4282265730-r6 { fill: #0178d4 }
+.terminal-4282265730-r7 { fill: #121212 }
+.terminal-4282265730-r8 { fill: #000000 }
+.terminal-4282265730-r9 { fill: #e1e1e1;font-weight: bold }
+.terminal-4282265730-r10 { fill: #dde6f1 }
+.terminal-4282265730-r11 { fill: #99b3d4 }
+.terminal-4282265730-r12 { fill: #dde8f3 }
+.terminal-4282265730-r13 { fill: #99badd }
+.terminal-4282265730-r14 { fill: #ddeaf6 }
+.terminal-4282265730-r15 { fill: #99c1e5 }
+.terminal-4282265730-r16 { fill: #ddedf9 }
+.terminal-4282265730-r17 { fill: #99c9ed }
+.terminal-4282265730-r18 { fill: #003054 }
+.terminal-4282265730-r19 { fill: #ffa62b;font-weight: bold }
+.terminal-4282265730-r20 { fill: #495259 }
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
- ColorsApp
+ ColorsApp
-
+
-
-
-Theme ColorsNamed Colors
-╸━━━━━━━━━━━━╺━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
-
-▊█ Theme Colors █████████▎
-▊▎
-▊primary ▎▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁
-▊secondary ▎▎
-▊background ▎▎"primary"
-▊primary-background ▎▎
-▊secondary-background▎▎
-▊surface ▎▎$primary-darken-3$text-mute
-▊panel ▎▎
-▊boost ▎▎
-▊warning ▎▎$primary-darken-2$text-mute
-▊error ▎▎
-▊success ▎▎
-▊accent ▎▎$primary-darken-1$text-mute
-▊▎▎
-▊▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▎▎
-▎$primary$text-mute
-▎
-▉
- [ Previous theme ] Next theme ▏^p palette
+
+
+Theme ColorsNamed Colors
+╸━━━━━━━━━━━━╺━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+
+▊█ Theme Colors █████████▎
+▊▎
+▊primary ▎▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁
+▊secondary ▎▎
+▊background ▎▎"primary"
+▊primary-background ▎▎
+▊secondary-background▎▎
+▊surface ▎▎$primary-darken-3$text-mute
+▊panel ▎▎
+▊boost ▎▎
+▊warning ▎▎$primary-darken-2$text-mute
+▊error ▎▎
+▊success ▎▎
+▊accent ▎▎$primary-darken-1$text-mute
+▊▎▎
+▊▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▎▎
+▎$primary$text-mute
+▎
+▉
+ [ Previous theme ] Next theme ▏^p palette
diff --git a/tests/snapshot_tests/__snapshots__/test_snapshots/test_toggle_style_order.svg b/tests/snapshot_tests/__snapshots__/test_snapshots/test_toggle_style_order.svg
index 623a85acb1..5cfb9d4f53 100644
--- a/tests/snapshot_tests/__snapshots__/test_snapshots/test_toggle_style_order.svg
+++ b/tests/snapshot_tests/__snapshots__/test_snapshots/test_toggle_style_order.svg
@@ -19,139 +19,139 @@
font-weight: 700;
}
- .terminal-2214966114-matrix {
+ .terminal-2591470392-matrix {
font-family: Fira Code, monospace;
font-size: 20px;
line-height: 24.4px;
font-variant-east-asian: full-width;
}
- .terminal-2214966114-title {
+ .terminal-2591470392-title {
font-size: 18px;
font-weight: bold;
font-family: arial;
}
- .terminal-2214966114-r1 { fill: #121212 }
-.terminal-2214966114-r2 { fill: #0178d4 }
-.terminal-2214966114-r3 { fill: #e0e0e0 }
-.terminal-2214966114-r4 { fill: #c5c8c6 }
-.terminal-2214966114-r5 { fill: #242f38 }
-.terminal-2214966114-r6 { fill: #000f18 }
-.terminal-2214966114-r7 { fill: #f4005f;font-weight: bold }
-.terminal-2214966114-r8 { fill: #80bbe9;font-weight: bold }
-.terminal-2214966114-r9 { fill: #830938;font-weight: bold }
-.terminal-2214966114-r10 { fill: #888888 }
+ .terminal-2591470392-r1 { fill: #121212 }
+.terminal-2591470392-r2 { fill: #0178d4 }
+.terminal-2591470392-r3 { fill: #e0e0e0 }
+.terminal-2591470392-r4 { fill: #c5c8c6 }
+.terminal-2591470392-r5 { fill: #242f38 }
+.terminal-2591470392-r6 { fill: #000f18 }
+.terminal-2591470392-r7 { fill: #f4005f;font-weight: bold }
+.terminal-2591470392-r8 { fill: #80bbe9;font-weight: bold }
+.terminal-2591470392-r9 { fill: #880909;font-weight: bold }
+.terminal-2591470392-r10 { fill: #888888 }
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
- CheckboxApp
+ CheckboxApp
-
+
-
- ▊▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▎
-▊▐X▌This is just some text.▎
-▊▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▎
-This is just some text.
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+ ▊▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▎
+▊▐X▌This is just some text.▎
+▊▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▎
+This is just some text.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/tests/snapshot_tests/snapshot_apps/ansi_mapping.py b/tests/snapshot_tests/snapshot_apps/ansi_mapping.py
index 4775c1e05d..3ca591718a 100644
--- a/tests/snapshot_tests/snapshot_apps/ansi_mapping.py
+++ b/tests/snapshot_tests/snapshot_apps/ansi_mapping.py
@@ -5,19 +5,20 @@
class AnsiMappingApp(App[None]):
def compose(self) -> ComposeResult:
ansi_colors = [
- "red",
- "green",
- "yellow",
- "blue",
- "magenta",
- "cyan",
- "white",
- "black",
+ "ansi_red",
+ "ansi_green",
+ "ansi_yellow",
+ "ansi_blue",
+ "ansi_magenta",
+ "ansi_cyan",
+ "ansi_white",
+ "ansi_black",
]
yield Label("Foreground & background")
for color in ansi_colors:
- yield Label(f"[{color}]{color}[/]")
- yield Label(f"[dim {color}]dim {color}[/]")
+ color_name = color.partition("_")[-1]
+ yield Label(f"[{color}]{color_name}[/]")
+ yield Label(f"[dim {color}]dim {color_name}[/]")
app = AnsiMappingApp()
diff --git a/tests/snapshot_tests/snapshot_apps/auto_tab_active.py b/tests/snapshot_tests/snapshot_apps/auto_tab_active.py
index 07c9897e2c..c13aca1500 100644
--- a/tests/snapshot_tests/snapshot_apps/auto_tab_active.py
+++ b/tests/snapshot_tests/snapshot_apps/auto_tab_active.py
@@ -7,18 +7,18 @@ class ExampleApp(App):
def compose(self) -> ComposeResult:
with TabbedContent(id="tabbed-root"):
- with TabPane("[red]Parent 1[/]"):
+ with TabPane("[ansi_red]Parent 1[/]"):
with TabbedContent():
- with TabPane("[red]Child 1.1[/]"):
+ with TabPane("[ansi_red]Child 1.1[/]"):
yield Button("Button 1.1", variant="error")
- with TabPane("[red]Child 1.2[/]"):
+ with TabPane("[ansi_red]Child 1.2[/]"):
yield Button("Button 1.2", variant="error")
- with TabPane("[green]Parent 2[/]", id="parent-2"):
+ with TabPane("[ansi_green]Parent 2[/]", id="parent-2"):
with TabbedContent(id="tabbed-parent-2"):
- with TabPane("[green]Child 2.1[/]"):
+ with TabPane("[ansi_green]Child 2.1[/]"):
yield Button("Button 2.1", variant="success")
- with TabPane("[green]Child 2.2[/]", id="child-2-2"):
+ with TabPane("[ansi_green]Child 2.2[/]", id="child-2-2"):
yield Button(
"Button 2.2",
variant="success",
diff --git a/tests/snapshot_tests/snapshot_apps/markup.py b/tests/snapshot_tests/snapshot_apps/markup.py
new file mode 100644
index 0000000000..defdc35b84
--- /dev/null
+++ b/tests/snapshot_tests/snapshot_apps/markup.py
@@ -0,0 +1,24 @@
+from textual.app import App, ComposeResult
+from textual.widgets import Label
+
+
+class ContentApp(App):
+
+ def compose(self) -> ComposeResult:
+ yield Label("[bold]Bold[/] [italic]Italic[/] [u]Underline[/] [s]Strike[/s]")
+ yield Label(
+ "[$primary]Primary[/] [$secondary]Secondary[/] [$warning]Warning[/] [$error]Error[/]"
+ )
+ yield Label("[$text on $primary]Text on Primary")
+ yield Label("[$primary on $primary-muted]Primary on primary muted")
+ yield Label("[$error on $error-muted]Error on error muted")
+ yield Label(
+ "[on $boost] [on $boost] [on $boost] Three layers of $boost [/] [/] [/]"
+ )
+ yield Label("[on $primary 20%]On primary twenty percent")
+ yield Label("[$text 80% on $primary]Hello")
+
+
+if __name__ == "__main__":
+ app = ContentApp()
+ app.run()
diff --git a/tests/snapshot_tests/snapshot_apps/toggle_style_order.py b/tests/snapshot_tests/snapshot_apps/toggle_style_order.py
index a92e41a421..68fd684990 100644
--- a/tests/snapshot_tests/snapshot_apps/toggle_style_order.py
+++ b/tests/snapshot_tests/snapshot_apps/toggle_style_order.py
@@ -12,7 +12,7 @@ class CheckboxApp(App):
def compose(self):
yield Checkbox("[red bold]This is just[/] some text.")
- yield Label("[red bold]This is just[/] some text.")
+ yield Label("[bold red]This is just[/] some text.")
if __name__ == "__main__":
diff --git a/tests/snapshot_tests/test_snapshots.py b/tests/snapshot_tests/test_snapshots.py
index 892d897a30..0c0cb70f2e 100644
--- a/tests/snapshot_tests/test_snapshots.py
+++ b/tests/snapshot_tests/test_snapshots.py
@@ -183,7 +183,8 @@ def on_mount(self) -> None:
def test_input_cursor(snap_compare):
"""The first input should say こんにちは.
The second input should say こんにちは, with a cursor on the final character (double width).
- Note that this might render incorrectly in the SVG output - the letters may overlap."""
+ Note that this might render incorrectly in the SVG output - the letters may overlap.
+ """
class InputApp(App[None]):
def compose(self) -> ComposeResult:
@@ -2078,7 +2079,7 @@ class ANSIApp(App):
"""
def compose(self) -> ComposeResult:
- yield Label("[red]Red[/] [magenta]Magenta[/]")
+ yield Label("[ansi_red]Red[/] [ansi_magenta]Magenta[/]")
app = ANSIApp(ansi_color=True)
assert snap_compare(app)
@@ -2096,7 +2097,7 @@ class CommandPaletteApp(App[None]):
"""
def compose(self) -> ComposeResult:
- yield Label("[red]Red[/] [magenta]Magenta[/] " * 200)
+ yield Label("[ansi_red]Red[/] [ansi_magenta]Magenta[/] " * 200)
def on_mount(self) -> None:
self.action_command_palette()
@@ -2886,14 +2887,17 @@ async def run_before(pilot: Pilot) -> None:
def test_markup_command_list(snap_compare):
"""Regression test for https://github.com/Textualize/textual/issues/5276
- You should see a command list, with console markup applied to the action name and help text."""
+ You should see a command list, with console markup applied to the action name and help text.
+ """
class MyApp(App):
def on_mount(self) -> None:
self.search_commands(
[
SimpleCommand(
- "Hello [u green]World", lambda: None, "Help [u red]text"
+ "Hello [u ansi_green]World",
+ lambda: None,
+ "Help [u ansi_red]text",
)
]
)
@@ -2945,7 +2949,8 @@ def on_resize(self) -> None:
def test_add_remove_tabs(snap_compare):
"""Regression test for https://github.com/Textualize/textual/issues/5215
- You should see a TabbedContent with three panes, entitled 'tab-2', 'New tab' and 'New tab'"""
+ You should see a TabbedContent with three panes, entitled 'tab-2', 'New tab' and 'New tab'
+ """
class ExampleApp(App):
BINDINGS = [
@@ -2992,7 +2997,8 @@ async def run_before(pilot: Pilot) -> None:
def test_disable_command_palette(snap_compare):
"""Test command palette may be disabled by check_action.
- You should see a footer with an enabled binding, and the command palette binding greyed out."""
+ You should see a footer with an enabled binding, and the command palette binding greyed out.
+ """
class FooterApp(App):
BINDINGS = [("b", "bell", "Bell")]
@@ -3047,7 +3053,8 @@ def compose(self) -> ComposeResult:
def test_dock_align(snap_compare):
"""Regression test for https://github.com/Textualize/textual/issues/5345
- You should see a blue panel aligned to the top right of the screen, with a centered button."""
+ You should see a blue panel aligned to the top right of the screen, with a centered button.
+ """
class MainContainer(Static):
def compose(self):
@@ -3319,7 +3326,7 @@ def test_static_markup(snap_compare):
You should see 3 labels.
This first label contains an invalid style, and should have tags removed.
- The second label should have the word "markup" boldened.
+ The second label should have the word "markup" emboldened.
The third label has markup disabled, and should show tags without styles.
"""
@@ -3335,7 +3342,8 @@ def compose(self) -> ComposeResult:
def test_arbitrary_selection_double_cell(snap_compare):
"""Check that selection understands double width cells.
- You should see a smiley face followed by 'Hello World!', where Hello is highlighted."""
+ You should see a smiley face followed by 'Hello World!', where Hello is highlighted.
+ """
class LApp(App):
def compose(self) -> ComposeResult:
@@ -3348,3 +3356,68 @@ async def run_before(pilot: Pilot) -> None:
await pilot.pause()
assert snap_compare(LApp(), run_before=run_before)
+
+
+def test_markup(snap_compare):
+ """Check markup rendering, text in test should match the markup."""
+ assert snap_compare(SNAPSHOT_APPS_DIR / "markup.py")
+
+
+def test_no_wrap(snap_compare):
+ """Test no wrap. You should see exactly two lines. The first is cropped, the second is
+ cropped with an ellipsis symbol."""
+
+ TEXT = """I must not fear. Fear is the mind-killer. Fear is the little-death that brings total obliteration. I will face my fear."""
+
+ class NoWrapApp(App):
+ CSS = """
+ Label {
+ max-width: 100vw;
+ text-wrap: nowrap;
+ }
+ #label2 {
+ text-overflow: ellipsis;
+ }
+ """
+
+ def compose(self) -> ComposeResult:
+ yield Label(TEXT, id="label1")
+ yield Label(TEXT, id="label2")
+
+ assert snap_compare(NoWrapApp())
+
+
+def test_overflow(snap_compare):
+ """Test overflow. You should see three labels across 4 lines. The first with overflow clip,
+ the second with overflow ellipsis, and the last with overflow fold."""
+
+ TEXT = "FOO " + "FOOBARBAZ" * 100
+
+ class OverflowApp(App):
+ CSS = """
+ Label {
+ max-width: 100vw;
+ }
+ #label1 {
+ # Overflow will be cropped
+ text-overflow: clip;
+ background: blue 20%;
+ }
+ #label2 {
+ # Like clip, but last character will be an ellipsis
+ text-overflow: ellipsis;
+ background: green 20%;
+ }
+ #label3 {
+ # Overflow will fold on to subsequence lines
+ text-overflow: fold;
+ background: red 20%;
+ }
+ """
+
+ def compose(self) -> ComposeResult:
+ yield Label(TEXT, id="label1")
+ yield Label(TEXT, id="label2")
+ yield Label(TEXT, id="label3")
+
+ assert snap_compare(OverflowApp())
diff --git a/tests/test_border.py b/tests/test_border.py
index c7a7d3354e..2a21b72514 100644
--- a/tests/test_border.py
+++ b/tests/test_border.py
@@ -1,19 +1,16 @@
import pytest
-from rich.console import Console
from rich.segment import Segment
-from rich.style import Style
+from rich.style import Style as RichStyle
from rich.text import Text
from textual._border import render_border_label, render_row
+from textual.content import Content
+from textual.style import Style
from textual.widget import Widget
-_EMPTY_STYLE = Style()
-_BLANK_SEGMENT = Segment(" ", _EMPTY_STYLE)
-_WIDE_CONSOLE = Console(width=9999)
-
def test_border_render_row():
- style = Style.parse("red")
+ style = RichStyle.parse("red")
row = (Segment("┏", style), Segment("━", style), Segment("┓", style))
assert list(render_row(row, 5, False, False, ())) == [
@@ -105,14 +102,13 @@ def test_render_border_label_empty_label_skipped(
assert [] == list(
render_border_label(
- (Text(""), Style()),
+ (Content(""), Style()),
True,
"round",
width,
- _EMPTY_STYLE,
- _EMPTY_STYLE,
- _EMPTY_STYLE,
- _WIDE_CONSOLE,
+ Style(),
+ Style(),
+ Style(),
has_left_corner,
has_right_corner,
)
@@ -142,14 +138,13 @@ def test_render_border_label_skipped_if_narrow(
assert [] == list(
render_border_label(
- (Text.from_markup(label), Style()),
+ (Content.from_markup(label), Style()),
True,
"round",
width,
- _EMPTY_STYLE,
- _EMPTY_STYLE,
- _EMPTY_STYLE,
- _WIDE_CONSOLE,
+ Style(),
+ Style(),
+ Style(),
has_left_corner,
has_right_corner,
)
@@ -173,14 +168,13 @@ def test_render_border_label_wide_plain(label: str):
True,
"round",
BIG_NUM,
- _EMPTY_STYLE,
- _EMPTY_STYLE,
- _EMPTY_STYLE,
- _WIDE_CONSOLE,
+ Style(),
+ Style(),
+ Style(),
True,
True,
)
- segments = render_border_label((Text.from_markup(label), Style()), *args)
+ segments = render_border_label((Content.from_markup(label), Style()), *args)
(segment,) = segments
assert segment == Segment(f" {label} ", None)
@@ -199,14 +193,13 @@ def test_render_border_empty_text_with_markup(label: str):
"""Test label rendering if there is no text but some markup."""
assert [] == list(
render_border_label(
- (Text.from_markup(label), Style()),
+ (Content.from_markup(label), Style()),
True,
"round",
999,
- _EMPTY_STYLE,
- _EMPTY_STYLE,
- _EMPTY_STYLE,
- _WIDE_CONSOLE,
+ Style(),
+ Style(),
+ Style(),
True,
True,
)
@@ -220,44 +213,53 @@ def test_render_border_label():
border_style = Style.parse("green on blue")
# Implicit test on the number of segments returned:
- blank1, what, is_up, with_you, blank2 = render_border_label(
- (Text.from_markup(label), Style()),
- True,
- "round",
- 9999,
- _EMPTY_STYLE,
- _EMPTY_STYLE,
- border_style,
- _WIDE_CONSOLE,
- True,
- True,
+ segments = list(
+ render_border_label(
+ (Content.from_markup(label), Style.null()),
+ True,
+ "round",
+ 9999,
+ Style(),
+ Style(),
+ border_style,
+ True,
+ True,
+ )
)
- expected_blank = Segment(" ", border_style)
+ for segment in segments:
+ print("!!", segment)
+
+ blank1, what, is_up, with_you, blank2 = segments
+
+ expected_blank = Segment(" ", border_style.rich_style)
assert blank1 == expected_blank
assert blank2 == expected_blank
what_style = Style.parse("b on red")
- expected_what = Segment("What ", border_style + what_style)
+ expected_what = Segment("What ", (border_style + what_style).rich_style)
+ print(what)
+ print(expected_what)
assert what == expected_what
is_up_style = Style.parse("b on red i")
- expected_is_up = Segment("is up", border_style + is_up_style)
+ expected_is_up = Segment("is up", (border_style + is_up_style).rich_style)
assert is_up == expected_is_up
with_you_style = Style.parse("b i")
- expected_with_you = Segment(" with you?", border_style + with_you_style)
+ expected_with_you = Segment(
+ " with you?", (border_style + with_you_style).rich_style
+ )
assert with_you == expected_with_you
blank1, what, blank2 = render_border_label(
- (Text.from_markup(label), Style()),
+ (Content.from_markup(label), Style()),
True,
"round",
5 + 4, # 5 where "What…" fits + 2 for the blank spaces + 2 for the corners.
- _EMPTY_STYLE,
- _EMPTY_STYLE,
+ Style(),
+ Style(),
border_style,
- _WIDE_CONSOLE,
True, # This corner costs 2 cells.
True, # This corner costs 2 cells.
)
@@ -265,5 +267,5 @@ def test_render_border_label():
assert blank1 == expected_blank
assert blank2 == expected_blank
- expected_what = Segment("What…", border_style + what_style)
+ expected_what = Segment("What…", (border_style + what_style).rich_style)
assert what == expected_what
diff --git a/tests/test_content.py b/tests/test_content.py
index bc2b96775b..d8a72515fb 100644
--- a/tests/test_content.py
+++ b/tests/test_content.py
@@ -1,8 +1,34 @@
+from __future__ import annotations
+
from rich.text import Text
from textual.content import Content, Span
+def test_blank():
+ """Check blank content."""
+ blank = Content("")
+ assert isinstance(blank, Content)
+ assert str(blank) == ""
+ assert blank.plain == ""
+ assert not blank
+ assert blank.markup == ""
+ assert len(blank) == 0
+ assert blank.spans == []
+
+
+def test_simple():
+ """Check content with simple unstyled text."""
+ simple = Content("foo")
+ assert isinstance(simple, Content)
+ assert str(simple) == "foo"
+ assert simple.plain == "foo"
+ assert simple # Not empty is truthy
+ assert simple.markup == "foo"
+ assert len(simple) == 3
+ assert simple.spans == []
+
+
def test_constructor():
content = Content("Hello, World")
assert content
@@ -10,9 +36,6 @@ def test_constructor():
assert content.cell_length == 12
assert content.plain == "Hello, World"
repr(content)
- assert content.align == "left"
- assert content.no_wrap is False
- assert content.ellipsis is False
def test_bool():
@@ -50,3 +73,125 @@ def test_cell_length():
assert Content("").cell_length == 0
assert Content("foo").cell_length == 3
assert Content("💩").cell_length == 2
+
+
+def test_stylize() -> None:
+ """Test the stylize method."""
+ foo = Content("foo bar")
+ assert foo.spans == []
+ red_foo = foo.stylize("red")
+ # stylize create a new object
+ assert foo.spans == []
+ # With no parameters, full string is stylized
+ assert red_foo.spans == [Span(0, 7, "red")]
+ red_foo = red_foo.stylize("blue", 4, 7)
+ # Another span is appended
+ assert red_foo.spans == [
+ Span(0, 7, "red"),
+ Span(4, 7, "blue"),
+ ]
+
+
+def test_stylize_before() -> None:
+ """Test the stylize_before method."""
+ foo = Content("foo bar")
+ assert foo.spans == []
+ red_foo = foo.stylize("red")
+ # stylize create a new object
+ assert foo.spans == []
+ # With no parameters, full string is stylized
+ assert red_foo.spans == [Span(0, 7, "red")]
+ red_foo = red_foo.stylize_before("blue", 4, 7)
+ # Another span is appended
+ assert red_foo.spans == [
+ Span(4, 7, "blue"),
+ Span(0, 7, "red"),
+ ]
+
+
+def test_eq() -> None:
+ """Test equality."""
+ assert Content("foo") == Content("foo")
+ assert Content("foo") == "foo"
+ assert Content("foo") != Content("bar")
+ assert Content("foo") != "bar"
+
+
+def test_add() -> None:
+ """Test addition."""
+ # Simple cases
+ assert Content("") + Content("") == Content("")
+ assert Content("foo") + Content("") == Content("foo")
+ # Works with simple strings
+ assert Content("foo") + "" == Content("foo")
+ assert "" + Content("foo") == Content("foo")
+
+ # Test spans after addition
+ content = Content.styled("foo", "red") + " " + Content.styled("bar", "blue")
+ assert str(content) == "foo bar"
+ assert content.spans == [Span(0, 3, "red"), Span(4, 7, "blue")]
+ assert content.cell_length == 7
+
+
+def test_from_markup():
+ """Test simple parsing of Textual markup."""
+ content = Content.from_markup("[red]Hello[/red] [blue]World[/blue]")
+ assert len(content) == 11
+ assert content.plain == "Hello World"
+ assert content.spans == [
+ Span(start=0, end=5, style="red"),
+ Span(start=6, end=11, style="blue"),
+ ]
+
+
+def test_markup():
+ """Test markup round trip"""
+ content = Content.from_markup("[red]Hello[/red] [blue]World[/blue]")
+ assert content.plain == "Hello World"
+ assert content.markup == "[red]Hello[/red] [blue]World[/blue]"
+
+
+def test_join():
+ """Test the join method."""
+
+ # Edge cases
+ assert Content("").join([]) == ""
+ assert Content(".").join([]) == ""
+ assert Content("").join(["foo"]) == "foo"
+ assert Content(".").join(["foo"]) == "foo"
+
+ # Join strings and Content
+ pieces = [Content.styled("foo", "red"), "bar", Content.styled("baz", "blue")]
+ content = Content(".").join(pieces)
+ assert content.plain == "foo.bar.baz"
+ assert content.spans == [Span(0, 3, "red"), Span(8, 11, "blue")]
+
+
+def test_sort():
+ """Test content may be sorted."""
+ # functools.total_ordering doing most of the heavy lifting here.
+ contents = sorted([Content("foo"), Content("bar"), Content("baz")])
+ assert contents[0].plain == "bar"
+ assert contents[1].plain == "baz"
+ assert contents[2].plain == "foo"
+
+
+def test_truncate():
+ """Test truncated method."""
+ content = Content.from_markup("[red]Hello World[/red]")
+ # Edge case of 0
+ assert content.truncate(0).markup == ""
+ # Edge case of 0 wil ellipsis
+ assert content.truncate(0, ellipsis=True).markup == ""
+ # Edge case of 1
+ assert content.truncate(1, ellipsis=True).markup == "[red]…[/red]"
+ # Truncate smaller
+ assert content.truncate(3).markup == "[red]Hel[/red]"
+ # Truncate to same size
+ assert content.truncate(11).markup == "[red]Hello World[/red]"
+ # Truncate smaller will ellipsis
+ assert content.truncate(5, ellipsis=True).markup == "[red]Hell…[/red]"
+ # Truncate larger results unchanged
+ assert content.truncate(15).markup == "[red]Hello World[/red]"
+ # Truncate larger with padding increases size
+ assert content.truncate(15, pad=True).markup == "[red]Hello World[/red] "
diff --git a/tests/test_issue_4248.py b/tests/test_issue_4248.py
index 012b11edae..beada864f9 100644
--- a/tests/test_issue_4248.py
+++ b/tests/test_issue_4248.py
@@ -11,7 +11,6 @@ async def test_issue_4248() -> None:
class ActionApp(App[None]):
def compose(self) -> ComposeResult:
- yield Label("[@click]click me and crash[/]", id="nothing")
yield Label("[@click=]click me and crash[/]", id="no-params")
yield Label("[@click=()]click me and crash[/]", id="empty-params")
yield Label("[@click=foobar]click me[/]", id="unknown-sans-parens")
@@ -26,8 +25,6 @@ def action_bump(self, by_value: int = 1) -> None:
app = ActionApp()
async with app.run_test() as pilot:
- assert bumps == 0
- await pilot.click("#nothing")
assert bumps == 0
await pilot.click("#no-params")
assert bumps == 0
diff --git a/tests/test_markup.py b/tests/test_markup.py
new file mode 100644
index 0000000000..32032bf5bd
--- /dev/null
+++ b/tests/test_markup.py
@@ -0,0 +1,127 @@
+from __future__ import annotations
+
+import pytest
+
+from textual.content import Content, Span
+from textual.markup import MarkupError, to_content
+
+
+@pytest.mark.parametrize(
+ ["markup", "content"],
+ [
+ ("", Content("")),
+ ("foo", Content("foo")),
+ ("foo\n", Content("foo\n")),
+ ("foo\nbar", Content("foo\nbar")),
+ ("[bold]Hello", Content("Hello", [Span(0, 5, "bold")])),
+ (
+ "[bold rgb(10, 20, 30)]Hello",
+ Content("Hello", [Span(0, 5, "bold rgb(10, 20, 30)")]),
+ ),
+ (
+ "[bold red]Hello[/] World",
+ Content("Hello World", [Span(0, 5, "bold red")]),
+ ),
+ (
+ "[bold red]Hello",
+ Content("Hello", [Span(0, 5, "bold red")]),
+ ),
+ (
+ "[bold red]Hello[/]\nWorld",
+ Content("Hello\nWorld", [Span(0, 5, "bold red")]),
+ ),
+ (
+ "[b][on red]What [i]is up[/on red] with you?[/]",
+ Content(
+ "What is up with you?",
+ spans=[
+ Span(0, 10, style="on red"),
+ Span(5, 20, style="i"),
+ Span(0, 20, style="b"),
+ ],
+ ),
+ ),
+ (
+ "[b]Welcome to Textual[/b]\n\nI must not fear",
+ Content(
+ "Welcome to Textual\n\nI must not fear",
+ spans=[
+ Span(0, 18, style="b"),
+ ],
+ ),
+ ),
+ (
+ "[$accent]Hello",
+ Content(
+ "Hello",
+ spans=[
+ Span(0, 5, "$accent"),
+ ],
+ ),
+ ),
+ (
+ "[on red @click=app.bell]Click me",
+ Content(
+ "Click me",
+ spans=[
+ Span(0, 8, "on red @click=app.bell"),
+ ],
+ ),
+ ),
+ (
+ "[on red @click=app.bell]Click me[/on red @click=]",
+ Content(
+ "Click me",
+ spans=[
+ Span(0, 8, "on red @click=app.bell"),
+ ],
+ ),
+ ),
+ ],
+)
+def test_to_content(markup: str, content: Content):
+ markup_content = to_content(markup)
+ print(repr(markup_content))
+ print(repr(content))
+ assert markup_content.is_same(content)
+
+
+def test_content_parse_fail() -> None:
+ with pytest.raises(MarkupError):
+ to_content("[rgb(1,2,3,4)]foo")
+ with pytest.raises(MarkupError):
+ to_content("[foo]foo[/bar]")
+ with pytest.raises(MarkupError):
+ to_content("foo[/]")
+
+
+@pytest.mark.parametrize(
+ ["markup", "variables", "content"],
+ [
+ # Simple substitution
+ (
+ "Hello $name",
+ {"name": "Will"},
+ Content("Hello Will"),
+ ),
+ # Wrapped in tags
+ (
+ "Hello [bold]$name[/bold]",
+ {"name": "Will"},
+ Content("Hello Will", spans=[Span(6, 10, style="bold")]),
+ ),
+ # dollar in tags should not trigger substitution.
+ (
+ "Hello [link='$name']$name[/link]",
+ {"name": "Will"},
+ Content("Hello Will", spans=[Span(6, 10, style="link='$name'")]),
+ ),
+ ],
+)
+def test_template_variables(
+ markup: str, variables: dict[str, object], content: Content
+) -> None:
+ markup_content = Content.from_markup(markup, **variables)
+ print(repr(markup_content))
+ print(repr(content))
+ assert markup_content.is_same(content)
diff --git a/tests/test_style_parse.py b/tests/test_style_parse.py
new file mode 100644
index 0000000000..987d4852c0
--- /dev/null
+++ b/tests/test_style_parse.py
@@ -0,0 +1,56 @@
+import pytest
+
+from textual.color import Color
+from textual.style import Style
+
+
+@pytest.mark.parametrize(
+ ["markup", "style"],
+ [
+ ("", Style()),
+ (
+ "b",
+ Style(bold=True),
+ ),
+ ("i", Style(italic=True)),
+ ("u", Style(underline=True)),
+ ("r", Style(reverse=True)),
+ ("bold", Style(bold=True)),
+ ("italic", Style(italic=True)),
+ ("underline", Style(underline=True)),
+ ("reverse", Style(reverse=True)),
+ ("bold italic", Style(bold=True, italic=True)),
+ ("not bold italic", Style(bold=False, italic=True)),
+ ("bold not italic", Style(bold=True, italic=False)),
+ ("rgb(10, 20, 30)", Style(foreground=Color(10, 20, 30))),
+ ("rgba(10, 20, 30, 0.5)", Style(foreground=Color(10, 20, 30, 0.5))),
+ ("rgb(10, 20, 30) 50%", Style(foreground=Color(10, 20, 30, 0.5))),
+ ("on rgb(10, 20, 30)", Style(background=Color(10, 20, 30))),
+ ("on rgb(10, 20, 30) 50%", Style(background=Color(10, 20, 30, 0.5))),
+ ("@click=app.bell", Style.from_meta({"@click": "app.bell"})),
+ ("@click='app.bell'", Style.from_meta({"@click": "app.bell"})),
+ ('''@click="app.bell"''', Style.from_meta({"@click": "app.bell"})),
+ ("@click=app.bell()", Style.from_meta({"@click": "app.bell()"})),
+ (
+ "@click=app.notify('hello')",
+ Style.from_meta({"@click": "app.notify('hello')"}),
+ ),
+ (
+ "@click=app.notify('hello [World]!')",
+ Style.from_meta({"@click": "app.notify('hello [World]!')"}),
+ ),
+ (
+ "@click=app.notify('hello') bold",
+ Style(bold=True) + Style.from_meta({"@click": "app.notify('hello')"}),
+ ),
+ ],
+)
+def test_parse_style(markup: str, style: Style) -> None:
+ """Check parsing of valid styles."""
+ parsed_style = Style.parse(markup)
+ print("parsed\t\t", repr(parsed_style))
+ print("expected\t", repr(style))
+ print(parsed_style.meta, style.meta)
+ print(parsed_style._meta)
+ print(style._meta)
+ assert parsed_style == style
diff --git a/tests/test_styles_cache.py b/tests/test_styles_cache.py
index 6567de4eae..615ed70aa6 100644
--- a/tests/test_styles_cache.py
+++ b/tests/test_styles_cache.py
@@ -1,6 +1,5 @@
from __future__ import annotations
-from rich.console import Console
from rich.segment import Segment
from rich.style import Style
@@ -41,18 +40,21 @@ def test_no_styles():
Color.parse("blue"),
Color.parse("green"),
content.__getitem__,
- Console(),
- "",
- "",
+ None,
+ None,
content_size=Size(3, 3),
)
style = Style.from_color(bgcolor=Color.parse("green").rich_color)
+
expected = [
Strip([Segment("foo", style)], 3),
Strip([Segment("bar", style)], 3),
Strip([Segment("baz", style)], 3),
]
+ print(lines[0])
+ print(expected[0])
+
assert lines == expected
@@ -71,7 +73,6 @@ def test_border():
Color.parse("blue"),
Color.parse("green"),
content.__getitem__,
- Console(),
None,
None,
content_size=Size(3, 3),
@@ -105,7 +106,6 @@ def test_padding():
Color.parse("blue"),
Color.parse("green"),
content.__getitem__,
- Console(),
None,
None,
content_size=Size(3, 3),
@@ -140,7 +140,6 @@ def test_padding_border():
Color.parse("blue"),
Color.parse("green"),
content.__getitem__,
- Console(),
None,
None,
content_size=Size(3, 3),
@@ -176,7 +175,6 @@ def test_outline():
Color.parse("blue"),
Color.parse("green"),
content.__getitem__,
- Console(),
None,
None,
content_size=Size(3, 3),
@@ -207,7 +205,6 @@ def test_crop():
Color.parse("blue"),
Color.parse("green"),
content.__getitem__,
- Console(),
None,
None,
content_size=Size(3, 3),
@@ -246,7 +243,6 @@ def get_content_line(y: int) -> Strip:
Color.parse("blue"),
Color.parse("green"),
get_content_line,
- Console(),
None,
None,
content_size=Size(3, 3),
@@ -274,7 +270,6 @@ def get_content_line(y: int) -> Strip:
Color.parse("blue"),
Color.parse("green"),
get_content_line,
- Console(),
None,
None,
content_size=Size(3, 3),
@@ -293,7 +288,6 @@ def get_content_line(y: int) -> Strip:
Color.parse("blue"),
Color.parse("green"),
get_content_line,
- Console(),
None,
None,
content_size=Size(3, 3),
diff --git a/tests/test_widget.py b/tests/test_widget.py
index 18d46a366d..1297aa18d1 100644
--- a/tests/test_widget.py
+++ b/tests/test_widget.py
@@ -1,7 +1,6 @@
from operator import attrgetter
import pytest
-from rich.text import Text
from textual import events
from textual._node_list import DuplicateIds
@@ -317,11 +316,11 @@ def on_mount(self):
def test_render_str() -> None:
widget = Label()
- assert widget.render_str("foo") == Text("foo")
- assert widget.render_str("[b]foo") == Text.from_markup("[b]foo")
+ assert widget.render_str("foo") == Content("foo")
+ assert widget.render_str("[b]foo") == Content.from_markup("[b]foo")
# Text objects are passed unchanged
- text = Text("bar")
- assert widget.render_str(text) is text
+ content = Content("bar")
+ assert widget.render_str(content) is content
async def test_compose_order() -> None: