Skip to content

Commit

Permalink
Merge pull request #5485 from Textualize/textual-markup
Browse files Browse the repository at this point in the history
Textual markup
  • Loading branch information
willmcgugan authored Feb 7, 2025
2 parents a05cffa + a91d8cc commit 1c23d6c
Show file tree
Hide file tree
Showing 94 changed files with 4,918 additions and 1,771 deletions.
5 changes: 3 additions & 2 deletions .github/workflows/pythonpackage.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
5 changes: 5 additions & 0 deletions docs/api/content.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
title: "textual.content"
---

::: textual.content
5 changes: 5 additions & 0 deletions docs/api/style.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
title: "textual.style"
---

::: textual.style
36 changes: 36 additions & 0 deletions docs/examples/guide/content/content01.py
Original file line number Diff line number Diff line change
@@ -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()
5 changes: 5 additions & 0 deletions docs/examples/guide/content/playground.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
from textual._markup_playground import MarkupPlayground

if __name__ == "__main__":
app = MarkupPlayground()
app.run()
36 changes: 36 additions & 0 deletions docs/examples/guide/content/renderables.py
Original file line number Diff line number Diff line change
@@ -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()
2 changes: 1 addition & 1 deletion docs/examples/styles/border_sub_title_align_all.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
2 changes: 1 addition & 1 deletion docs/examples/styles/link_background.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
2 changes: 1 addition & 1 deletion docs/examples/styles/link_background_hover.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
2 changes: 1 addition & 1 deletion docs/examples/styles/link_color.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
2 changes: 1 addition & 1 deletion docs/examples/styles/link_color_hover.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
2 changes: 1 addition & 1 deletion docs/examples/styles/link_style.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
2 changes: 1 addition & 1 deletion docs/examples/styles/link_style_hover.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
18 changes: 18 additions & 0 deletions docs/examples/styles/text_overflow.py
Original file line number Diff line number Diff line change
@@ -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()
17 changes: 17 additions & 0 deletions docs/examples/styles/text_overflow.tcss
Original file line number Diff line number Diff line change
@@ -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%;
}
17 changes: 17 additions & 0 deletions docs/examples/styles/text_wrap.py
Original file line number Diff line number Diff line change
@@ -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()
12 changes: 12 additions & 0 deletions docs/examples/styles/text_wrap.tcss
Original file line number Diff line number Diff line change
@@ -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%;
}
35 changes: 25 additions & 10 deletions docs/guide/CSS.md
Original file line number Diff line number Diff line change
Expand Up @@ -100,25 +100,25 @@ With a header and a footer widget the DOM looks like this:
--8<-- "docs/images/dom2.excalidraw.svg"
</div>

!!! 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.

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:

Expand All @@ -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`).

Expand Down Expand Up @@ -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.

Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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.

Expand Down Expand Up @@ -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.

Expand Down Expand Up @@ -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.
4 changes: 2 additions & 2 deletions docs/guide/actions.md
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down
Loading

0 comments on commit 1c23d6c

Please sign in to comment.