mirror of
https://github.com/Textualize/textual.git
synced 2025-10-17 02:38:12 +03:00
Merge pull request #704 from Textualize/text-justify
`text-align` CSS support
This commit is contained in:
24
docs/examples/styles/text_align.css
Normal file
24
docs/examples/styles/text_align.css
Normal file
@@ -0,0 +1,24 @@
|
||||
#one {
|
||||
text-align: left;
|
||||
background: lightblue;
|
||||
|
||||
}
|
||||
|
||||
#two {
|
||||
text-align: center;
|
||||
background: indianred;
|
||||
}
|
||||
|
||||
#three {
|
||||
text-align: right;
|
||||
background: palegreen;
|
||||
}
|
||||
|
||||
#four {
|
||||
text-align: justify;
|
||||
background: palevioletred;
|
||||
}
|
||||
|
||||
Static {
|
||||
padding: 1;
|
||||
}
|
||||
28
docs/examples/styles/text_align.py
Normal file
28
docs/examples/styles/text_align.py
Normal file
@@ -0,0 +1,28 @@
|
||||
from __future__ import annotations
|
||||
|
||||
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. I will permit it to pass over "
|
||||
"me and through me."
|
||||
)
|
||||
|
||||
|
||||
class TextAlign(App):
|
||||
def compose(self) -> ComposeResult:
|
||||
left = Static("[b]Left aligned[/]\n" + TEXT, id="one")
|
||||
yield left
|
||||
|
||||
right = Static("[b]Center aligned[/]\n" + TEXT, id="two")
|
||||
yield right
|
||||
|
||||
center = Static("[b]Right aligned[/]\n" + TEXT, id="three")
|
||||
yield center
|
||||
|
||||
full = Static("[b]Justified[/]\n" + TEXT, id="four")
|
||||
yield full
|
||||
|
||||
|
||||
app = TextAlign(css_path="text_align.css")
|
||||
@@ -12,7 +12,7 @@ There are a number of rules to set the colors used in Textual scrollbars. You wo
|
||||
| `scrollbar-background-active` | Scrollbar background when the thumb is being dragged |
|
||||
| `scrollbar-corner-color` | The gap between the horizontal and vertical scrollbars |
|
||||
|
||||
## Example:
|
||||
## Syntax
|
||||
|
||||
```
|
||||
scrollbar-color: <COLOR>;
|
||||
|
||||
57
docs/styles/text_align.md
Normal file
57
docs/styles/text_align.md
Normal file
@@ -0,0 +1,57 @@
|
||||
# Text-align
|
||||
|
||||
The `text-align` rule aligns text within a widget.
|
||||
|
||||
## Syntax
|
||||
|
||||
```
|
||||
text-align: [left|start|center|right|end|justify];
|
||||
```
|
||||
|
||||
### Values
|
||||
|
||||
| Value | Description |
|
||||
|-----------|----------------------------------|
|
||||
| `left` | Left aligns text in the widget |
|
||||
| `start` | Left aligns text in the widget |
|
||||
| `center` | Center aligns text in the widget |
|
||||
| `right` | Right aligns text in the widget |
|
||||
| `end` | Right aligns text in the widget |
|
||||
| `justify` | Justifies text in the widget |
|
||||
|
||||
## Example
|
||||
|
||||
This example shows, from top to bottom: `left`, `center`, `right`, and `justify` text alignments.
|
||||
|
||||
=== "text_align.py"
|
||||
|
||||
```python
|
||||
--8<-- "docs/examples/styles/text_align.py"
|
||||
```
|
||||
|
||||
=== "text_align.css"
|
||||
|
||||
```css
|
||||
--8<-- "docs/examples/styles/text_align.css"
|
||||
```
|
||||
|
||||
=== "Output"
|
||||
|
||||
```{.textual path="docs/examples/styles/text_align.py"}
|
||||
```
|
||||
|
||||
## CSS
|
||||
|
||||
```sass
|
||||
/* Set text in all Widgets to be right aligned */
|
||||
Widget {
|
||||
text-align: right;
|
||||
}
|
||||
```
|
||||
|
||||
## Python
|
||||
|
||||
```python
|
||||
# Set text in the widget to be right aligned
|
||||
widget.styles.text_align = "right"
|
||||
```
|
||||
11
mkdocs.yml
11
mkdocs.yml
@@ -6,10 +6,10 @@ nav:
|
||||
- "getting_started.md"
|
||||
- "introduction.md"
|
||||
- Guide:
|
||||
- "guide/devtools.md"
|
||||
- "guide/devtools.md"
|
||||
- "guide/CSS.md"
|
||||
- "guide/events.md"
|
||||
|
||||
|
||||
- "actions.md"
|
||||
- Events:
|
||||
- "events/blur.md"
|
||||
@@ -35,7 +35,7 @@ nav:
|
||||
- "events/resize.md"
|
||||
- "events/screen_resume.md"
|
||||
- "events/screen_suspend.md"
|
||||
- "events/show.md"
|
||||
- "events/show.md"
|
||||
- Styles:
|
||||
- "styles/background.md"
|
||||
- "styles/border.md"
|
||||
@@ -54,9 +54,10 @@ nav:
|
||||
- "styles/outline.md"
|
||||
- "styles/overflow.md"
|
||||
- "styles/padding.md"
|
||||
- "styles/scrollbar.md"
|
||||
- "styles/scrollbar_gutter.md"
|
||||
- "styles/scrollbar_size.md"
|
||||
- "styles/scrollbar.md"
|
||||
- "styles/text_align.md"
|
||||
- "styles/text_style.md"
|
||||
- "styles/tint.md"
|
||||
- "styles/visibility.md"
|
||||
@@ -82,7 +83,7 @@ markdown_extensions:
|
||||
- admonition
|
||||
- def_list
|
||||
- meta
|
||||
|
||||
|
||||
- toc:
|
||||
permalink: true
|
||||
baselevel: 1
|
||||
|
||||
33
sandbox/darren/text_align.py
Normal file
33
sandbox/darren/text_align.py
Normal file
@@ -0,0 +1,33 @@
|
||||
from __future__ import annotations
|
||||
|
||||
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. 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. "
|
||||
)
|
||||
|
||||
|
||||
class TextAlign(App):
|
||||
def compose(self) -> ComposeResult:
|
||||
left = Static("[b]Left aligned[/]\n" + TEXT, id="one")
|
||||
yield left
|
||||
|
||||
right = Static("[b]Center aligned[/]\n" + TEXT, id="two")
|
||||
yield right
|
||||
|
||||
center = Static("[b]Right aligned[/]\n" + TEXT, id="three")
|
||||
yield center
|
||||
|
||||
full = Static("[b]Fully justified[/]\n" + TEXT, id="four")
|
||||
yield full
|
||||
|
||||
|
||||
app = TextAlign(css_path="text_align.scss", watch_css=True)
|
||||
|
||||
if __name__ == "__main__":
|
||||
app.run()
|
||||
24
sandbox/darren/text_align.scss
Normal file
24
sandbox/darren/text_align.scss
Normal file
@@ -0,0 +1,24 @@
|
||||
#one {
|
||||
text-align: left;
|
||||
background: lightblue;
|
||||
|
||||
}
|
||||
|
||||
#two {
|
||||
text-align: center;
|
||||
background: indianred;
|
||||
}
|
||||
|
||||
#three {
|
||||
text-align: right;
|
||||
background: palegreen;
|
||||
}
|
||||
|
||||
#four {
|
||||
text-align: justify;
|
||||
background: palevioletred;
|
||||
}
|
||||
|
||||
Static {
|
||||
padding: 1;
|
||||
}
|
||||
@@ -9,10 +9,10 @@ from textual.css._help_renderables import Example, Bullet, HelpText
|
||||
from textual.css.constants import (
|
||||
VALID_BORDER,
|
||||
VALID_LAYOUT,
|
||||
VALID_EDGE,
|
||||
VALID_ALIGN_HORIZONTAL,
|
||||
VALID_ALIGN_VERTICAL,
|
||||
VALID_STYLE_FLAGS,
|
||||
VALID_TEXT_ALIGN,
|
||||
)
|
||||
|
||||
if sys.version_info >= (3, 8):
|
||||
@@ -648,6 +648,26 @@ def align_help_text() -> HelpText:
|
||||
)
|
||||
|
||||
|
||||
def text_align_help_text() -> HelpText:
|
||||
"""Help text to show when the user supplies an invalid value for the text-align property
|
||||
|
||||
Returns:
|
||||
HelpText: Renderable for displaying the help text for this property.
|
||||
"""
|
||||
return HelpText(
|
||||
summary="Invalid value for the [i]text-align[/] property.",
|
||||
bullets=[
|
||||
Bullet(
|
||||
f"The [i]text-align[/] property must be one of {friendly_list(VALID_TEXT_ALIGN)}",
|
||||
examples=[
|
||||
Example("text-align: center;"),
|
||||
Example("text-align: right;"),
|
||||
],
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
def offset_single_axis_help_text(property_name: str) -> HelpText:
|
||||
"""Help text to show when the user supplies an invalid value for an offset-* property.
|
||||
|
||||
|
||||
@@ -24,6 +24,7 @@ from ._help_text import (
|
||||
property_invalid_value_help_text,
|
||||
scrollbar_size_property_help_text,
|
||||
scrollbar_size_single_axis_help_text,
|
||||
text_align_help_text,
|
||||
)
|
||||
from .constants import (
|
||||
VALID_ALIGN_HORIZONTAL,
|
||||
@@ -36,6 +37,7 @@ from .constants import (
|
||||
VALID_VISIBILITY,
|
||||
VALID_STYLE_FLAGS,
|
||||
VALID_SCROLLBAR_GUTTER,
|
||||
VALID_TEXT_ALIGN,
|
||||
)
|
||||
from .errors import DeclarationError, StyleValueError
|
||||
from .model import Declaration
|
||||
@@ -618,6 +620,20 @@ class StylesBuilder:
|
||||
style_definition = " ".join(token.value for token in tokens)
|
||||
self.styles.text_style = style_definition
|
||||
|
||||
def process_text_align(self, name: str, tokens: list[Token]) -> None:
|
||||
"""Process a text-align declaration"""
|
||||
if not tokens:
|
||||
return
|
||||
|
||||
if len(tokens) > 1 or tokens[0].value not in VALID_TEXT_ALIGN:
|
||||
self.error(
|
||||
name,
|
||||
tokens[0],
|
||||
text_align_help_text(),
|
||||
)
|
||||
|
||||
self.styles._rules["text_align"] = tokens[0].value
|
||||
|
||||
def process_dock(self, name: str, tokens: list[Token]) -> None:
|
||||
if not tokens:
|
||||
return
|
||||
|
||||
@@ -38,6 +38,7 @@ VALID_BOX_SIZING: Final = {"border-box", "content-box"}
|
||||
VALID_OVERFLOW: Final = {"scroll", "hidden", "auto"}
|
||||
VALID_ALIGN_HORIZONTAL: Final = {"left", "center", "right"}
|
||||
VALID_ALIGN_VERTICAL: Final = {"top", "middle", "bottom"}
|
||||
VALID_TEXT_ALIGN: Final = {"start", "end", "left", "right", "center", "justify"}
|
||||
VALID_SCROLLBAR_GUTTER: Final = {"auto", "stable"}
|
||||
VALID_STYLE_FLAGS: Final = {
|
||||
"none",
|
||||
|
||||
@@ -31,7 +31,6 @@ from ._style_properties import (
|
||||
SpacingProperty,
|
||||
StringEnumProperty,
|
||||
StyleFlagsProperty,
|
||||
StyleProperty,
|
||||
TransitionsProperty,
|
||||
)
|
||||
from .constants import (
|
||||
@@ -42,6 +41,7 @@ from .constants import (
|
||||
VALID_OVERFLOW,
|
||||
VALID_SCROLLBAR_GUTTER,
|
||||
VALID_VISIBILITY,
|
||||
VALID_TEXT_ALIGN,
|
||||
)
|
||||
from .scalar import Scalar, ScalarOffset, Unit
|
||||
from .scalar_animation import ScalarAnimation
|
||||
@@ -57,6 +57,7 @@ from .types import (
|
||||
Specificity3,
|
||||
Specificity6,
|
||||
Visibility,
|
||||
TextAlign,
|
||||
)
|
||||
|
||||
if sys.version_info >= (3, 8):
|
||||
@@ -143,6 +144,8 @@ class RulesMap(TypedDict, total=False):
|
||||
content_align_horizontal: AlignHorizontal
|
||||
content_align_vertical: AlignVertical
|
||||
|
||||
text_align: TextAlign
|
||||
|
||||
|
||||
RULE_NAMES = list(RulesMap.__annotations__.keys())
|
||||
RULE_NAMES_SET = frozenset(RULE_NAMES)
|
||||
@@ -250,6 +253,8 @@ class StylesBase(ABC):
|
||||
content_align_vertical = StringEnumProperty(VALID_ALIGN_VERTICAL, "top")
|
||||
content_align = AlignProperty()
|
||||
|
||||
text_align = StringEnumProperty(VALID_TEXT_ALIGN, "start")
|
||||
|
||||
def __eq__(self, styles: object) -> bool:
|
||||
"""Check that Styles contains the same rules."""
|
||||
if not isinstance(styles, StylesBase):
|
||||
@@ -459,7 +464,6 @@ class StylesBase(ABC):
|
||||
@rich.repr.auto
|
||||
@dataclass
|
||||
class Styles(StylesBase):
|
||||
|
||||
node: DOMNode | None = None
|
||||
_rules: RulesMap = field(default_factory=dict)
|
||||
|
||||
|
||||
@@ -39,6 +39,7 @@ ScrollbarGutter = Literal["auto", "stable"]
|
||||
BoxSizing = Literal["border-box", "content-box"]
|
||||
Overflow = Literal["scroll", "hidden", "auto"]
|
||||
EdgeStyle = Tuple[EdgeType, Color]
|
||||
TextAlign = Literal["left", "start", "center", "right", "end", "justify"]
|
||||
|
||||
Specificity3 = Tuple[int, int, int]
|
||||
Specificity4 = Tuple[int, int, int, int]
|
||||
|
||||
@@ -619,7 +619,7 @@ class Region(NamedTuple):
|
||||
"""Move the offset of the Region.
|
||||
|
||||
Args:
|
||||
translate (tuple[int, int]): Offset to add to region.
|
||||
offset (tuple[int, int]): Offset to add to region.
|
||||
|
||||
Returns:
|
||||
Region: A new region shifted by (x, y)
|
||||
|
||||
@@ -242,7 +242,7 @@ class MessagePump(metaclass=MessagePumpMeta):
|
||||
name: str | None = None,
|
||||
repeat: int = 0,
|
||||
pause: bool = False,
|
||||
):
|
||||
) -> Timer:
|
||||
"""Call a function at periodic intervals.
|
||||
|
||||
Args:
|
||||
|
||||
@@ -1,14 +1,11 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from asyncio import Lock
|
||||
from itertools import islice
|
||||
from fractions import Fraction
|
||||
from itertools import islice
|
||||
from operator import attrgetter
|
||||
from typing import (
|
||||
TYPE_CHECKING,
|
||||
Any,
|
||||
Awaitable,
|
||||
Callable,
|
||||
ClassVar,
|
||||
Collection,
|
||||
Iterable,
|
||||
@@ -16,8 +13,7 @@ from typing import (
|
||||
)
|
||||
|
||||
import rich.repr
|
||||
|
||||
from rich.console import Console, RenderableType
|
||||
from rich.console import Console, RenderableType, JustifyMethod
|
||||
from rich.measure import Measurement
|
||||
from rich.segment import Segment
|
||||
from rich.style import Style
|
||||
@@ -33,13 +29,13 @@ from ._segment_tools import align_lines
|
||||
from ._styles_cache import StylesCache
|
||||
from ._types import Lines
|
||||
from .box_model import BoxModel, get_box_model
|
||||
from .css.constants import VALID_TEXT_ALIGN
|
||||
from .dom import DOMNode
|
||||
from .dom import NoScreen
|
||||
from .geometry import Offset, Region, Size, Spacing, clamp
|
||||
from .layouts.vertical import VerticalLayout
|
||||
from .message import Message
|
||||
from .reactive import Reactive
|
||||
from .dom import NoScreen
|
||||
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .app import App, ComposeResult
|
||||
@@ -1215,11 +1211,15 @@ class Widget(DOMNode):
|
||||
"""
|
||||
|
||||
if isinstance(renderable, str):
|
||||
renderable = Text.from_markup(renderable)
|
||||
justify = _get_rich_justify(self.styles.text_align)
|
||||
renderable = Text.from_markup(renderable, justify=justify)
|
||||
|
||||
rich_style = self.rich_style
|
||||
if isinstance(renderable, Text):
|
||||
renderable.stylize(rich_style)
|
||||
if not renderable.justify:
|
||||
justify = _get_rich_justify(self.styles.text_align)
|
||||
renderable.justify = justify
|
||||
else:
|
||||
renderable = Styled(renderable, rich_style)
|
||||
|
||||
@@ -1380,9 +1380,6 @@ class Widget(DOMNode):
|
||||
def render(self) -> RenderableType:
|
||||
"""Get renderable for widget.
|
||||
|
||||
Args:
|
||||
style (Styles): The Styles object for this Widget.
|
||||
|
||||
Returns:
|
||||
RenderableType: Any renderable
|
||||
"""
|
||||
@@ -1580,3 +1577,22 @@ class Widget(DOMNode):
|
||||
self.scroll_page_up()
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _get_rich_justify(css_align: str) -> JustifyMethod:
|
||||
"""Given the value for CSS text-align, return the analogous argument
|
||||
for the Rich text `justify` parameter.
|
||||
|
||||
Args:
|
||||
css_align: The value of text-align CSS property.
|
||||
|
||||
Returns:
|
||||
JustifyMethod: The Rich JustifyMethod that corresponds to the text-align
|
||||
value
|
||||
"""
|
||||
assert css_align in VALID_TEXT_ALIGN
|
||||
return {
|
||||
"start": "left",
|
||||
"end": "right",
|
||||
"justify": "full",
|
||||
}.get(css_align, css_align)
|
||||
|
||||
@@ -2,7 +2,6 @@ from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
from textual.color import Color
|
||||
from textual.css.errors import UnresolvedVariableError
|
||||
from textual.css.parse import substitute_references
|
||||
@@ -1131,3 +1130,27 @@ class TestParsePadding:
|
||||
stylesheet = Stylesheet()
|
||||
stylesheet.add_source(css)
|
||||
assert stylesheet.rules[0].styles.padding == Spacing(2, 3, -1, 1)
|
||||
|
||||
|
||||
class TestParseTextAlign:
|
||||
@pytest.mark.parametrize("valid_align", ["left", "start", "center", "right", "end", "justify"])
|
||||
def test_text_align(self, valid_align):
|
||||
css = f"#foo {{ text-align: {valid_align} }}"
|
||||
stylesheet = Stylesheet()
|
||||
stylesheet.add_source(css)
|
||||
assert stylesheet.rules[0].styles.text_align == valid_align
|
||||
|
||||
def test_text_align_invalid(self):
|
||||
css = "#foo { text-align: invalid-value; }"
|
||||
stylesheet = Stylesheet()
|
||||
with pytest.raises(StylesheetParseError):
|
||||
stylesheet.add_source(css)
|
||||
stylesheet.parse()
|
||||
rules = stylesheet._parse_rules(css, "foo")
|
||||
assert rules[0].errors
|
||||
|
||||
def test_text_align_empty_uses_default(self):
|
||||
css = "#foo { text-align: ; }"
|
||||
stylesheet = Stylesheet()
|
||||
stylesheet.add_source(css)
|
||||
assert stylesheet.rules[0].styles.text_align == "start"
|
||||
|
||||
@@ -25,4 +25,4 @@ def test_auto_refresh():
|
||||
elapsed = app.run(quit_after=1, headless=True)
|
||||
assert elapsed is not None
|
||||
# CI can run slower, so we need to give this a bit of margin
|
||||
assert elapsed >= 0.3 and elapsed < 0.6
|
||||
assert 0.2 <= elapsed < 0.8
|
||||
|
||||
Reference in New Issue
Block a user