content docs

This commit is contained in:
Will McGugan
2025-02-02 19:37:36 +00:00
parent 27954777a2
commit 7d228fd228
10 changed files with 455 additions and 66 deletions

View File

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

View File

@@ -0,0 +1,5 @@
from textual._markup_playground import MarkupPlayground
if __name__ == "__main__":
app = MarkupPlayground()
app.run()

View File

@@ -1 +1,322 @@
# Content
Content may be returned from
## 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.
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, and the bottom 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
```
The interface looks something like this:
```{.textual path="docs/examples/guide/content/playground.py", type="[i]Hello!"] lines=15}
```
If you are trying Textual Markup for the first time, it is worthwhile to test what you read with he Playground app!
### 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 something like the following:
```
[bold]
```
This will make following text bold. For instance, the following would result in the text "Hello, World!" in bold:
```
[bold]Hello, World!
```
The second type of tag, a *closing* tag, is almost identical, but starts with a forward slash inside the first square bracket.
A closing tag ends a style, started by a previous opening tag.
!!! note
Without a closing tag, the style will persist to the end of the string. This can be a convenient shortcut if you want to style the entire string.
For instance, the following is a closing tag to end the bold style:
```
[/bold]
```
A closing tag must match a previous opening tag, so that Textual knows which style is being closed.
Let's use this to embolden just the first word in our example:
```
[bold]Hello[/bold], World!
```
This will make "Hello" bold, but the rest of the text will be non-bold.
You can use any number of tags, and they may overlap which combines their styles.
For instance, the following combines the bold and italic styles:
```
[bold]Bold [italic]Bold and italic[/italic][/bold]
```
#### Auto-closing tags
A closing tag without any style information (i.e. `[/]`) is an *auto-closing* tag.
Auto-closing tags will close the last tag, regardless of it's style.
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 tags:
| Style | Abbreviation | Description |
| ----------- | ------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `bold` | `b` | **Bold text** |
| `dim` | `d` | <span style="opacity: 0.6;">Dim text </span> (slightly transparent) |
| `italic` | `i` | *Italic text* |
| `underline` | `u` | <u>Underlined text</u> |
| `strike` | `s` | <strike>Strikethrough text<strile> |
| `reverse` | `r` | <span style="background: var(--md-primary-bg-color); color: var(--md-primary-fg-color);">Reversed colors text</span> (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 markup starts with `[bold]` which makes the text bold until it reaches `[not bold]` which disables the bold style, until the corresponding `[/not bold]`.
```
[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=15 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 [&lt;color&gt;](/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 zero 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)[/]
```
!!! tip
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 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, you can also add a percentage which will modify the alpha component of the previous color.
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.[/]
```
```{.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![/]
```
```{.textual path="docs/examples/guide/content/playground.py" lines=15 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.
For instance, the following create a clickable link:
```
[link="https://www.willmcgugan.com"]Visit my blog![/link]
```
This will produce the following output:
<code><pre><a href="https://www.willmcgugan.com">Visit my blog!</a></pre></code>
## Content class
Under the hood, Textual will convert markup into a [Content][textual.content.Content] instance.
You can also return Content directly from `render()`, which you may want to do if you require more advanced formatting beyond simple markup.
To clarify, here's a render method that returns a string with markup:
```python
def render(self) -> RenderResult:
return "[b]Hello, World![/b]"
```
This is roughly the equivalent to the following code:
```python
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.
## Rich renderables
Textual supports Rich renderables, which means you can return any object that works with Rich, such as Rich's [Text](https://rich.readthedocs.io/en/latest/text.html) object.
The Content class is generally preferred, as it supports more of Textual's features.
If you already have a Text object and your code is working, there is no need to change it -- Textual won't be dropping Rich support.
But we recommend the [Content class](#content-class) for newer code.

View File

@@ -11,6 +11,7 @@ nav:
- "guide/styles.md"
- "guide/CSS.md"
- "guide/design.md"
- "guide/content.md"
- "guide/queries.md"
- "guide/layout.md"
- "guide/events.md"

View File

@@ -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)
title = attrs.get("title")
print(f"screenshotting {path!r}")

View File

@@ -0,0 +1,55 @@
from rich.highlighter import ReprHighlighter
from textual import containers, on
from textual.app import App, ComposeResult
from textual.widgets import Static, TextArea
class MarkupPlayground(App):
TITLE = "Markup Playground"
CSS = """
Screen {
layout: vertical;
#editor {
height: 1fr;
border: tab $primary;
padding: 1;
margin: 1 1 0 1;
}
#results-container {
margin: 0 1;
border: tab $success;
&.-error {
border: tab $error;
}
}
#results {
height: 1fr;
padding: 1 1;
}
}
"""
def compose(self) -> ComposeResult:
yield (text_area := TextArea(id="editor"))
text_area.border_title = "Markup"
with containers.VerticalScroll(id="results-container") as container:
yield Static(id="results")
container.border_title = "Output"
@on(TextArea.Changed)
def on_markup_changed(self, event: TextArea.Changed) -> None:
results = self.query_one("#results", Static)
try:
results.update(event.text_area.text)
except Exception as error:
highlight = ReprHighlighter()
# results.update(highlight(str(error)))
from rich.traceback import Traceback
results.update(Traceback())
self.query_one("#results-container").add_class("-error")
else:
self.query_one("#results-container").remove_class("-error")

View File

@@ -845,7 +845,9 @@ class Content(Visual):
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.
@@ -875,6 +877,20 @@ class Content(Visual):
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
pricessing of the output.
Args:
base_style (_type_, optional): The style used as a base. This will typically be the style of the widget underneath the content.
end (_type_, optional): 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)
@@ -886,6 +902,7 @@ class Content(Visual):
@lru_cache(maxsize=1024)
def get_style(style: str, /) -> Style:
"""The default get_style method."""
try:
visual_style = Style.parse(style)
except Exception:

View File

@@ -338,60 +338,7 @@ def _to_content(markup: str, style: str | Style = "") -> Content:
if __name__ == "__main__": # pragma: no cover
from rich.highlighter import ReprHighlighter
from textual._markup_playground import MarkupPlayground
from textual import containers, on
from textual.app import App, ComposeResult
from textual.widgets import Static, TextArea
class MarkupApp(App):
CSS = """
Screen {
layout: horizontal;
#editor {
width: 1fr;
border: tab $primary;
padding: 1;
margin: 1 1 0 1;
}
#results-container {
margin: 1 1 0 1;
border: tab $success;
&.-error {
border: tab $error;
}
}
#results {
width: 1fr;
padding: 1 1;
}
}
"""
def compose(self) -> ComposeResult:
yield (text_area := TextArea(id="editor"))
text_area.border_title = "Markup"
with containers.VerticalScroll(id="results-container") as container:
yield Static(id="results")
container.border_title = "Output"
@on(TextArea.Changed)
def on_markup_changed(self, event: TextArea.Changed) -> None:
results = self.query_one("#results", Static)
try:
results.update(event.text_area.text)
except Exception as error:
highlight = ReprHighlighter()
# results.update(highlight(str(error)))
from rich.traceback import Traceback
results.update(Traceback())
self.query_one("#results-container").add_class("-error")
else:
self.query_one("#results-container").remove_class("-error")
app = MarkupApp()
app = MarkupPlayground()
app.run()

View File

@@ -399,6 +399,7 @@ class Widget(DOMNode):
id: str | None = None,
classes: str | None = None,
disabled: bool = False,
markup: bool = True,
) -> None:
"""Initialize a Widget.
@@ -409,6 +410,7 @@ class Widget(DOMNode):
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
@@ -4034,22 +4036,23 @@ class Widget(DOMNode):
yield
def render(self) -> RenderResult:
"""Get text or Rich renderable for this widget.
"""Get [content](./guide/content) for the widget.
Implement this for custom widgets.
This method should return a string, 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:
@@ -4070,7 +4073,7 @@ class Widget(DOMNode):
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

View File

@@ -64,17 +64,18 @@ class Static(Widget, inherit_bindings=False):
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
@@ -84,7 +85,7 @@ class Static(Widget, inherit_bindings=False):
@renderable.setter
def renderable(self, renderable: RenderableType | SupportsVisual) -> None:
if isinstance(renderable, str):
if self.markup:
if self._render_markup:
self._renderable = Text.from_markup(renderable)
else:
self._renderable = Text(renderable)
@@ -109,5 +110,5 @@ class Static(Widget, inherit_bindings=False):
"""
self._content = content
self._visual = visualize(self, content, markup=self.markup)
self._visual = visualize(self, content, markup=self._render_markup)
self.refresh(layout=True)