from __future__ import annotations import os from collections import defaultdict from itertools import chain from operator import itemgetter from pathlib import Path, PurePath from typing import Final, Iterable, NamedTuple, Sequence, cast import rich.repr from rich.console import Console, ConsoleOptions, RenderableType, RenderResult from rich.markup import render from rich.padding import Padding from rich.panel import Panel from rich.text import Text from textual.cache import LRUCache from textual.css.errors import StylesheetError from textual.css.match import _check_selectors from textual.css.model import RuleSet from textual.css.parse import parse from textual.css.styles import RulesMap, Styles from textual.css.tokenize import Token, tokenize_values from textual.css.tokenizer import TokenError from textual.css.types import CSSLocation, Specificity3, Specificity6 from textual.dom import DOMNode from textual.markup import parse_style from textual.style import Style from textual.widget import Widget _DEFAULT_STYLES = Styles() class StylesheetParseError(StylesheetError): """Raised when the stylesheet could not be parsed.""" def __init__(self, errors: StylesheetErrors) -> None: self.errors = errors def __rich__(self) -> RenderableType: return self.errors class StylesheetErrors: """A renderable for stylesheet errors.""" def __init__(self, rules: list[RuleSet]) -> None: self.rules = rules self.variables: dict[str, str] = {} @classmethod def _get_snippet(cls, code: str, line_no: int) -> RenderableType: from rich.syntax import Syntax syntax = Syntax( code, lexer="scss", theme="ansi_light", line_numbers=True, indent_guides=True, line_range=(max(0, line_no - 2), line_no + 2), highlight_lines={line_no}, ) return syntax def __rich_console__( self, console: Console, options: ConsoleOptions ) -> RenderResult: error_count = 0 errors = list( dict.fromkeys(chain.from_iterable(_rule.errors for _rule in self.rules)) ) for token, message in errors: error_count += 1 if token.referenced_by: line_idx, col_idx = token.referenced_by.location else: line_idx, col_idx = token.location line_no, col_no = line_idx + 1, col_idx + 1 display_path, widget_var = token.read_from if display_path: link_path = str(Path(display_path).absolute()) filename = Path(link_path).name else: link_path = "" filename = "" # If we have a widget/variable from where the CSS was read, then line/column # numbers are relative to the inline CSS and we'll display them next to the # widget/variable. # Otherwise, they're absolute positions in a TCSS file and we can show them # next to the file path. if widget_var: path_string = link_path or filename widget_string = f" in {widget_var}:{line_no}:{col_no}" else: path_string = f"{link_path or filename}:{line_no}:{col_no}" widget_string = "" title = Text.assemble( "Error at ", path_string, widget_string, style="bold red" ) yield "" yield Panel( self._get_snippet( token.referenced_by.code if token.referenced_by else token.code, line_no, ), title=title, title_align="left", border_style="red", ) yield Padding(message, pad=(0, 0, 1, 3)) yield "" yield render( f" [b][red]CSS parsing failed:[/] {error_count} error{'s' if error_count != 1 else ''}[/] found in stylesheet" ) class CssSource(NamedTuple): """Contains the CSS content and whether or not the CSS comes from user defined stylesheets vs widget-level stylesheets. Args: content: The CSS as a string. is_defaults: True if the CSS is default (i.e. that defined at the widget level). False if it's user CSS (which will override the defaults). tie_breaker: Specificity tie breaker. scope: Scope of CSS. """ content: str is_defaults: bool tie_breaker: int = 0 scope: str = "" @rich.repr.auto(angular=True) class Stylesheet: """A Stylesheet generated from Textual CSS.""" def __init__(self, *, variables: dict[str, str] | None = None) -> None: self._rules: list[RuleSet] = [] self._rules_map: dict[str, list[RuleSet]] | None = None self._variables = variables or {} self.__variable_tokens: dict[str, list[Token]] | None = None self.source: dict[CSSLocation, CssSource] = {} self._require_parse = False self._invalid_css: set[str] = set() self._parse_cache: LRUCache[tuple, list[RuleSet]] = LRUCache(64) self._style_parse_cache: LRUCache[str, Style] = LRUCache(1024 * 4) def __rich_repr__(self) -> rich.repr.Result: yield list(self.source.keys()) @property def _variable_tokens(self) -> dict[str, list[Token]]: if self.__variable_tokens is None: self.__variable_tokens = tokenize_values(self._variables) return self.__variable_tokens @property def rules(self) -> list[RuleSet]: """List of rule sets. Returns: List of rules sets for this stylesheet. """ if self._require_parse: self.parse() self._require_parse = False assert self._rules is not None return self._rules @property def rules_map(self) -> dict[str, list[RuleSet]]: """Structure that maps a selector on to a list of rules. Returns: Mapping of selector to rule sets. """ if self._rules_map is None: rules_map: dict[str, list[RuleSet]] = defaultdict(list) for rule in self.rules: for name in rule.selector_names: rules_map[name].append(rule) self._rules_map = dict(rules_map) return self._rules_map @property def css(self) -> str: """The equivalent TCSS for this stylesheet. Note that this may not produce the same content as the file(s) used to generate the stylesheet. """ return "\n\n".join(rule_set.css for rule_set in self.rules) def copy(self) -> Stylesheet: """Create a copy of this stylesheet. Returns: New stylesheet. """ stylesheet = Stylesheet(variables=self._variables.copy()) stylesheet.source = self.source.copy() return stylesheet def set_variables(self, variables: dict[str, str]) -> None: """Set CSS variables. Args: variables: A mapping of name to variable. """ self._variables = variables self.__variable_tokens = None self._invalid_css = set() self._parse_cache.clear() self._style_parse_cache.clear() def parse_style(self, style_text: str | Style) -> Style: """Parse a (visual) Style. Args: style_text: Visual style, such as "bold white 90% on $primary" Returns: New Style instance. """ if isinstance(style_text, Style): return style_text if style_text in self._style_parse_cache: return self._style_parse_cache[style_text] style = parse_style(style_text) self._style_parse_cache[style_text] = style return style def _parse_rules( self, css: str, read_from: CSSLocation, is_default_rules: bool = False, tie_breaker: int = 0, scope: str = "", ) -> list[RuleSet]: """Parse CSS and return rules. Args: css: String containing Textual CSS. read_from: Original CSS location. is_default_rules: True if the rules we're extracting are default (i.e. in Widget.DEFAULT_CSS) rules. False if they're from user defined CSS. scope: Scope of rules, or empty string for global scope. Raises: StylesheetError: If the CSS is invalid. Returns: List of RuleSets. """ cache_key = (css, read_from, is_default_rules, tie_breaker, scope) try: return self._parse_cache[cache_key] except KeyError: pass try: rules = list( parse( scope, css, read_from, variable_tokens=self._variable_tokens, is_default_rules=is_default_rules, tie_breaker=tie_breaker, ) ) except TokenError: raise except Exception as error: raise StylesheetError(f"failed to parse css; {error}") from None self._parse_cache[cache_key] = rules return rules def read(self, filename: str | PurePath) -> None: """Read Textual CSS file. Args: filename: Filename of CSS. Raises: StylesheetError: If the CSS could not be read. StylesheetParseError: If the CSS is invalid. """ filename = os.path.expanduser(filename) try: with open(filename, "rt", encoding="utf-8") as css_file: css = css_file.read() path = os.path.abspath(filename) except Exception: raise StylesheetError(f"unable to read CSS file {filename!r}") from None self.source[(str(path), "")] = CssSource(css, False, 0) self._require_parse = True def read_all(self, paths: Sequence[PurePath]) -> None: """Read multiple CSS files, in order. Args: paths: The paths of the CSS files to read, in order. Raises: StylesheetError: If the CSS could not be read. StylesheetParseError: If the CSS is invalid. """ for path in paths: self.read(path) def has_source(self, path: str, class_var: str = "") -> bool: """Check if the stylesheet has this CSS source already. Args: path: The file path of the source in question. class_var: The widget class variable we might be reading the CSS from. Returns: Whether the stylesheet is aware of this CSS source or not. """ return (path, class_var) in self.source def add_source( self, css: str, read_from: CSSLocation | None = None, is_default_css: bool = False, tie_breaker: int = 0, scope: str = "", ) -> None: """Parse CSS from a string. Args: css: String with CSS source. read_from: The original source location of the CSS. path: The path of the source if a file, or some other identifier. is_default_css: True if the CSS is defined in the Widget, False if the CSS is defined in a user stylesheet. tie_breaker: Integer representing the priority of this source. scope: CSS type name to limit scope or empty string for no scope. Raises: StylesheetError: If the CSS could not be read. StylesheetParseError: If the CSS is invalid. """ if read_from is None: read_from = ("", str(hash(css))) if read_from in self.source and self.source[read_from].content == css: # Location already in source and CSS is identical. content, is_defaults, source_tie_breaker, scope = self.source[read_from] if source_tie_breaker > tie_breaker: self.source[read_from] = CssSource( content, is_defaults, tie_breaker, scope ) return self.source[read_from] = CssSource(css, is_default_css, tie_breaker, scope) self._require_parse = True self._rules_map = None def parse(self) -> None: """Parse the source in the stylesheet. Raises: StylesheetParseError: If there are any CSS related errors. """ rules: list[RuleSet] = [] add_rules = rules.extend for read_from, ( css, is_default_rules, tie_breaker, scope, ) in self.source.items(): if css in self._invalid_css: continue try: css_rules = self._parse_rules( css, read_from=read_from, is_default_rules=is_default_rules, tie_breaker=tie_breaker, scope=scope, ) except Exception: self._invalid_css.add(css) raise if any(rule.errors for rule in css_rules): error_renderable = StylesheetErrors(css_rules) self._invalid_css.add(css) raise StylesheetParseError(error_renderable) add_rules(css_rules) self._rules = rules self._require_parse = False self._rules_map = None def reparse(self) -> None: """Re-parse source, applying new variables. Raises: StylesheetError: If the CSS could not be read. StylesheetParseError: If the CSS is invalid. """ # Do this in a fresh Stylesheet so if there are errors we don't break self. stylesheet = Stylesheet(variables=self._variables) for read_from, (css, is_defaults, tie_breaker, scope) in self.source.items(): stylesheet.add_source( css, read_from=read_from, is_default_css=is_defaults, tie_breaker=tie_breaker, scope=scope, ) try: stylesheet.parse() except Exception: # If we don't update self's invalid CSS, we might end up reparsing this CSS # before Textual quits application mode. # See https://github.com/Textualize/textual/issues/3581. self._invalid_css.update(stylesheet._invalid_css) raise else: self._rules = stylesheet.rules self._rules_map = None self.source = stylesheet.source self._require_parse = False @classmethod def _check_rule( cls, rule_set: RuleSet, css_path_nodes: list[DOMNode] ) -> Iterable[Specificity3]: """Check a rule set, return specificity of applicable rules. Args: rule_set: A rule set. css_path_nodes: A list of the nodes from the App to the node being checked. Yields: Specificity of any matching selectors. """ for selector_set in rule_set.selector_set: if _check_selectors(selector_set.selectors, css_path_nodes): yield selector_set.specificity # pseudo classes which iterate over multiple nodes # These shouldn't be used in a cache key _EXCLUDE_PSEUDO_CLASSES_FROM_CACHE: Final[set[str]] = { "first-of-type", "last-of-type", "first-child", "last-child", "odd", "even", "focus-within", "empty", } def apply( self, node: DOMNode, *, animate: bool = False, cache: dict[tuple, RulesMap] | None = None, ) -> None: """Apply the stylesheet to a DOM node. Args: node: The `DOMNode` to apply the stylesheet to. Applies the styles defined in this `Stylesheet` to the node. If the same rule is defined multiple times for the node (e.g. multiple classes modifying the same CSS property), then only the most specific rule will be applied. animate: Animate changed rules. cache: An optional cache when applying a group of nodes. """ # Dictionary of rule attribute names e.g. "text_background" to list of tuples. # The tuples contain the rule specificity, and the value for that rule. # We can use this to determine, for a given rule, whether we should apply it # or not by examining the specificity. If we have two rules for the # same attribute, then we can choose the most specific rule and use that. rule_attributes: defaultdict[str, list[tuple[Specificity6, object]]] rule_attributes = defaultdict(list) rules_map = self.rules_map # Discard rules which are not applicable early limit_rules = { rule for name in rules_map.keys() & node._selector_names for rule in rules_map[name] } rules = list(filter(limit_rules.__contains__, reversed(self.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", "first-child", "last-child", "empty"} ) node._has_odd_or_even = ( "odd" in all_pseudo_classes or "even" in all_pseudo_classes ) cache_key: tuple | None = None if cache is not None and all_pseudo_classes.isdisjoint( self._EXCLUDE_PSEUDO_CLASSES_FROM_CACHE ): cache_key = ( node._parent, ( None if node._id is None else (node._id if f"#{node._id}" in rules_map else None) ), node.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 _check_rule = self._check_rule css_path_nodes = node.css_path_nodes # Rules that may be set to the special value `initial` initial: set[str] = set() # Rules in DEFAULT_CSS set to the special value `initial` initial_defaults: set[str] = set() for rule in rules: is_default_rules = rule.is_default_rules tie_breaker = rule.tie_breaker for base_specificity in _check_rule(rule, css_path_nodes): for key, rule_specificity, value in rule.styles.extract_rules( base_specificity, is_default_rules, tie_breaker ): if value is None: if is_default_rules: initial_defaults.add(key) else: initial.add(key) rule_attributes[key].append((rule_specificity, value)) if rule_attributes: # For each rule declared for this node, keep only the most specific one get_first_item = itemgetter(0) node_rules: RulesMap = cast( RulesMap, { name: max(specificity_rules, key=get_first_item)[1] for name, specificity_rules in rule_attributes.items() }, ) # Set initial values for initial_rule_name in initial: # Rules with a value of None should be set to the default value if node_rules[initial_rule_name] is None: # type: ignore[literal-required] # Exclude non default values # rule[0] is the specificity, rule[0][0] is 0 for default rules default_rules = [ rule for rule in rule_attributes[initial_rule_name] if not rule[0][0] ] if default_rules: # There is a default value new_value = max(default_rules, key=get_first_item)[1] node_rules[initial_rule_name] = new_value # type: ignore[literal-required] else: # No default value initial_defaults.add(initial_rule_name) # Rules in DEFAULT_CSS set to initial for initial_rule_name in initial_defaults: if node_rules[initial_rule_name] is None: # type: ignore[literal-required] default_rules = [ rule for rule in rule_attributes[initial_rule_name] if rule[0][0] ] if default_rules: # There is a default value rule_value = max(default_rules, key=get_first_item)[1] else: rule_value = getattr(_DEFAULT_STYLES, initial_rule_name) node_rules[initial_rule_name] = rule_value # type: ignore[literal-required] if cache_key is not None: cache[cache_key] = node_rules self.replace_rules(node, node_rules, animate=animate) self._process_component_classes(node) def _process_component_classes(self, node: DOMNode) -> None: """Process component classes for the given node. Args: node: A DOM Node. """ component_classes = node._get_component_classes() if component_classes: # Create virtual nodes that exist to extract styles refresh_node = False old_component_styles = node._component_styles.copy() node._component_styles.clear() for component in sorted(component_classes): virtual_node = DOMNode(classes=component) virtual_node._attach(node) self.apply(virtual_node, animate=False) if ( not refresh_node and old_component_styles.get(component) != virtual_node.styles ): # If the styles have changed we want to refresh the node refresh_node = True node._component_styles[component] = virtual_node.styles if refresh_node: node.refresh() @classmethod def replace_rules( cls, node: DOMNode, rules: RulesMap, animate: bool = False ) -> None: """Replace style rules on a node, animating as required. Args: node: A DOM node. rules: Mapping of rules. animate: Enable animation. """ # Alias styles and base styles styles = node.styles base_styles = styles.base # Styles currently used on new rules modified_rule_keys = base_styles._rules.keys() | rules.keys() if animate: new_styles = Styles(node, rules) if new_styles == base_styles: # Nothing to animate, return early return current_render_rules = styles.get_render_rules() is_animatable = styles.is_animatable get_current_render_rule = current_render_rules.get new_render_rules = new_styles.get_render_rules() get_new_render_rule = new_render_rules.get animator = node.app.animator base = node.styles.base 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) # Check if this can / should be animated. It doesn't suffice to check # if the current and target values are different because a previous # animation may have been scheduled but may have not started yet. if is_animatable(key) and ( new_render_value != old_render_value or animator.is_being_animated(base, key) ): transition = new_styles._get_transition(key) if transition is not None: duration, easing, delay = transition animator.animate( base, key, new_render_value, final_value=new_value, duration=duration, delay=delay, easing=easing, ) continue # Default is to set value (if new_value is None, rule will be removed) setattr(base_styles, key, new_value) else: # Not animated, so we apply the rules directly get_rule = rules.get for key in modified_rule_keys: setattr(base_styles, key, get_rule(key)) node.notify_style_update() def update(self, root: DOMNode, animate: bool = False) -> None: """Update styles on node and its children. Args: root: Root note to update. animate: Enable CSS animation. """ self.update_nodes(root.walk_children(with_self=True), animate=animate) def update_nodes(self, nodes: Iterable[DOMNode], animate: bool = False) -> None: """Update styles for nodes. Args: nodes: Nodes to update. animate: Enable CSS animation. """ cache: dict[tuple, RulesMap] = {} apply = self.apply for node in nodes: apply(node, animate=animate, cache=cache) if isinstance(node, Widget) and node.is_scrollable: if node.show_vertical_scrollbar: apply(node.vertical_scrollbar, cache=cache) if node.show_horizontal_scrollbar: apply(node.horizontal_scrollbar, cache=cache) if node.show_horizontal_scrollbar and node.show_vertical_scrollbar: apply(node.scrollbar_corner, cache=cache)