css align

This commit is contained in:
Will McGugan
2022-04-14 13:10:49 +01:00
parent 69694c4591
commit 8e76e524a8
13 changed files with 188 additions and 44 deletions

19
sandbox/align.css Normal file
View File

@@ -0,0 +1,19 @@
Screen {
layout: horizontal;
}
Widget#thing {
width: 20;
height: 10;
align: center middle;
}
Widget#thing2 {
width: 30;
height: 8;
align: center middle;
background: green;
}

17
sandbox/align.py Normal file
View File

@@ -0,0 +1,17 @@
from textual.app import App
from textual.widget import Widget
class AlignApp(App):
def on_load(self):
self.bind("t", "log_tree")
def on_mount(self) -> None:
self.log("MOUNTED")
self.mount(thing=Widget(), thing2=Widget())
def action_log_tree(self):
self.log(self.screen.tree)
AlignApp.run(css_file="align.css", log="textual.log", watch_css=True)

View File

@@ -459,6 +459,7 @@ class App(DOMNode):
Args:
error (Exception): An exception instance.
"""
if hasattr(error, "__rich__"):
# Exception has a rich method, so we can defer to that for the rendering
self.panic(error)
@@ -489,15 +490,9 @@ class App(DOMNode):
if os.getenv("TEXTUAL_DEVTOOLS") == "1":
try:
await self.devtools.connect()
if self._log_console:
self._log_console.print(
f"Connected to devtools ({self.devtools.url})"
)
self.log(f"Connected to devtools ({self.devtools.url})")
except DevtoolsConnectionError:
if self._log_console:
self._log_console.print(
f"Couldn't connect to devtools ({self.devtools.url})"
)
self.log(f"Couldn't connect to devtools ({self.devtools.url})")
try:
if self.css_file is not None:
self.stylesheet.read(self.css_file)
@@ -529,14 +524,15 @@ class App(DOMNode):
self.title = self._title
self.refresh()
await self.animator.start()
with redirect_stdout(StdoutRedirector(self.devtools, self._log_file)): # type: ignore
await super().process_messages()
log("Message processing stopped")
with timer("animator.stop()"):
await self.animator.stop()
with timer("self.close_all()"):
await self.close_all()
await super().process_messages()
log("PROCESS END")
if self.devtools.is_connected:
await self._disconnect_devtools()
self.log(f"Disconnected from devtools ({self.devtools.url})")
with timer("animator.stop()"):
await self.animator.stop()
with timer("self.close_all()"):
await self.close_all()
finally:
driver.stop_application_mode()
except Exception as error:
@@ -545,12 +541,6 @@ class App(DOMNode):
self._running = False
if self._exit_renderables:
self._print_error_renderables()
if self.devtools.is_connected:
await self._disconnect_devtools()
if self._log_console is not None:
self._log_console.print(
f"Disconnected from devtools ({self.devtools.url})"
)
if self._log_file is not None:
self._log_file.close()

View File

@@ -139,6 +139,7 @@ class Color(NamedTuple):
@property
def rich_color(self) -> RichColor:
"""This color encoded in Rich's Color class."""
# TODO: This isn't cheap as I'd like - cache in a LRUCache ?
r, g, b, _a = self
return RichColor.from_rgb(r, g, b)

View File

@@ -6,6 +6,8 @@ import rich.repr
from ._error_tools import friendly_list
from .constants import (
VALID_ALIGN_HORIZONTAL,
VALID_ALIGN_VERTICAL,
VALID_BORDER,
VALID_BOX_SIZING,
VALID_EDGE,
@@ -600,3 +602,30 @@ class StylesBuilder:
transitions[css_property] = Transition(duration, easing, delay)
self.styles._rules["transitions"] = transitions
def process_align(self, name: str, tokens: list[Token]) -> None:
if len(tokens) != 2:
self.error(name, tokens[0], "expected two tokens")
token_horizontal = tokens[0]
token_vertical = tokens[1]
if token_horizontal.name != "token":
self.error(
name,
token_horizontal,
f"invalid token {token_horizontal!r}, expected {friendly_list(VALID_ALIGN_HORIZONTAL)}",
)
if token_vertical.name != "token":
self.error(
name,
token_vertical,
f"invalid token {token_vertical!r}, expected {friendly_list(VALID_ALIGN_VERTICAL)}",
)
self.styles._rules["align_horizontal"] = token_horizontal.value
self.styles._rules["align_vertical"] = token_vertical.value
def process_align_horizontal(self, name: str, tokens: list[Token]) -> None:
self._process_enum(name, tokens, VALID_ALIGN_HORIZONTAL)
def process_align_vertical(self, name: str, tokens: list[Token]) -> None:
self._process_enum(name, tokens, VALID_ALIGN_VERTICAL)

View File

@@ -29,6 +29,6 @@ VALID_LAYOUT: Final = {"dock", "vertical", "grid"}
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"}
NULL_SPACING: Final = Spacing(0, 0, 0, 0)

View File

@@ -34,6 +34,7 @@ class Unit(Enum):
HEIGHT = 5
VIEW_WIDTH = 6
VIEW_HEIGHT = 7
AUTO = 8
UNIT_SYMBOL = {
@@ -124,11 +125,14 @@ class Scalar(NamedTuple):
Returns:
Scalar: New scalar
"""
match = _MATCH_SCALAR(token)
if match is None:
raise ScalarParseError(f"{token!r} is not a valid scalar")
value, unit_name = match.groups()
scalar = cls(float(value), SYMBOL_UNIT[unit_name or ""], percent_unit)
if token.lower() == "auto":
scalar = cls(1.0, Unit.AUTO, Unit.AUTO)
else:
match = _MATCH_SCALAR(token)
if match is None:
raise ScalarParseError(f"{token!r} is not a valid scalar")
value, unit_name = match.groups()
scalar = cls(float(value), SYMBOL_UNIT[unit_name or ""], percent_unit)
return scalar
@lru_cache(maxsize=4096)

View File

@@ -13,7 +13,7 @@ from rich.style import Style
from .. import log
from .._animator import Animation, EasingFunction
from ..color import Color
from ..geometry import Size, Spacing
from ..geometry import Offset, Size, Spacing
from ._style_properties import (
BorderProperty,
BoxProperty,
@@ -32,17 +32,28 @@ from ._style_properties import (
TransitionsProperty,
FractionalProperty,
)
from .constants import VALID_BOX_SIZING, VALID_DISPLAY, VALID_VISIBILITY, VALID_OVERFLOW
from .constants import (
VALID_ALIGN_HORIZONTAL,
VALID_ALIGN_VERTICAL,
VALID_BOX_SIZING,
VALID_DISPLAY,
VALID_VISIBILITY,
VALID_OVERFLOW,
)
from .scalar import Scalar, ScalarOffset, Unit
from .scalar_animation import ScalarAnimation
from .transition import Transition
from .types import (
BoxSizing,
Display,
AlignHorizontal,
AlignVertical,
Edge,
AlignHorizontal,
Overflow,
Specificity3,
Specificity4,
AlignVertical,
Visibility,
)
@@ -116,6 +127,9 @@ class RulesMap(TypedDict, total=False):
scrollbar_background_hover: Color
scrollbar_background_active: Color
align_horizontal: AlignHorizontal
align_vertical: AlignVertical
RULE_NAMES = list(RulesMap.__annotations__.keys())
_rule_getter = attrgetter(*RULE_NAMES)
@@ -204,6 +218,9 @@ class StylesBase(ABC):
scrollbar_background_hover = ColorProperty("#444444")
scrollbar_background_active = ColorProperty("black")
align_horizontal = StringEnumProperty(VALID_ALIGN_HORIZONTAL, "left")
align_vertical = StringEnumProperty(VALID_ALIGN_VERTICAL, "top")
def __eq__(self, styles: object) -> bool:
"""Check that Styles containts the same rules."""
if not isinstance(styles, StylesBase):
@@ -410,6 +427,44 @@ class StylesBase(ABC):
return size, margin
def align_width(self, width: int, parent_width: int) -> int:
"""Align the width dimension.
Args:
width (int): Width of the content.
parent_width (int): Width of the parent container.
Returns:
int: An offset to add to the X coordinate.
"""
offset_x = 0
align_horizontal = self.align_horizontal
if align_horizontal != "left":
if align_horizontal == "center":
offset_x = (parent_width - width) // 2
else:
offset_x = parent_width - width
return offset_x
def align_height(self, height: int, parent_height: int) -> int:
"""Align the height dimensions
Args:
height (int): Height of the content.
parent_height (int): Height of the parent container.
Returns:
int: An offset to add to the Y coordinate.
"""
offset_y = 0
align_vertical = self.align_vertical
if align_vertical != "left":
if align_vertical == "middle":
offset_y = (parent_height - height) // 2
else:
offset_y = parent_height - height
return offset_y
@rich.repr.auto
@dataclass
@@ -676,6 +731,15 @@ class Styles(StylesBase):
),
)
if has_rule("align_horizontal") and has_rule("align_vertical"):
append_declaration(
"align", f"{self.align_horizontal} {self.align_vertical}"
)
elif has_rule("align_horizontal"):
append_declaration("align-horizontal", self.align_horizontal)
elif has_rule("align_horizontal"):
append_declaration("align-vertical", self.align_vertical)
lines.sort()
return lines

View File

@@ -30,6 +30,8 @@ EdgeType = Literal[
]
Visibility = Literal["visible", "hidden", "initial", "inherit"]
Display = Literal["block", "none"]
AlignHorizontal = Literal["left", "center", "right"]
AlignVertical = Literal["top", "middle", "bottom"]
BoxSizing = Literal["border-box", "content-box"]
Overflow = Literal["scroll", "hidden", "auto"]
EdgeStyle = Tuple[str, Color]

View File

@@ -25,10 +25,14 @@ class HorizontalLayout(Layout):
parent_size = parent.size
for widget in parent.children:
styles = widget.styles
(content_width, content_height), margin = widget.styles.get_box_model(
size, parent_size
)
region = Region(margin.left + x, margin.top, content_width, content_height)
offset_y = styles.align_height(content_height, parent_size.height)
region = Region(
margin.left + x, margin.top + offset_y, content_width, content_height
)
max_height = max(max_height, content_height + margin.height)
add_placement(WidgetPlacement(region, widget, 0))
x += region.width + margin.left

View File

@@ -27,10 +27,15 @@ class VerticalLayout(Layout):
parent_size = parent.size
for widget in parent.children:
(content_width, content_height), margin = widget.styles.get_box_model(
styles = widget.styles
(content_width, content_height), margin = styles.get_box_model(
size, parent_size
)
region = Region(margin.left, y + margin.top, content_width, content_height)
offset_x = styles.align_width(content_width, parent_size.width)
region = Region(
margin.left + offset_x, y + margin.top, content_width, content_height
)
max_width = max(max_width, content_width + margin.width)
add_placement(WidgetPlacement(region, widget, 0))
y += region.height + margin.top

View File

@@ -1,19 +1,19 @@
from __future__ import annotations
from rich.console import ConsoleOptions, Console, RenderResult
from rich.color import Color
from rich.segment import Segment
from rich.style import Style
from ._blend_colors import blend_colors_rgb
from ..color import Color
class VerticalGradient:
"""Draw a vertical gradient."""
def __init__(self, color1: str, color2: str) -> None:
self._color1 = Color.parse(color1).get_truecolor()
self._color2 = Color.parse(color2).get_truecolor()
self._color1 = Color.parse(color1)
self._color2 = Color.parse(color2)
def __rich_console__(
self, console: Console, options: ConsoleOptions
@@ -22,15 +22,20 @@ class VerticalGradient:
height = options.height or options.max_height
color1 = self._color1
color2 = self._color2
default_color = Color.default()
default_color = Color(0, 0, 0).rich_color
from_color = Style.from_color
blend = color1.blend
rich_color1 = color1.rich_color
for y in range(height):
yield Segment(
f"{width * ' '}\n",
from_color(
default_color, blend_colors_rgb(color1, color2, y / (height - 1))
line_color = from_color(
default_color,
(
blend(color2, y / (height - 1)).rich_color
if height > 1
else rich_color1
),
)
yield Segment(f"{width * ' '}\n", line_color)
if __name__ == "__main__":

View File

@@ -204,7 +204,11 @@ class Screen(Widget):
if isinstance(event, events.MouseDown) and widget.can_focus:
await self.app.set_focus(widget)
event.style = self.get_style_at(event.screen_x, event.screen_y)
await widget.forward_event(event.offset(-region.x, -region.y))
if widget is self:
event.set_forwarded()
await self.post_message(event)
else:
await widget.forward_event(event.offset(-region.x, -region.y))
elif isinstance(event, (events.MouseScrollDown, events.MouseScrollUp)):
try: