Skip to content

Commit

Permalink
Merge branch 'main' of github.com:Textualize/textual into themes
Browse files Browse the repository at this point in the history
  • Loading branch information
darrenburns committed Oct 21, 2024
2 parents 9f84434 + 3daf4d7 commit 2f97fbf
Show file tree
Hide file tree
Showing 13 changed files with 624 additions and 86 deletions.
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
### Added

- Added `background-tint` CSS rule https://github.com/Textualize/textual/pull/5117
- Added `:first-of-type`, `:last-of-type`, `:odd`, and `:even` pseudo classes https://github.com/Textualize/textual/pull/5139

## [0.83.0] - 2024-10-10

Expand Down
4 changes: 4 additions & 0 deletions docs/guide/CSS.md
Original file line number Diff line number Diff line change
Expand Up @@ -330,10 +330,14 @@ Here are some other pseudo classes:
- `:dark` Matches widgets in dark mode (where `App.dark == True`).
- `:disabled` Matches widgets which are in a disabled state.
- `:enabled` Matches widgets which are in an enabled state.
- `:even` Matches a widget at an evenly numbered position within its siblings.
- `:first-of-type` Matches a widget that is the first of its type amongst its siblings.
- `:focus-within` Matches widgets with a focused child widget.
- `:focus` Matches widgets which have input focus.
- `:inline` Matches widgets when the app is running in inline mode.
- `:last-of-type` Matches a widget that is the last of its type amongst its siblings.
- `:light` Matches widgets in dark mode (where `App.dark == False`).
- `:odd` Matches a widget at an oddly numbered position within its siblings.

## Combinators

Expand Down
19 changes: 13 additions & 6 deletions src/textual/_resolve.py
Original file line number Diff line number Diff line change
Expand Up @@ -196,23 +196,30 @@ def resolve_box_models(
Returns:
List of resolved box models.
"""
margin_width, margin_height = margin

fraction_width = Fraction(max(0, size.width - margin_width))
fraction_height = Fraction(max(0, size.height - margin_height))

margin_width, margin_height = margin
fraction_width = Fraction(size.width)
fraction_height = Fraction(size.height)
fraction_zero = Fraction(0)
margin_size = size - margin

margins = [widget.styles.margin.totals for widget in widgets]

# Fixed box models
box_models: list[BoxModel | None] = [
(
None
if _dimension is not None and _dimension.is_fraction
else widget._get_box_model(
size, viewport_size, fraction_width, fraction_height
size,
viewport_size,
max(fraction_zero, fraction_width - margin_width),
max(fraction_zero, fraction_height - margin_height),
)
)
for (_dimension, widget) in zip(dimensions, widgets)
for (_dimension, widget, (margin_width, margin_height)) in zip(
dimensions, widgets, margins
)
]

if None not in box_models:
Expand Down
32 changes: 15 additions & 17 deletions src/textual/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -493,6 +493,16 @@ class MyApp(App[None]):
INLINE_PADDING: ClassVar[int] = 1
"""Number of blank lines above an inline app."""

_PSEUDO_CLASSES: ClassVar[dict[str, Callable[[App], bool]]] = {
"focus": lambda app: app.app_focus,
"blur": lambda app: not app.app_focus,
"dark": lambda app: app.dark,
"light": lambda app: not app.dark,
"inline": lambda app: app.is_inline,
"ansi": lambda app: app.ansi_color,
"nocolor": lambda app: app.no_color,
} # type: ignore[assignment]

title: Reactive[str] = Reactive("", compute=False)
"""The title of the app, displayed in the header."""
sub_title: Reactive[str] = Reactive("", compute=False)
Expand Down Expand Up @@ -913,22 +923,6 @@ def _context(self) -> Generator[None, None, None]:
active_message_pump.reset(message_pump_reset_token)
active_app.reset(app_reset_token)

def get_pseudo_classes(self) -> Iterable[str]:
"""Pseudo classes for a widget.
Returns:
Names of the pseudo classes.
"""
app_theme = self.get_theme(self.theme)
yield "focus" if self.app_focus else "blur"
yield "dark" if app_theme and app_theme.dark else "light"
if self.is_inline:
yield "inline"
if self.ansi_color:
yield "ansi"
if self.no_color:
yield "nocolor"

def _watch_ansi_color(self, ansi_color: bool) -> None:
"""Enable or disable the truecolor filter when the reactive changes"""
for filter in self._filters:
Expand Down Expand Up @@ -3265,17 +3259,21 @@ def _register(
widget_list = widgets

apply_stylesheet = self.stylesheet.apply
new_widgets: list[Widget] = []
add_new_widget = new_widgets.append
for widget in widget_list:
widget._closing = False
widget._closed = False
widget._pruning = False
if not isinstance(widget, Widget):
raise AppError(f"Can't register {widget!r}; expected a Widget instance")
if widget not in self._registry:
add_new_widget(widget)
self._register_child(parent, widget, before, after)
if widget._nodes:
self._register(widget, *widget._nodes, cache=cache)
apply_stylesheet(widget, cache=cache)
for widget in new_widgets:
apply_stylesheet(widget, cache=cache)

if not self._running:
# If the app is not running, prevent awaiting of the widget tasks
Expand Down
4 changes: 4 additions & 0 deletions src/textual/css/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,10 @@
"inline",
"light",
"nocolor",
"first-of-type",
"last-of-type",
"odd",
"even",
}
VALID_OVERLAY: Final = {"none", "screen"}
VALID_CONSTRAIN: Final = {"inflect", "inside", "none"}
Expand Down
32 changes: 21 additions & 11 deletions src/textual/css/stylesheet.py
Original file line number Diff line number Diff line change
Expand Up @@ -432,6 +432,15 @@ def _check_rule(
if _check_selectors(selector_set.selectors, css_path_nodes):
yield selector_set.specificity

# pseudo classes which iterate over many nodes
# these have the potential to be slow, and shouldn't be used in a cache key
EXPENSIVE_PSEUDO_CLASSES = {
"first-of-type",
"last-of_type",
"odd",
"even",
}

def apply(
self,
node: DOMNode,
Expand Down Expand Up @@ -467,14 +476,18 @@ def apply(
for rule in rules_map[name]
}
rules = list(filter(limit_rules.__contains__, reversed(self.rules)))

node._has_hover_style = any("hover" in rule.pseudo_classes for rule in rules)
node._has_focus_within = any(
"focus-within" in rule.pseudo_classes for rule in rules
all_pseudo_classes = set().union(*[rule.pseudo_classes for rule in rules])
node._has_hover_style = "hover" in all_pseudo_classes
node._has_focus_within = "focus-within" in all_pseudo_classes
node._has_order_style = not all_pseudo_classes.isdisjoint(
{"first-of-type", "last-of-type", "odd", "even"}
)

cache_key: tuple | None
if cache is not None:
cache_key: tuple | None = None

if cache is not None and all_pseudo_classes.isdisjoint(
self.EXPENSIVE_PSEUDO_CLASSES
):
cache_key = (
node._parent,
(
Expand All @@ -483,16 +496,14 @@ def apply(
else (node._id if f"#{node._id}" in rules_map else None)
),
node.classes,
node.pseudo_classes,
node._pseudo_classes_cache_key,
node._css_type_name,
)
cached_result: RulesMap | None = cache.get(cache_key)
if cached_result is not None:
self.replace_rules(node, cached_result, animate=animate)
self._process_component_classes(node)
return
else:
cache_key = None

_check_rule = self._check_rule
css_path_nodes = node.css_path_nodes
Expand Down Expand Up @@ -561,8 +572,7 @@ def apply(
rule_value = getattr(_DEFAULT_STYLES, initial_rule_name)
node_rules[initial_rule_name] = rule_value # type: ignore[literal-required]

if cache is not None:
assert cache_key is not None
if cache_key is not None:
cache[cache_key] = node_rules
self.replace_rules(node, node_rules, animate=animate)
self._process_component_classes(node)
Expand Down
34 changes: 28 additions & 6 deletions src/textual/dom.py
Original file line number Diff line number Diff line change
Expand Up @@ -180,6 +180,9 @@ class DOMNode(MessagePump):
# Names of potential computed reactives
_computes: ClassVar[frozenset[str]]

_PSEUDO_CLASSES: ClassVar[dict[str, Callable[[object], bool]]] = {}
"""Pseudo class checks."""

def __init__(
self,
*,
Expand Down Expand Up @@ -217,6 +220,8 @@ def __init__(
)
self._has_hover_style: bool = False
self._has_focus_within: bool = False
self._has_order_style: bool = False
"""The node has an ordered dependent pseudo-style (`:odd`, `:even`, `:first-of-type`, `:last-of-type`)"""
self._reactive_connect: (
dict[str, tuple[MessagePump, Reactive[object] | object]] | None
) = None
Expand Down Expand Up @@ -1228,13 +1233,18 @@ def on_dark_change(old_value:bool, new_value:bool) -> None:
"""
_watch(self, obj, attribute_name, callback, init=init)

def get_pseudo_classes(self) -> Iterable[str]:
"""Get any pseudo classes applicable to this Node, e.g. hover, focus.
def get_pseudo_classes(self) -> set[str]:
"""Pseudo classes for a widget.
Returns:
Iterable of strings, such as a generator.
Names of the pseudo classes.
"""
return ()

return {
name
for name, check_class in self._PSEUDO_CLASSES.items()
if check_class(self)
}

def reset_styles(self) -> None:
"""Reset styles back to their initial state."""
Expand Down Expand Up @@ -1658,7 +1668,10 @@ def has_pseudo_class(self, class_name: str) -> bool:
Returns:
`True` if the DOM node has the pseudo class, `False` if not.
"""
return class_name in self.get_pseudo_classes()
try:
return self._PSEUDO_CLASSES[class_name](self)
except KeyError:
return False

def has_pseudo_classes(self, class_names: set[str]) -> bool:
"""Check the node has all the given pseudo classes.
Expand All @@ -1669,7 +1682,16 @@ def has_pseudo_classes(self, class_names: set[str]) -> bool:
Returns:
`True` if all pseudo class names are present.
"""
return class_names.issubset(self.get_pseudo_classes())
PSEUDO_CLASSES = self._PSEUDO_CLASSES
try:
return all(PSEUDO_CLASSES[name](self) for name in class_names)
except KeyError:
return False

@property
def _pseudo_classes_cache_key(self) -> tuple[int, ...]:
"""A cache key used when updating a number of nodes from the stylesheet."""
return ()

def refresh(
self, *, repaint: bool = True, layout: bool = False, recompose: bool = False
Expand Down
Loading

0 comments on commit 2f97fbf

Please sign in to comment.