Merge pull request #294 from Textualize/reset-css

Implement CSS defaults
This commit is contained in:
Will McGugan
2022-02-21 14:19:38 +00:00
committed by GitHub
12 changed files with 319 additions and 101 deletions

View File

@@ -1,3 +1,3 @@
# Dev Sandbox
This directory contains test code. None of the .py files here are guaranteed to run or do anything useful, but you are welcome to look around.
This directory contains test code for Textual devs to experiment with new features. None of the .py files here are guaranteed to run or do anything useful, but you are welcome to look around.

33
sandbox/dev_sandbox.py Normal file
View File

@@ -0,0 +1,33 @@
from rich.console import RenderableType
from rich.panel import Panel
from textual.app import App
from textual.widget import Widget
class PanelWidget(Widget):
def render(self) -> RenderableType:
return Panel("hello world!", title="Title")
class BasicApp(App):
"""Sandbox application used for testing/development by Textual developers"""
def on_load(self):
"""Bind keys here."""
self.bind("tab", "toggle_class('#sidebar', '-active')")
self.bind("a", "toggle_class('#header', '-visible')")
self.bind("c", "toggle_class('#content', '-content-visible')")
self.bind("d", "toggle_class('#footer', 'dim')")
def on_mount(self):
"""Build layout here."""
self.mount(
header=Widget(),
content=PanelWidget(),
footer=Widget(),
sidebar=Widget(),
)
BasicApp.run(css_file="dev_sandbox.scss", watch_css=True, log="textual.log")

63
sandbox/dev_sandbox.scss Normal file
View File

@@ -0,0 +1,63 @@
/* CSS file for dev_sandbox.py */
$text: #f0f0f0;
$primary: #021720;
$secondary:#95d52a;
$background: #262626;
$primary-style: $text on $background;
$animation-speed: 500ms;
$animation: offset $animation-speed in_out_cubic;
App > View {
docks: side=left/1;
text: on $background;
}
Widget:hover {
outline: heavy;
text: bold !important;
}
#sidebar {
text: $primary-style;
dock: side;
width: 30;
offset-x: -100%;
transition: $animation;
border-right: outer $secondary;
}
#sidebar.-active {
offset-x: 0;
}
#header {
text: $text on $primary;
height: 3;
border-bottom: hkey $secondary;
}
#header.-visible {
visibility: hidden;
}
#content {
text: $text on $background;
offset-y: -3;
}
#content.-content-visible {
visibility: hidden;
}
#footer {
opacity: 1;
text: $text on $primary;
height: 3;
border-top: hkey $secondary;
}
#footer.dim {
opacity: 0.5;
}

View File

@@ -5,3 +5,7 @@ App > View {
Widget {
text: on blue;
}
Widget.-highlight {
outline: heavy red;
}

View File

@@ -25,5 +25,9 @@ class BasicApp(App):
def key_b(self) -> None:
self["#footer"].set_styles("text: on green")
def key_c(self) -> None:
self["#header"].toggle_class("-highlight")
self.log(self["#header"].styles)
BasicApp.run(css_file="local_styles.css", log="textual.log")

View File

@@ -45,6 +45,7 @@ class SimpleAnimation(Animation):
duration: float
start_value: float | Animatable
end_value: float | Animatable
final_value: float | Animatable
easing: EasingFunction
def __call__(self, time: float) -> bool:
@@ -62,7 +63,9 @@ class SimpleAnimation(Animation):
factor = min(1.0, (time - self.start_time) / self.duration)
eased_factor = self.easing(factor)
if isinstance(self.start_value, Animatable):
if factor == 1.0:
value = self.end_value
elif isinstance(self.start_value, Animatable):
assert isinstance(
self.end_value, Animatable
), "end_value must be animatable"
@@ -98,6 +101,7 @@ class BoundAnimator:
attribute: str,
value: float,
*,
final_value: Any = ...,
duration: float | None = None,
speed: float | None = None,
easing: EasingFunction | str = DEFAULT_EASING,
@@ -107,6 +111,7 @@ class BoundAnimator:
self._obj,
attribute=attribute,
value=value,
final_value=final_value,
duration=duration,
speed=speed,
easing=easing_function,
@@ -148,11 +153,25 @@ class Animator:
attribute: str,
value: Any,
*,
final_value: Any = ...,
duration: float | None = None,
speed: float | None = None,
easing: EasingFunction | str = DEFAULT_EASING,
) -> None:
"""Animate an attribute to a new value.
Args:
obj (object): The object containing the attribute.
attribute (str): The name of the attribute.
value (Any): The destination value of the attribute.
final_value (Any, optional): The final value, or ellipsis if it is the same as ``value``. Defaults to ....
duration (float | None, optional): The duration of the animation, or ``None`` to use speed. Defaults to ``None``.
speed (float | None, optional): The speed of the animation. Defaults to None.
easing (EasingFunction | str, optional): An easing function. Defaults to DEFAULT_EASING.
"""
if final_value is ...:
final_value = value
start_time = time()
animation_key = (id(obj), attribute)
@@ -190,6 +209,7 @@ class Animator:
duration=animation_duration,
start_value=start_value,
end_value=value,
final_value=final_value,
easing=easing_function,
)
assert animation is not None, "animation expected to be non-None"
@@ -206,4 +226,5 @@ class Animator:
animation = self._animations[animation_key]
if animation(animation_time):
del self._animations[animation_key]
# TODO: We should be able to do animation without refreshing everything
self.target.view.refresh(True, True)

View File

@@ -682,17 +682,12 @@ class App(DOMNode):
async def action_add_class_(self, selector: str, class_name: str) -> None:
self.view.query(selector).add_class(class_name)
self.view.refresh(layout=True)
async def action_remove_class_(self, selector: str, class_name: str) -> None:
self.view.query(selector).remove_class(class_name)
self.view.refresh(layout=True)
async def action_toggle_class(self, selector: str, class_name: str) -> None:
self.view.query(selector).toggle_class(class_name)
self.view.refresh(layout=True)
async def handle_styles_updated(self, message: messages.StylesUpdated) -> None:
self.reset_styles()
self.stylesheet.update(self)
self.view.refresh(layout=True)

View File

@@ -105,8 +105,8 @@ class ScalarProperty:
)
if new_value is not None and new_value.is_percent:
new_value = Scalar(float(new_value.value), self.percent_unit, Unit.WIDTH)
obj.set_rule(self.name, new_value)
obj.refresh()
if obj.set_rule(self.name, new_value):
obj.refresh()
class BoxProperty:
@@ -151,7 +151,8 @@ class BoxProperty:
StyleSyntaxError: If the string supplied for the color has invalid syntax.
"""
if border is None:
obj.clear_rule(self.name)
if obj.clear_rule(self.name):
obj.refresh()
else:
_type, color = border
new_value = border
@@ -159,8 +160,8 @@ class BoxProperty:
new_value = (_type, Color.parse(color))
elif isinstance(color, Color):
new_value = (_type, color)
obj.set_rule(self.name, new_value)
obj.refresh()
if obj.set_rule(self.name, new_value):
obj.refresh()
@rich.repr.auto
@@ -378,11 +379,13 @@ class SpacingProperty:
ValueError: When the value is malformed, e.g. a ``tuple`` with a length that is
not 1, 2, or 4.
"""
obj.refresh(layout=True)
if spacing is None:
obj.clear_rule(self.name)
if obj.clear_rule(self.name):
obj.refresh(layout=True)
else:
obj.set_rule(self.name, Spacing.unpack(spacing))
if obj.set_rule(self.name, Spacing.unpack(spacing)):
obj.refresh(layout=True)
class DocksProperty:
@@ -411,12 +414,12 @@ class DocksProperty:
obj (Styles): The ``Styles`` object.
docks (Iterable[DockGroup]): Iterable of DockGroups
"""
obj.refresh(layout=True)
if docks is None:
obj.clear_rule("docks")
if obj.clear_rule("docks"):
obj.refresh(layout=True)
else:
obj.set_rule("docks", tuple(docks))
if obj.set_rule("docks", tuple(docks)):
obj.refresh(layout=True)
class DockProperty:
@@ -445,8 +448,8 @@ class DockProperty:
obj (Styles): The ``Styles`` object
spacing (str | None): The spacing to use.
"""
obj.refresh(layout=True)
obj.set_rule("dock", spacing)
if obj.set_rule("dock", spacing):
obj.refresh(layout=True)
class LayoutProperty:
@@ -477,14 +480,15 @@ class LayoutProperty:
from ..layouts.factory import get_layout, Layout # Prevents circular import
obj.refresh(layout=True)
if layout is None:
obj.clear_rule("layout")
if obj.clear_rule("layout"):
obj.refresh(layout=True)
elif isinstance(layout, Layout):
obj.set_rule("layout", layout)
if obj.set_rule("layout", layout):
obj.refresh(layout=True)
else:
obj.set_rule("layout", get_layout(layout))
if obj.set_rule("layout", get_layout(layout)):
obj.refresh(layout=True)
class OffsetProperty:
@@ -525,11 +529,13 @@ class OffsetProperty:
ScalarParseError: If any of the string values supplied in the 2-tuple cannot
be parsed into a Scalar. For example, if you specify an non-existent unit.
"""
obj.refresh(layout=True)
if offset is None:
obj.clear_rule(self.name)
if obj.clear_rule(self.name):
obj.refresh(layout=True)
elif isinstance(offset, ScalarOffset):
obj.set_rule(self.name, offset)
if obj.set_rule(self.name, offset):
obj.refresh(layout=True)
else:
x, y = offset
scalar_x = (
@@ -543,7 +549,8 @@ class OffsetProperty:
else Scalar(float(y), Unit.CELLS, Unit.HEIGHT)
)
_offset = ScalarOffset(scalar_x, scalar_y)
obj.set_rule(self.name, _offset)
if obj.set_rule(self.name, _offset):
obj.refresh(layout=True)
class StringEnumProperty:
@@ -580,15 +587,17 @@ class StringEnumProperty:
Raises:
StyleValueError: If the value is not in the set of valid values.
"""
obj.refresh()
if value is None:
obj.clear_rule(self.name)
if obj.clear_rule(self.name):
obj.refresh()
else:
if value not in self._valid_values:
raise StyleValueError(
f"{self.name} must be one of {friendly_list(self._valid_values)}"
)
obj.set_rule(self.name, value)
if obj.set_rule(self.name, value):
obj.refresh()
class NameProperty:
@@ -619,13 +628,15 @@ class NameProperty:
Raises:
StyleTypeError: If the value is not a ``str``.
"""
obj.refresh(layout=True)
if name is None:
obj.clear_rule(self.name)
if obj.clear_rule(self.name):
obj.refresh(layout=True)
else:
if not isinstance(name, str):
raise StyleTypeError(f"{self.name} must be a str")
obj.set_rule(self.name, name)
if obj.set_rule(self.name, name):
obj.refresh(layout=True)
class NameListProperty:
@@ -640,15 +651,18 @@ class NameListProperty:
def __set__(
self, obj: Styles, names: str | tuple[str] | None = None
) -> str | tuple[str] | None:
obj.refresh(layout=True)
if names is None:
obj.clear_rule(self.name)
if obj.clear_rule(self.name):
obj.refresh(layout=True)
elif isinstance(names, str):
obj.set_rule(
if obj.set_rule(
self.name, tuple(name.strip().lower() for name in names.split(" "))
)
):
obj.refresh(layout=True)
elif isinstance(names, tuple):
obj.set_rule(self.name, names)
if obj.set_rule(self.name, names):
obj.refresh(layout=True)
class ColorProperty:
@@ -681,13 +695,16 @@ class ColorProperty:
Raises:
ColorParseError: When the color string is invalid.
"""
obj.refresh()
if color is None:
obj.clear_rule(self.name)
if obj.clear_rule(self.name):
obj.refresh()
elif isinstance(color, Color):
obj.set_rule(self.name, color)
if obj.set_rule(self.name, color):
obj.refresh()
elif isinstance(color, str):
obj.set_rule(self.name, Color.parse(color))
if obj.set_rule(self.name, Color.parse(color)):
obj.refresh()
class StyleFlagsProperty:
@@ -722,7 +739,7 @@ class StyleFlagsProperty:
"""
return obj.get_rule(self.name, Style.null())
def __set__(self, obj: Styles, style_flags: str | None):
def __set__(self, obj: Styles, style_flags: Style | str | None):
"""Set the style using a style flag string
Args:
@@ -733,9 +750,12 @@ class StyleFlagsProperty:
Raises:
StyleValueError: If the value is an invalid style flag
"""
obj.refresh()
if style_flags is None:
obj.clear_rule(self.name)
if obj.clear_rule(self.name):
obj.refresh()
elif isinstance(style_flags, Style):
if obj.set_rule(self.name, style_flags):
obj.refresh()
else:
words = [word.strip() for word in style_flags.split(" ")]
valid_word = self._VALID_PROPERTIES.__contains__
@@ -746,7 +766,8 @@ class StyleFlagsProperty:
f"valid values are {friendly_list(self._VALID_PROPERTIES)}"
)
style = Style.parse(style_flags)
obj.set_rule(self.name, style)
if obj.set_rule(self.name, style):
obj.refresh()
class TransitionsProperty:
@@ -806,10 +827,10 @@ class FractionalProperty:
value (float|str|None): The value to set as a float between 0 and 1, or
as a percentage string such as '10%'.
"""
obj.refresh()
name = self.name
if value is None:
obj.clear_rule(name)
if obj.clear_rule(name):
obj.refresh()
return
if isinstance(value, float):
@@ -820,4 +841,5 @@ class FractionalProperty:
raise StyleTypeError(
f"{self.name} must be a str (e.g. '10%') or a float (e.g. 0.1)"
)
obj.set_rule(name, clamp(float_value, 0, 1))
if obj.set_rule(name, clamp(float_value, 0, 1)):
obj.refresh()

View File

@@ -4,6 +4,7 @@ import sys
from abc import ABC, abstractmethod
from dataclasses import dataclass, field
from functools import lru_cache
from operator import attrgetter
from typing import TYPE_CHECKING, Any, Iterable, NamedTuple, cast
import rich.repr
@@ -94,6 +95,7 @@ class RulesMap(TypedDict, total=False):
RULE_NAMES = list(RulesMap.__annotations__.keys())
_rule_getter = attrgetter(*RULE_NAMES)
class DockGroup(NamedTuple):
@@ -154,6 +156,12 @@ class StylesBase(ABC):
layers = NameListProperty()
transitions = TransitionsProperty()
def __eq__(self, styles: object) -> bool:
"""Check that Styles containts the same rules."""
if not isinstance(styles, StylesBase):
return NotImplemented
return self.get_rules() == styles.get_rules()
@abstractmethod
def has_rule(self, rule: str) -> bool:
"""Check if a rule is set on this Styles object.
@@ -166,11 +174,14 @@ class StylesBase(ABC):
"""
@abstractmethod
def clear_rule(self, rule: str) -> None:
def clear_rule(self, rule: str) -> bool:
"""Removes the rule from the Styles object, as if it had never been set.
Args:
rule (str): Rule name.
Returns:
bool: ``True`` if a rule was cleared, or ``False`` if the rule is already not set.
"""
@abstractmethod
@@ -182,12 +193,15 @@ class StylesBase(ABC):
"""
@abstractmethod
def set_rule(self, rule: str, value: object | None) -> None:
"""Set an individual rule.
def set_rule(self, rule: str, value: object | None) -> bool:
"""Set a rule.
Args:
rule (str): Name of rule.
value (object): Value of rule.
rule (str): Rule name.
value (object | None): New rule value.
Returns:
bool: ``True`` if the rule changed, otherwise ``False``.
"""
@abstractmethod
@@ -203,8 +217,8 @@ class StylesBase(ABC):
"""
@abstractmethod
def refresh(self, layout: bool = False) -> None:
"""Mark the styles are requiring a refresh.
def refresh(self, *, layout: bool = False) -> None:
"""Mark the styles as requiring a refresh.
Args:
layout (bool, optional): Also require a layout. Defaults to False.
@@ -238,6 +252,12 @@ class StylesBase(ABC):
rules (RulesMap): A mapping of rules.
"""
def get_render_rules(self) -> RulesMap:
"""Get rules map with defaults."""
# Get a dictionary of rules, going through the properties
rules = dict(zip(RULE_NAMES, _rule_getter(self)))
return cast(RulesMap, rules)
@classmethod
def is_animatable(cls, rule: str) -> bool:
"""Check if a given rule may be animated.
@@ -289,27 +309,50 @@ class Styles(StylesBase):
important: set[str] = field(default_factory=set)
def copy(self) -> Styles:
"""Get a copy of this Styles object."""
return Styles(node=self.node, _rules=self.get_rules(), important=self.important)
def has_rule(self, rule: str) -> bool:
return rule in self._rules
def clear_rule(self, rule: str) -> None:
self._rules.pop(rule, None)
def clear_rule(self, rule: str) -> bool:
"""Removes the rule from the Styles object, as if it had never been set.
Args:
rule (str): Rule name.
Returns:
bool: ``True`` if a rule was cleared, or ``False`` if it was already not set.
"""
return self._rules.pop(rule, None) is not None
def get_rules(self) -> RulesMap:
return self._rules.copy()
def set_rule(self, rule: str, value: object | None) -> None:
def set_rule(self, rule: str, value: object | None) -> bool:
"""Set a rule.
Args:
rule (str): Rule name.
value (object | None): New rule value.
Returns:
bool: ``True`` if the rule changed, otherwise ``False``.
"""
if value is None:
self._rules.pop(rule, None)
return self._rules.pop(rule, None) is not None
else:
current = self._rules.get(rule)
self._rules[rule] = value
return current != value
def get_rule(self, rule: str, default: object = None) -> object:
return self._rules.get(rule, default)
def refresh(self, layout: bool = False) -> None:
def refresh(self, *, layout: bool = False) -> None:
self._repaint_required = True
self._layout_required = layout
self._layout_required = self._layout_required or layout
def check_refresh(self) -> tuple[bool, bool]:
"""Check if the Styles must be refreshed.
@@ -322,9 +365,7 @@ class Styles(StylesBase):
return result
def reset(self) -> None:
"""
Reset internal style rules to ``None``, reverting to default styles.
"""
"""Reset the rules to initial state."""
self._rules.clear()
def merge(self, other: Styles) -> None:
@@ -561,11 +602,7 @@ class RenderStyles(StylesBase):
if self.has_rule(rule_name):
yield rule_name, getattr(self, rule_name)
def reset(self) -> None:
"""Reset the inline styles."""
self._inline_styles.reset()
def refresh(self, layout: bool = False) -> None:
def refresh(self, *, layout: bool = False) -> None:
self._inline_styles.refresh(layout=layout)
def merge(self, other: Styles) -> None:
@@ -590,21 +627,25 @@ class RenderStyles(StylesBase):
result = (base_repaint or inline_repaint, base_layout or inline_layout)
return result
def reset(self) -> None:
"""Reset the rules to initial state."""
self._inline_styles.reset()
def has_rule(self, rule: str) -> bool:
"""Check if a rule has been set."""
return self._inline_styles.has_rule(rule) or self._base_styles.has_rule(rule)
def set_rule(self, rule: str, value: object | None) -> None:
self._inline_styles.set_rule(rule, value)
def set_rule(self, rule: str, value: object | None) -> bool:
return self._inline_styles.set_rule(rule, value)
def get_rule(self, rule: str, default: object = None) -> object:
if self._inline_styles.has_rule(rule):
return self._inline_styles.get_rule(rule, default)
return self._base_styles.get_rule(rule, default)
def clear_rule(self, rule_name: str) -> None:
def clear_rule(self, rule_name: str) -> bool:
"""Clear a rule (from inline)."""
self._inline_styles.clear_rule(rule_name)
return self._inline_styles.clear_rule(rule_name)
def get_rules(self) -> RulesMap:
"""Get rules as a dictionary"""

View File

@@ -16,11 +16,12 @@ from rich.syntax import Syntax
from rich.text import Text
from textual._loop import loop_last
from .._context import active_app
from .errors import StylesheetError
from .match import _check_selectors
from .model import RuleSet
from .parse import parse
from .styles import RulesMap
from .styles import RULE_NAMES, Styles, RulesMap
from .types import Specificity3, Specificity4
from ..dom import DOMNode
from .. import log
@@ -179,47 +180,79 @@ class Stylesheet:
},
)
self.apply_rules(node, node_rules, animate=animate)
self.replace_rules(node, node_rules, animate=animate)
@classmethod
def apply_rules(cls, node: DOMNode, rules: RulesMap, animate: bool = False) -> None:
"""Apply style rules to a node, animating as required.
def replace_rules(
cls, node: DOMNode, rules: RulesMap, animate: bool = False
) -> None:
"""Replace style rules on a node, animating as required.
Args:
node (DOMNode): A DOM node.
rules (RulesMap): Mapping of rules.
animate (bool, optional): Enable animation. Defaults to False.
"""
# Alias styles and base styles
styles = node.styles
base_styles = styles.base
# Styles currently used an new rules
modified_rule_keys = {*base_styles.get_rules().keys(), *rules.keys()}
# Current render rules (missing rules are filled with default)
current_render_rules = styles.get_render_rules()
# Calculate replacement rules (defaults + new rules)
new_styles = node._default_styles.copy()
new_styles.merge_rules(rules)
if new_styles == base_styles:
# Nothing to change, return early
return
# New render rules
new_render_rules = new_styles.get_render_rules()
# Some aliases
is_animatable = styles.is_animatable
get_current_render_rule = current_render_rules.get
get_new_render_rule = new_render_rules.get
if animate:
for key in modified_rule_keys:
# Get old and new render rules
old_render_value = get_current_render_rule(key)
new_render_value = get_new_render_rule(key)
# Get new rule value (may be None)
new_value = rules.get(key)
is_animatable = styles.is_animatable
current_rules = styles.get_rules()
set_rule = styles.base.set_rule
for key, value in rules.items():
current = current_rules.get(key)
if current == value:
continue
if is_animatable(key):
transition = styles.get_transition(key)
if transition is None:
styles.base.set_rule(key, value)
else:
# Check if this can / should be animated
if is_animatable(key) and new_render_value != old_render_value:
transition = new_styles.get_transition(key)
if transition is not None:
duration, easing, delay = transition
node.app.animator.animate(
node.styles.base,
key,
value,
new_render_value,
final_value=new_value,
duration=duration,
easing=easing,
)
else:
set_rule(key, value)
continue
# Default is to set value (if new_value is None, rule will be removed)
setattr(base_styles, key, new_value)
else:
styles.base.merge_rules(rules)
# Not animated, so we apply the rules directly
for key in modified_rule_keys:
setattr(base_styles, key, rules.get(key))
node.on_style_change()
# The styles object may have requested a refresh / layout
# It's the style properties that set these flags
repaint, layout = styles.check_refresh()
if repaint:
node.refresh(layout=layout)
def update(self, root: DOMNode, animate: bool = False) -> None:
"""Update a node and its children."""

View File

@@ -45,8 +45,8 @@ class DOMNode(MessagePump):
self.INLINE_STYLES, repr(self), node=self
)
self.styles = RenderStyles(self, self._css_styles, self._inline_styles)
default_styles = Styles.parse(self.DEFAULT_STYLES, repr(self))
self._default_rules = default_styles.extract_rules((0, 0, 0))
self._default_styles = Styles.parse(self.DEFAULT_STYLES, repr(self))
self._default_rules = self._default_styles.extract_rules((0, 0, 0))
super().__init__()
def __rich_repr__(self) -> rich.repr.Result:

View File

@@ -12,6 +12,7 @@ from rich.control import Control
from rich.segment import Segment, SegmentLines
from rich.style import Style
from . import log
from ._loop import loop_last
from ._types import Lines
from .geometry import Region, Offset, Size
@@ -425,4 +426,5 @@ class Layout(ABC):
update_region = region.intersection(clip)
update_lines = self.render(console, crop=update_region).lines
update = LayoutUpdate(update_lines, update_region)
log(update)
return update