Merge pull request #704 from Textualize/text-justify

`text-align` CSS support
This commit is contained in:
Will McGugan
2022-08-30 12:58:31 +01:00
committed by GitHub
17 changed files with 273 additions and 25 deletions

View 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;
}

View 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")

View File

@@ -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
View 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"
```

View File

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

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

View 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;
}

View File

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

View File

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

View File

@@ -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",

View File

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

View File

@@ -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]

View File

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

View File

@@ -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:

View File

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

View File

@@ -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"

View File

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