Skip to content

Commit 3ec59ee

Browse files
committed
added substution
1 parent 97feb9c commit 3ec59ee

File tree

6 files changed

+116
-26
lines changed

6 files changed

+116
-26
lines changed

docs/guide/content.md

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -112,7 +112,7 @@ Auto-closing tags recommended when it is clear which tag they are intended to cl
112112

113113
### Styles
114114

115-
Tags may contain any number of the following tags:
115+
Tags may contain any number of the following values:
116116

117117
| Style | Abbreviation | Description |
118118
| ----------- | ------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------- |
@@ -267,9 +267,6 @@ For instance, the following create a clickable link:
267267
This will produce the following output:
268268
<code><pre><a href="https://www.willmcgugan.com">Visit my blog!</a></pre></code>
269269

270-
271-
272-
273270
## Content class
274271

275272
Under the hood, Textual will convert markup into a [Content][textual.content.Content] instance.

src/textual/_markup_playground.py

Lines changed: 49 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
1-
from rich.highlighter import ReprHighlighter
1+
import json
22

33
from textual import containers, on
44
from textual.app import App, ComposeResult
5+
from textual.content import Content
6+
from textual.reactive import reactive
57
from textual.widgets import Static, TextArea
68

79

@@ -10,15 +12,26 @@ class MarkupPlayground(App):
1012
TITLE = "Markup Playground"
1113
CSS = """
1214
Screen {
15+
& > * {
16+
margin: 0 1;
17+
}
1318
layout: vertical;
1419
#editor {
1520
height: 1fr;
1621
border: tab $primary;
1722
padding: 1;
18-
margin: 1 1 0 1;
23+
margin: 1 1 0 0;
1924
}
20-
#results-container {
21-
margin: 0 1;
25+
#variables {
26+
height: 1fr;
27+
border: tab $primary;
28+
padding: 1;
29+
margin: 1 0 0 1;
30+
}
31+
#variables.-bad-json {
32+
border: tab $error;
33+
}
34+
#results-container {
2235
border: tab $success;
2336
&.-error {
2437
border: tab $error;
@@ -32,22 +45,30 @@ class MarkupPlayground(App):
3245
}
3346
"""
3447

48+
variables: reactive[dict[str, object]] = reactive({})
49+
3550
def compose(self) -> ComposeResult:
36-
yield (text_area := TextArea(id="editor"))
37-
text_area.border_title = "Markup"
51+
with containers.HorizontalScroll():
52+
yield (editor := TextArea(id="editor"))
53+
yield (variables := TextArea(id="variables", language="json"))
54+
editor.border_title = "Markup"
55+
variables.border_title = "Variables (JSON)"
3856

3957
with containers.VerticalScroll(id="results-container") as container:
4058
yield Static(id="results")
4159
container.border_title = "Output"
4260

43-
@on(TextArea.Changed)
61+
@on(TextArea.Changed, "#editor")
4462
def on_markup_changed(self, event: TextArea.Changed) -> None:
63+
self.update_markup()
64+
65+
def update_markup(self) -> None:
4566
results = self.query_one("#results", Static)
67+
editor = self.query_one("#editor", TextArea)
4668
try:
47-
results.update(event.text_area.text)
69+
content = Content.from_markup(editor.text, **self.variables)
70+
results.update(content)
4871
except Exception as error:
49-
highlight = ReprHighlighter()
50-
# results.update(highlight(str(error)))
5172
from rich.traceback import Traceback
5273

5374
results.update(Traceback())
@@ -57,3 +78,21 @@ def on_markup_changed(self, event: TextArea.Changed) -> None:
5778
)
5879
else:
5980
self.query_one("#results-container").remove_class("-error")
81+
82+
def watch_variables(self, variables: dict[str, object]) -> None:
83+
self.update_markup()
84+
85+
@on(TextArea.Changed, "#variables")
86+
def on_variables_change(self, event: TextArea.Changed) -> None:
87+
variables_text_area = self.query_one("#variables", TextArea)
88+
try:
89+
variables = json.loads(variables_text_area.text)
90+
except Exception as error:
91+
if not variables_text_area.has_class("-bad-json"):
92+
self.notify(f"Bad JSON: ${error}", title="Variables", severity="error")
93+
variables_text_area.add_class("-bad-json")
94+
else:
95+
if variables_text_area.has_class("-bad-json"):
96+
variables_text_area.remove_class("-bad-json")
97+
self.notify("JSON parsed correctly", title="Variables")
98+
self.variables = variables

src/textual/content.py

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -168,17 +168,18 @@ def markup(self) -> str:
168168
return markup
169169

170170
@classmethod
171-
def from_markup(cls, markup: str | Content) -> Content:
171+
def from_markup(cls, markup: str | Content, **variables: object) -> Content:
172172
"""Create content from Textual markup.
173173
174-
If `markup` is already Content, return it unmodified.
174+
If `markup` is already a Content instance, return it unmodified.
175175
176176
!!! note
177-
Textual markup is not the same as Rich markup. Use [Text.parse] to parse Rich Console markup.
177+
Textual markup is not the same as Rich markup. Use [Text.parse][rich.text.Text.parse] to parse Rich Console markup.
178178
179179
180180
Args:
181181
markup: Textual markup, or Content.
182+
**variables: Optional template variables used
182183
183184
Returns:
184185
New Content instance.
@@ -188,7 +189,7 @@ def from_markup(cls, markup: str | Content) -> Content:
188189
return markup
189190
from textual.markup import to_content
190191

191-
content = to_content(markup)
192+
content = to_content(markup, template_variables=variables or None)
192193
return content
193194

194195
@classmethod

src/textual/css/parse.py

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -405,7 +405,6 @@ def substitute_references(
405405
)
406406
)
407407
else:
408-
1 / 0
409408
_unresolved(ref_name, variables.keys(), token)
410409
else:
411410
variable_tokens.append(token)
@@ -423,7 +422,6 @@ def substitute_references(
423422
ReferencedBy(variable_name, ref_location, ref_length, ref_code)
424423
)
425424
else:
426-
1 / 0
427425
_unresolved(variable_name, variables.keys(), token)
428426
else:
429427
yield token

src/textual/markup.py

Lines changed: 29 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,8 @@
55
__all__ = ["MarkupError", "escape", "to_content"]
66

77
import re
8-
from typing import TYPE_CHECKING, Callable, Match
8+
from string import Template
9+
from typing import TYPE_CHECKING, Callable, Mapping, Match
910

1011
from textual._context import active_app
1112
from textual.color import Color
@@ -271,12 +272,17 @@ def parse_style(style: str, variables: dict[str, str] | None = None) -> Style:
271272
return parsed_style
272273

273274

274-
def to_content(markup: str, style: str | Style = "") -> Content:
275+
def to_content(
276+
markup: str,
277+
style: str | Style = "",
278+
template_variables: Mapping[str, object] | None = None,
279+
) -> Content:
275280
"""Convert markup to Content.
276281
277282
Args:
278283
markup: String containing markup.
279284
style: Optional base style.
285+
template_variables: Mapping of string.Template variables
280286
281287
Raises:
282288
MarkupError: If the markup is invalid.
@@ -286,18 +292,23 @@ def to_content(markup: str, style: str | Style = "") -> Content:
286292
"""
287293
_rich_traceback_omit = True
288294
try:
289-
return _to_content(markup, style)
295+
return _to_content(markup, style, template_variables)
290296
except Exception as error:
291297
# Ensure all errors are wrapped in a MarkupError
292298
raise MarkupError(str(error)) from None
293299

294300

295-
def _to_content(markup: str, style: str | Style = "") -> Content:
301+
def _to_content(
302+
markup: str,
303+
style: str | Style = "",
304+
template_variables: Mapping[str, object] | None = None,
305+
) -> Content:
296306
"""Internal function to convert markup to Content.
297307
298308
Args:
299309
markup: String containing markup.
300310
style: Optional base style.
311+
template_variables: Mapping of string.Template variables
301312
302313
Raises:
303314
MarkupError: If the markup is invalid.
@@ -310,6 +321,7 @@ def _to_content(markup: str, style: str | Style = "") -> Content:
310321

311322
tokenizer = MarkupTokenizer()
312323
text: list[str] = []
324+
text_append = text.append
313325
iter_tokens = iter(tokenizer(markup, ("inline", "")))
314326

315327
style_stack: list[tuple[int, str, str]] = []
@@ -321,12 +333,23 @@ def _to_content(markup: str, style: str | Style = "") -> Content:
321333

322334
normalize_markup_tag = Style._normalize_markup_tag
323335

336+
if template_variables is None:
337+
process_text = lambda text: text
338+
339+
else:
340+
341+
def process_text(template_text: str, /) -> str:
342+
if "$" in template_text:
343+
return Template(template_text).safe_substitute(template_variables)
344+
return template_text
345+
324346
for token in iter_tokens:
325347

326348
token_name = token.name
327349
if token_name == "text":
328-
text.append(token.value)
329-
position += len(token.value)
350+
value = process_text(token.value.replace("\\[", "["))
351+
text_append(value)
352+
position += len(value)
330353

331354
elif token_name == "open_tag":
332355
tag_text = []

tests/test_markup.py

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,3 +73,35 @@ def test_content_parse_fail() -> None:
7373
to_content("[foo]foo[/bar]")
7474
with pytest.raises(MarkupError):
7575
to_content("foo[/]")
76+
77+
78+
@pytest.mark.parametrize(
79+
["markup", "variables", "content"],
80+
[
81+
# Simple substitution
82+
(
83+
"Hello $name",
84+
{"name": "Will"},
85+
Content("Hello Will"),
86+
),
87+
# Wrapped in tags
88+
(
89+
"Hello [bold]$name[/bold]",
90+
{"name": "Will"},
91+
Content("Hello Will", spans=[Span(6, 10, style="bold")]),
92+
),
93+
# dollar in tags should not trigger substitution.
94+
(
95+
"Hello [link='$name']$name[/link]",
96+
{"name": "Will"},
97+
Content("Hello Will", spans=[Span(6, 10, style="link='$name'")]),
98+
),
99+
],
100+
)
101+
def test_template_variables(
102+
markup: str, variables: dict[str, object], content: Content
103+
) -> None:
104+
markup_content = Content.from_markup(markup, **variables)
105+
print(repr(markup_content))
106+
print(repr(content))
107+
assert markup_content.is_same(content)

0 commit comments

Comments
 (0)