diff --git a/src/textual/_compositor.py b/src/textual/_compositor.py index 0c9ab27530c..38aaf2cf836 100644 --- a/src/textual/_compositor.py +++ b/src/textual/_compositor.py @@ -46,6 +46,7 @@ from textual._spatial_map import SpatialMap from textual.geometry import NULL_OFFSET, NULL_SPACING, Offset, Region, Size, Spacing from textual.map_geometry import MapGeometry +from textual.layout import WidgetPlacement from textual.strip import Strip, StripRenderable from textual.widget import Widget @@ -1179,7 +1180,6 @@ def arrange_widget( # Original arrangement ordinals survive spatial admission. # Do not rebuild ranks for every offscreen child on scroll. - first_layer_order = layer_order - len(arrange_result.placements) + 1 arranged_widgets = arrange_result.widgets widgets.update(arranged_widgets) @@ -1208,16 +1208,13 @@ def arrange_widget( placements = arrange_result.placements total_region = total_region.union(arrange_result.total_region) - # An offset added to all placements - placement_offset = container_region.offset - placement_scroll_offset = placement_offset - widget.scroll_offset + placement_scroll_offset = container_region.offset - widget.scroll_offset screen_coordinates.update(placement.widget for _, placement in placements if placement.widget.uses_screen_coordinates) - placements = [ - (ordinal, placement.process_offset(size.region, placement_scroll_offset)) - for ordinal, placement in placements - ] + placements = WidgetPlacement.process_offsets( + placements, size.region, placement_scroll_offset, + ) if type(widget).layers is Widget.layers: layers_to_index = default_layers if inherited_layers is None else inherited_layers @@ -1228,8 +1225,6 @@ def arrange_widget( for index, layer_name in enumerate(widget.layers) } - get_layer_index = layers_to_index.get - if widget._cover_widget is not None: store_geometry(widget._cover_widget, _MapGeometry( region.shrink(widget.styles.gutter), @@ -1242,38 +1237,20 @@ def arrange_widget( ), clip) # Add all the widgets - for ordinal, ( - sub_region, - sub_region_offset, - _, - sub_widget, - z, - fixed, - overlay, - absolute, - ) in reversed(placements): - layer_index = get_layer_index(sub_widget.layer, 0) - # Combine regions with children to calculate the "virtual size" - if fixed: - widget_region = ( - sub_region + sub_region_offset + placement_offset - ) - else: - widget_region = ( - sub_region + sub_region_offset + placement_scroll_offset - ) - - child_layer_order = first_layer_order + ordinal - widget_order = order + ((layer_index, z, child_layer_order),) - + for (sub_widget, sub_region, widget_region, widget_order, + child_layer_order, child_clip) in self._place_children( + placements, len(arrange_result.placements), + container_region, widget.scroll_offset, + order, layer_order, sub_clip, no_clip, layers_to_index, + ): if widget._cover_widget is None: add_widget( # noqa: F821 -- closure cleared only after traversal sub_widget, sub_region, widget_region, - ((1, 0, 0),) if overlay else widget_order, + widget_order, child_layer_order, - no_clip if overlay else sub_clip, + child_clip, visible, arrange_result.scroll_spacing, complete, @@ -1478,6 +1455,28 @@ def add_widget(widget, virtual_region, region, order, layer_order, clip, visible widgets -= invisible_widgets return cast(CompositorMap, map), widgets + @staticmethod + def _place_children(placements, placement_count, container_region, scroll_offset, + order, layer_order, sub_clip, no_clip, layers): + """Native placement projection, shared by scenes and detached paint. + + Arrangement owns ordinals and local boxes. This projection owns fixed, + absolute, overlay, layer and clip meaning; detached documents don't + reconstruct those answers with a second placement algorithm. + """ + first_layer_order = layer_order - placement_count + 1 + placement_offset = container_region.offset + placement_scroll_offset = placement_offset - scroll_offset + for ordinal, (region, offset, _, child, z, fixed, overlay, _) in reversed(placements): + child_region = region + offset + ( + placement_offset if fixed else placement_scroll_offset + ) + child_layer_order = first_layer_order + ordinal + child_order = order + ((layers.get(child.layer, 0), z, child_layer_order),) + yield (child, region, child_region, + ((1, 0, 0),) if overlay else child_order, child_layer_order, + no_clip if overlay else sub_clip) + @property def layers(self) -> list[tuple[Widget, MapGeometry]]: """Get widgets and geometry in layer order.""" @@ -1590,14 +1589,7 @@ def get_style_at(self, x: int, y: int) -> Style: if not lines: return Style.null() - end = 0 - - for segment in lines[0]: - end += segment.cell_length - if x < end: - return segment.style or Style.null() - - return Style.null() + return lines[0].get_style_at(x) def get_widget_and_offset_at( self, x: int, y: int @@ -1631,43 +1623,7 @@ def get_widget_and_offset_at( visible_screen_stack.set(widget.app._background_screens) line = widget.render_line(y) - end = 0 - start = 0 - offset_y: int | None = None - offset_x = 0 - offset_x2 = 0 - - from rich.cells import get_character_cell_size - - offset: Offset | None = None - for segment in line: - end += segment.cell_length - style = segment.style - if style is not None and style._meta is not None: - meta = style.meta - if "offset" in meta: - offset_x, offset_y = meta["offset"] - if offset_y is None: - continue - offset_x2 = offset_x + len(segment.text) - - if x < end and x >= start: - segment_cell_length = 0 - cell_cut = x - start - segment_offset = 0 - for character in segment.text: - if segment_cell_length >= cell_cut: - break - segment_cell_length += get_character_cell_size(character) - segment_offset += 1 - - offset = Offset(offset_x + segment_offset, offset_y) - break - start = end - - if offset is None and offset_y is not None: - offset = Offset(offset_x2, offset_y) - return widget, offset + return widget, line.get_content_offset(x) def find_widget(self, widget: Widget) -> MapGeometry: """Get information regarding the relative position of a widget in the Compositor. diff --git a/src/textual/_extrema.py b/src/textual/_extrema.py index d8e3d3bf2e3..b3366dde49e 100644 --- a/src/textual/_extrema.py +++ b/src/textual/_extrema.py @@ -1,10 +1,13 @@ from __future__ import annotations from fractions import Fraction -from typing import NamedTuple +from typing import TYPE_CHECKING, NamedTuple from textual.geometry import Size +if TYPE_CHECKING: + from textual.css.styles import StylesBase + class Extrema(NamedTuple): """Specifies minimum and maximum dimensions.""" @@ -62,3 +65,46 @@ def apply_dimensions(self, width: int, height: int) -> Size: int(self.apply_width(Fraction(width))), int(self.apply_height(Fraction(height))), ) + + @classmethod + def resolve( + cls, styles: StylesBase, container: Size, viewport: Size, + width_fraction: Fraction, height_fraction: Fraction, + ) -> Extrema: + """Resolve native min/max rules independently of scene custody.""" + min_width: Fraction | None = None + max_width: Fraction | None = None + min_height: Fraction | None = None + max_height: Fraction | None = None + + container -= styles.margin.totals + if styles.box_sizing == "border-box": + gutter_width, gutter_height = styles.gutter.totals + else: + gutter_width = gutter_height = 0 + + if styles.min_width is not None: + min_width = ( + styles.min_width.resolve(container, viewport, width_fraction) + - gutter_width + ) + + if styles.max_width is not None: + max_width = ( + styles.max_width.resolve(container, viewport, width_fraction) + - gutter_width + ) + if styles.min_height is not None: + min_height = ( + styles.min_height.resolve(container, viewport, height_fraction) + - gutter_height + ) + + if styles.max_height is not None: + max_height = ( + styles.max_height.resolve(container, viewport, height_fraction) + - gutter_height + ) + + extrema = cls(min_width, max_width, min_height, max_height) + return extrema diff --git a/src/textual/_styles_cache.py b/src/textual/_styles_cache.py index 5d47b71aa98..3d4a666d89f 100644 --- a/src/textual/_styles_cache.py +++ b/src/textual/_styles_cache.py @@ -11,7 +11,6 @@ from textual import log from textual._ansi_theme import DEFAULT_TERMINAL_THEME from textual._border import get_box, render_border_label, render_row -from textual._context import active_app from textual._opacity import _apply_opacity from textual._segment_tools import apply_hatch, line_pad, line_trim, make_blank from textual.color import TRANSPARENT, Color @@ -347,12 +346,6 @@ def post(segments: Iterable[Segment]) -> Iterable[Segment]: Returns: New list of segments """ - try: - app = active_app.get() - ansi_theme = app.ansi_theme - except LookupError: - ansi_theme = DEFAULT_TERMINAL_THEME - if styles.tint.a: segments = Tint.process_segments( segments, styles.tint, ansi_theme, background diff --git a/src/textual/box_model.py b/src/textual/box_model.py index 609a2cea878..eb0b5beecf3 100644 --- a/src/textual/box_model.py +++ b/src/textual/box_model.py @@ -1,9 +1,15 @@ from __future__ import annotations from fractions import Fraction -from typing import NamedTuple +from typing import TYPE_CHECKING, NamedTuple -from textual.geometry import Spacing +from textual.geometry import Size, Spacing +from textual.css.scalar import Scalar + +if TYPE_CHECKING: + from textual.widget import Widget + from textual.document._paint import DocumentNode + from textual._extrema import Extrema class BoxModel(NamedTuple): @@ -13,3 +19,130 @@ class BoxModel(NamedTuple): width: Fraction height: Fraction margin: Spacing # Additional margin + + @classmethod + def resolve( + cls, + node: Widget | DocumentNode, + container: Size, + viewport: Size, + width_fraction: Fraction, + height_fraction: Fraction, + constrain_width: bool = False, + greedy: bool = True, + ) -> tuple[BoxModel, Extrema]: + """Resolve native CSS dimensions from intrinsic measurements. + + Scene custody and measurement reuse belong to the caller. The actual + border-box, auto dimensions, fraction and extrema algorithm is shared + by mounted widgets and detached native document nodes. + """ + styles = node.styles + is_border_box = styles.box_sizing == "border-box" + gutter = styles.gutter # Padding plus border + margin = styles.margin + + styles_width = styles.width + if not greedy and styles_width is not None and styles_width.is_fraction: + styles_width = Scalar.parse("auto") + is_auto_width = styles_width and styles_width.is_auto + is_auto_height = styles.height and styles.height.is_auto + + # Container minus padding and border + content_container = container - gutter.totals + + extrema = node._extrema = node._resolve_extrema( + container, viewport, width_fraction, height_fraction + ) + min_width, max_width, min_height, max_height = extrema + + if styles_width is None: + # No width specified, fill available space + content_width = Fraction(content_container.width - margin.width) + elif is_auto_width: + # When width is auto, we want enough space to always fit the content + content_width = Fraction( + node.get_content_width(content_container - margin.totals, viewport) + ) + if ( + styles.overflow_x == "auto" and styles.scrollbar_gutter == "stable" + ) or node.show_vertical_scrollbar: + content_width += styles.scrollbar_size_vertical + if ( + content_width < content_container.width + and node._has_relative_children_width + ): + content_width = Fraction(content_container.width) + else: + # An explicit width + content_width = styles_width.resolve( + container - margin.totals, viewport, width_fraction + ) + if is_border_box: + content_width -= gutter.width + + if min_width is not None: + # Restrict to minimum width, if set + content_width = max(content_width, min_width, Fraction(0)) + + if max_width is not None and not ( + container.width == 0 + and not styles.max_width.is_cells + and node._parent is not None + and node._parent.styles.is_auto_width + ): + # Restrict to maximum width, if set + content_width = min(content_width, max_width) + + content_width = max(Fraction(0), content_width) + + if constrain_width: + content_width = min(Fraction(container.width - gutter.width), content_width) + + if styles.height is None: + # No height specified, fill the available space + content_height = Fraction(content_container.height - margin.height) + elif is_auto_height: + # Calculate dimensions based on content + content_height = Fraction( + node.get_content_height( + content_container - margin.totals, + viewport, + int(content_width), + ) + ) + if ( + styles.overflow_y == "auto" and styles.scrollbar_gutter == "stable" + ) or node.show_horizontal_scrollbar: + content_height += styles.scrollbar_size_horizontal + if ( + content_height < content_container.height + and node._has_relative_children_height + ): + content_height = Fraction(content_container.height) + else: + styles_height = styles.height + # Explicit height set + content_height = styles_height.resolve( + container - margin.totals, viewport, height_fraction + ) + if is_border_box: + content_height -= gutter.height + + if min_height is not None: + # Restrict to minimum height, if set + content_height = max(content_height, min_height, Fraction(0)) + + if max_height is not None and not ( + container.height == 0 + and not styles.max_height.is_cells + and node._parent is not None + and node._parent.styles.is_auto_height + ): + content_height = min(content_height, max_height) + + content_height = max(Fraction(0), content_height) + model = cls( + content_width + gutter.width, content_height + gutter.height, margin + ) + return model, extrema diff --git a/src/textual/css/model.py b/src/textual/css/model.py index 7b66836fe19..3317717c39f 100644 --- a/src/textual/css/model.py +++ b/src/textual/css/model.py @@ -69,7 +69,7 @@ def _check_type(name: str, node: DOMNode) -> bool: Returns: `True` if the selector matches. """ - return name in node._css_type_names + return name in node.css_type_names def _check_class(name: str, node: DOMNode) -> bool: diff --git a/src/textual/css/styles.py b/src/textual/css/styles.py index e343945ec5e..cd2e2c97855 100644 --- a/src/textual/css/styles.py +++ b/src/textual/css/styles.py @@ -1,6 +1,7 @@ from __future__ import annotations import weakref +from copy import deepcopy from contextlib import AbstractContextManager, contextmanager from dataclasses import dataclass, field from functools import partial @@ -1009,6 +1010,13 @@ def clear_rule(self, rule_name: str) -> bool: def get_rules(self) -> RulesMap: return self._rules.copy() + def acquire_document_rules(self) -> RulesMap: + """Acquire style values without borrowing scene layout placements.""" + rules = self.get_rules() + layout = rules.get("layout") + memo = {} if layout is None else {id(layout): layout.acquire_document()} + return deepcopy(rules, memo) + def set_rule(self, rule: str, value: object | None) -> bool: """Set a rule. diff --git a/src/textual/css/stylesheet.py b/src/textual/css/stylesheet.py index 7e38b99967b..d5ac777a685 100644 --- a/src/textual/css/stylesheet.py +++ b/src/textual/css/stylesheet.py @@ -271,7 +271,7 @@ def parse_style(self, style_text: str | Style) -> Style: return style_text if style_text in self._style_parse_cache: return self._style_parse_cache[style_text] - style = parse_style(style_text) + style = parse_style(style_text, self._variables) self._style_parse_cache[style_text] = style return style @@ -550,7 +550,7 @@ def _css_path_key( ( node._id if node._id in self._ids_in_rules else None, node.classes & relevant_classes, - node._css_type_name, + node.css_type_name, pseudo_key, node.name, ) @@ -651,7 +651,7 @@ def apply( ), node.classes, node._pseudo_classes_cache_key, - node._css_type_name, + node.css_type_name, ) cached_result: RulesMap | None = cache.get(cache_key) if cached_result is not None: @@ -809,7 +809,7 @@ def _process_component_classes(self, node: DOMNode) -> None: _ComponentStyles | None, node._component_styles.get(component) ) if component_styles is None: - virtual_node = DOMNode(classes=component) + virtual_node = node._make_component_node(component) virtual_node._attach(node) component_styles = _ComponentStyles(virtual_node) node._component_styles[component] = component_styles diff --git a/src/textual/document/_markdown.py b/src/textual/document/_markdown.py new file mode 100644 index 00000000000..53a65023026 --- /dev/null +++ b/src/textual/document/_markdown.py @@ -0,0 +1,424 @@ +"""Acquired Markdown source using the native block, CSS and paint owners.""" + +from __future__ import annotations + +from copy import deepcopy +from collections.abc import Sequence +from dataclasses import dataclass, replace +from inspect import getattr_static +from uuid import UUID, uuid4 + +from markdown_it.token import Token + +from textual.content import Content +from textual.containers import Horizontal, Vertical +from textual.document._paint import DocumentNode, DocumentPresentation +from textual.widgets._label import Label +from textual.widgets._markdown import ( + Markdown, + MarkdownBlock, + MarkdownBullet, + MarkdownFence, + MarkdownList, + MarkdownTableContent, + MarkdownTableCellContents, +) + + +class MarkdownSourceBlock: + """One parsed source identity, separate from a mounted block's custody. + + The original grammar owns staging children. The declaration owns their + native composition; preparation consumes the completed source cohort. + """ + + def __init__(self, document, declaration, token, source_index, *args): + self.document = document + self.declaration = declaration + self._token = token + self._arguments = args + self._blocks = [] + self._inline_token = None + self._inline_content = None + self._converter = None + self.id = None + self.source_index = source_index + self.source_range = tuple(token.map) if token.map is not None else (0, 0) + self.placement = None + self.bullet = args[0] if args else "" + self._producer = document.producers[declaration] + self.code = ( + token.content.rstrip() if issubclass(declaration, MarkdownFence) else None + ) + self._highlight = None + if self.code is not None: + if document.fence_content is not None: + self._highlight = document.fence_content( + self.code, + token.info, + document.presentation.native_ansi, + document.dark, + ) + self._highlighter = document.highlighters[declaration] + + def _is_block_type(self, declaration): + return issubclass(self.declaration, declaration) + + def heading_id(self): + return self.declaration.make_heading_id( + self._content, f"{self.document.heading_namespace}-{self.source_index}" + ) + + def table_of_contents_entry(self): + return self.declaration.make_heading_entry(self._content, self.id) + + def build_from_token(self, token): + self._inline_token = token + self._converter = self.document.converters[self.declaration] + if ( + self.document.inline_content is not None + and self._converter is self.document.native_converter + ): + self._inline_content = self.document.inline_content(token) + else: + self._inline_content = self._converter(token) + + @property + def _content(self): + if self._inline_content is not None: + return self._inline_content + return Content() + + def prepare(self): + return self._producer(self) + + def source_text(self) -> str: + """Read this original grammar member's bounded source contribution.""" + return MarkdownBlock.source_text(self.document.source, self.source_range) + + def node(self, *, children=(), **kwargs): + node = DocumentNode( + self.declaration, + self._content, + children=children, + name=self._token.type, + id=self.id, + classes=f"level-{self._token.level}", + expand=True, + pseudo_classes=self.document.child_pseudo_classes, + source_range=self.source_range, + source_index=self.source_index, + **kwargs, + ) + return node + + def list_node(self, rows): + pseudo = self.document.child_pseudo_classes + children = list( + MarkdownList.compose_rows( + rows, + lambda symbol: DocumentNode( + MarkdownBullet, + Content(symbol), + pseudo_classes=pseudo, + selection=MarkdownBullet.selection_text, + ), + lambda blocks: DocumentNode( + Vertical, + children=[block.prepare() for block in blocks], + pseudo_classes=pseudo, + ), + lambda *children: DocumentNode( + Horizontal, children=children, pseudo_classes=pseudo + ), + ) + ) + return self.node(children=children) + + def table_node(self, headers, rows): + cells = [ + DocumentNode( + MarkdownTableCellContents, + content, + classes=classes, + name=name, + pseudo_classes=self.document.child_pseudo_classes, + ) + for content, classes, name, _, _ in MarkdownTableContent.cells( + headers, rows + ) + ] + + def prepare_grid(node, layout): + MarkdownTableContent.prepare_grid(layout, node.parent.styles.is_auto_width) + + table = DocumentNode( + MarkdownTableContent, + children=cells, + shrink=True, + pseudo_classes=self.document.child_pseudo_classes, + inline_rules={"grid_size_columns": len(headers)}, + pre_layout=prepare_grid, + ) + return self.node(children=(table,)) + + def fence_node(self, *, label_type: type[Label] = Label): + """Compose the declared label using the original fence content owner. + + A prepared scene may declare a different label capability. Its CSS + identity belongs to that declaration; the fence still owns highlighting + and code-label attributes. + """ + content = self.highlight( + self.document.presentation.native_ansi, self.document.dark + ) + label = MarkdownFence.code_label( + content, + lambda content, **attributes: DocumentNode( + label_type, + content, + pseudo_classes=self.document.child_pseudo_classes, + **attributes, + ), + ) + return self.node(children=(label,), auto_links=False) + + def highlight(self, ansi: bool, dark: bool) -> Content: + """Use this acquired fence supplier for detached and scene lifetimes.""" + if ( + ansi == self.document.presentation.native_ansi + and dark == self.document.dark + and self._highlight is not None + ): + return self._highlight + content = ( + None if self.document.fence_content is None else + self.document.fence_content(self.code, self._token.info, ansi, dark) + ) + return ( + self._highlighter(self.code, self._token.info, ansi=ansi, dark=dark) + if content is None else content + ) + + +@dataclass(frozen=True, eq=False) +class MarkdownDocument: + """Frozen source and presentation, reusable across scene eviction. + + A fresh preparation uses native source nodes and native CSS/layout/paint. + It retains no widget, App, message task or preceding scene revision. + """ + + source: str + tokens: Sequence[Token] + declaration: type[Markdown] + child_pseudo_classes: frozenset[str] + dark: bool + process_layout: object + block_classes: dict + producers: dict + converters: dict + highlighters: dict + native_converter: object + inline_content: object + fence_content: object + unhandled: object + root_producer: object + bullets: tuple[str, ...] + presentation: DocumentPresentation + _source_key: tuple + _source_identity: UUID + + @property + def heading_namespace(self): + return self._source_identity.hex + + @classmethod + def acquire(cls, owner: Markdown, source: str, tokens: Sequence[Token]): + if not isinstance(tokens, Sequence): + raise TypeError( + "Document preparation requires an acquired materialized token resource" + ) + # The parser's acquired cohort is owned by the request. Copies and + # construction belong to prepare(), on the original preparation worker. + declaration = type(owner) + root_pseudo_classes = frozenset(owner.get_pseudo_classes()) + child_pseudo_classes = frozenset( + { + "blur", + "disabled" if owner.is_disabled else "enabled", + *( + name + for name in ("dark", "light", "inline", "ansi", "nocolor") + if name in root_pseudo_classes + ), + } + ) + dark = owner.app.current_theme.dark + process_layout = owner.get_document_process_layout() + block_classes = owner.acquire_document_blocks() + producers = { + block_type: block_type.document_node + for block_type in block_classes.values() + } + converters = {} + highlighters = {} + inline_content = owner.acquire_document_content() + fence_content = owner.acquire_document_fences() + unhandled = owner.acquire_document_unhandled() + for block_type in block_classes.values(): + converter = getattr_static(block_type, "_token_to_content") + if not isinstance(converter, staticmethod): + raise TypeError( + f"{block_type.__name__} needs a detached content producer" + ) + converters[block_type] = converter.__func__ + if issubclass(block_type, MarkdownFence): + highlighter = block_type.highlight + if ( + fence_content is None + and highlighter.__func__ is not MarkdownFence.highlight.__func__ + ): + raise TypeError( + f"{block_type.__name__} needs acquired document highlighting" + ) + highlighters[block_type] = highlighter + bullets = tuple(owner.BULLETS) + declarations = { + declaration, + *( + support + for block_type in block_classes.values() + for support in block_type.document_declarations() + ), + } + presentation = DocumentPresentation.acquire(owner, declarations) + root_producer = declaration.document_root + source_key = ( + source, + root_producer, + tuple( + ( + name, + block_type, + producers[block_type], + converters[block_type], + highlighters.get(block_type), + ) + for name, block_type in sorted(block_classes.items()) + ), + bullets, + process_layout, + cls._supplier_identity(inline_content), + cls._supplier_identity(fence_content), + cls._supplier_identity(unhandled), + ) + return cls( + source, + tokens, + declaration, + child_pseudo_classes, + dark, + process_layout, + block_classes, + producers, + converters, + highlighters, + MarkdownBlock._token_to_content, + inline_content, + fence_content, + unhandled, + root_producer, + bullets, + presentation, + source_key, + uuid4(), + ) + + @staticmethod + def _supplier_identity(supplier): + if supplier is None: + return None + if hasattr(supplier, "__self__"): + from textual.widget import Widget + + if isinstance(supplier.__self__, Widget): + raise TypeError("Detached content supplier cannot borrow a live widget") + return id(supplier.__self__), supplier.__func__ + return id(supplier) + + def same_source(self, other): + # Acquisition owns this identity; it survives worker serialization. + # Equal text or a new resolved cohort cannot replace this source. + return ( + self._source_identity == other._source_identity + and self._source_key == other._source_key + ) + + def with_presentation(self, owner: Markdown) -> MarkdownDocument: + """Reacquire only current declarations/style, retaining this exact source. + + The owner must retain the delivered tokens/content without mutation. + New source or newly resolved links require a new acquisition. Neither + this method nor acquire() copies or traverses the token resource. + """ + current = type(self).acquire(owner, self.source, self.tokens) + return replace(current, _source_identity=self._source_identity) + + def root_node(self, children): + return DocumentNode( + self.declaration, + children=children, + name=self.presentation.root.name, + id=self.presentation.root.id, + classes=self.presentation.root.classes, + pseudo_classes=self.presentation.root.pseudo_classes, + inline_rules=self.presentation.root.inline_rules, + process_layout=self.process_layout, + ) + + def prepare( + self, width: int, *, root_selection=None, selections=None, + selection_style=None, selecting=False, + ): + # Mutable parsing/construction state is local to this preparation, not + # retained alongside the source and not shared by width workers. + blocks = [] + tokens = deepcopy(tuple(self.tokens)) + + def create(name, token, *args): + block = MarkdownSourceBlock( + self, self.block_classes[name], token, len(blocks), *args + ) + blocks.append(block) + return block + + roots = tuple( + block + for block in Markdown._build_blocks( + tokens, + create, + ( + (lambda token: None) + if self.unhandled is None + else lambda token: self.unhandled(token, create) + ), + self.bullets, + ) + if block is not None + ) + blocks = tuple(blocks) + for block in blocks: + block._blocks = tuple(block._blocks) + root = self.root_producer(self, [block.prepare() for block in roots]) + return self.presentation.prepare( + root, + width, + document=self, + roots=roots, + headings=tuple(Markdown.heading_entries(roots)), + root_selection=root_selection, + selections=selections, + selection_style=selection_style, + selecting=selecting, + ) diff --git a/src/textual/document/_paint.py b/src/textual/document/_paint.py new file mode 100644 index 00000000000..4227a0a552a --- /dev/null +++ b/src/textual/document/_paint.py @@ -0,0 +1,1097 @@ +"""Native document geometry and paint, without scene or message-pump admission.""" + +from __future__ import annotations + +from copy import deepcopy +from dataclasses import dataclass, replace +from fractions import Fraction +from typing import TYPE_CHECKING, Callable, Iterable +from weakref import ref + +from rich.style import Style as RichStyle +from rich.palette import Palette + +from textual._arrange import arrange +from textual._compositor import Compositor, RootSceneClip +from textual._extrema import Extrema +from textual._styles_cache import StylesCache +from textual.box_model import BoxModel +from textual.content import Content +from textual.css.styles import RenderStyles, Styles +from textual.css.stylesheet import Stylesheet +from textual.dom import DOMNode +from textual.geometry import NULL_OFFSET, NULL_SPACING, Offset, Region, Size, Spacing +from textual.layout import WidgetPlacement +from textual.map_geometry import MapGeometry +from textual.layouts.vertical import VerticalLayout +from textual.selection import SELECT_ALL, Selection +from textual.strip import Strip, StripRenderable +from textual.style import Style +from textual.visual import RenderOptions, Visual + +if TYPE_CHECKING: + from rich.terminal_theme import TerminalTheme + from textual.filter import LineFilter + from textual.layout import DockArrangeResult, Layout + from textual.document._markdown import MarkdownSourceBlock + + +class StyleContext(DOMNode): + """An acquired declaration and selector state, separate from live custody.""" + + def __init__(self, declaration, *, pseudo_classes=frozenset(), **kwargs): + self.declaration = declaration + self._document_pseudo_classes = pseudo_classes + super().__init__(**kwargs) + # Original detached Styles already own validation without scene + # notification when node=None. RenderStyles still owns inheritance. + self._css_styles = Styles() + self._inline_styles = Styles() + self.styles = RenderStyles(self, self._css_styles, self._inline_styles) + + @property + def _parent(self): + reference = self.__dict__.get("_document_parent") + return None if reference is None else reference() + + @_parent.setter + def _parent(self, parent): + # Source ancestry is independent of message-pump custody and its epoch. + self._document_parent = None if parent is None else ref(parent) + + def _make_component_node(self, component): + return StyleContext(DOMNode, classes=component) + + @property + def style_type(self): + return self.declaration + + def bind_declaration(self, declaration): + self._document_declaration = declaration + self._css_types = declaration.selector_names + + def _is_style_type(self, declaration): + captured = self.__dict__.get("_document_declaration") + return ( + declaration in captured.python_bases + if captured is not None + else issubclass(self.declaration, declaration) + ) + + @property + def _component_style_scope(self): + captured = self.__dict__.get("_document_declaration") + return ( + self.declaration._component_style_scope + if captured is None + else captured.component_scope + ) + + @property + def css_type_names(self): + captured = self.__dict__.get("_document_declaration") + return ( + self.declaration._css_type_names + if captured is None + else captured.type_names + ) + + @property + def css_type_name(self): + captured = self.__dict__.get("_document_declaration") + return ( + self.declaration._css_type_name if captured is None else captured.type_name + ) + + def _selector_type_names(self): + return self.declaration._selector_type_names() + + @property + def _node_bases(self): + captured = self.__dict__.get("_document_declaration") + return ( + self.declaration._css_bases(self.declaration) + if captured is None + else captured.bases + ) + + def _get_component_classes(self): + captured = self.__dict__.get("_document_declaration") + return ( + self.declaration._get_component_classes() + if captured is None + else captured.component_classes + ) + + def has_pseudo_classes(self, class_names): + return class_names <= self.get_pseudo_classes() + + def has_pseudo_class(self, class_name): + return class_name in self.get_pseudo_classes() + + def get_pseudo_classes(self): + return set(self._document_pseudo_classes) + + @property + def _pseudo_classes_cache_key(self): + return frozenset(self.get_pseudo_classes()) + + +class DocumentNode(StyleContext): + """One acquired native declaration in an immutable document. + + These nodes have native CSS ancestry and use native layouts, box resolution + and paint. They never register with an App, start a message pump, mount a + widget, or read scene geometry. A declaration supplies content and children; + arbitrary widget methods are not invoked on this different resource. + """ + + _component_style_scope = True + + def __init__( + self, + declaration: type[DOMNode], + content: Content | None = None, + *, + children: Iterable[DocumentNode] = (), + name: str | None = None, + id: str | None = None, + classes: str | None = None, + expand: bool = False, + shrink: bool = False, + pseudo_classes: frozenset[str] = frozenset(), + scroll_offset: Offset = NULL_OFFSET, + source_range: tuple[int, int] | None = None, + source_index: int | None = None, + pre_layout: Callable | None = None, + process_layout: Callable | None = None, + inline_rules: dict | None = None, + auto_links: bool = True, + selection: Callable = Visual.selected_text, + ) -> None: + self.content = content + self.expand = expand + self.shrink = shrink + self.scroll_offset = scroll_offset + self.source_range = source_range + self.source_index = source_index + self._prepare_layout = pre_layout + self._process_layout = process_layout + self.auto_links = auto_links + self.selection = selection + self.text_selection = None + self._document_selection_style = None + self.selecting = False + self._extrema = Extrema() + self._region = Region() + self._default_layout = VerticalLayout() + self._styles_cache = StylesCache() + super().__init__( + declaration, + pseudo_classes=pseudo_classes, + name=name, + id=id, + classes=classes, + ) + if inline_rules: + self._inline_styles = Styles( + _rules=Styles(_rules=inline_rules).acquire_document_rules() + ) + self.styles = RenderStyles(self, self._css_styles, self._inline_styles) + for child in children: + self.add(child) + + def add(self, child: DocumentNode) -> None: + child._attach(self) + self._nodes._append(child) + + def get_pseudo_classes(self) -> set[str]: + # Ordered and empty state belongs to this complete source cohort, not + # the preceding mounted scene. Other state is explicitly acquired. + result = set(self._document_pseudo_classes) + result.discard("empty") + if self.is_empty: + result.add("empty") + if not isinstance(self.parent, DocumentNode): + return result + positions = { + "first-of-type": self.first_of_type, + "last-of-type": self.last_of_type, + "first-child": self.first_child, + "last-child": self.last_child, + "odd": self.is_odd, + "even": self.is_even, + } + result.difference_update(positions) + result.update(name for name, present in positions.items() if present) + return result + + @property + def layout_viewport(self) -> Size: + return self.presentation.viewport + + @property + def layout_screen_size(self) -> Size: + return self.presentation.screen_size + + @property + def is_container(self) -> bool: + return bool(self._nodes) or self.styles.layout is not None + + @property + def layout(self) -> Layout: + return self.styles.layout or self._default_layout + + @property + def layer(self) -> str: + return self.styles.layer or "default" + + def _get_document_layer_order(self): + order = None + for node in self.walk_ancestors(with_self=True): + if not node._component_style_scope: + break + if node.styles.has_rule("layers"): + order = node.styles.layers + return order + + absolute_offset = None + + @property + def uses_screen_coordinates(self) -> bool: + return self.styles.has_any_rules("constrain_x", "constrain_y") + + @property + def show_vertical_scrollbar(self) -> bool: + return False + + @property + def show_horizontal_scrollbar(self) -> bool: + return False + + @property + def _has_relative_children_height(self) -> bool: + return self._scan_relative_children_height(False)[0] + + def pre_layout(self, layout: Layout) -> None: + """Declarations with preparation effects override this source method.""" + if self._prepare_layout is not None: + self._prepare_layout(self, layout) + + def process_layout(self, placements): + return ( + placements + if self._process_layout is None + else self._process_layout(placements) + ) + + @property + def layout_invalidated_widgets(self): + # This is a fresh immutable source, never a partially updated scene. + return self.children + + @property + def scrollable_content_region(self): + return ( + self.content_region if self.region else Size(self._layout_width, 0).region + ) + + def arrange(self, size: Size, optimal: bool = False) -> DockArrangeResult: + self._layout_width = size.width + return arrange(self, self.children, size, self.layout_viewport, optimal) + + def _resolve_extrema(self, container, viewport, width_fraction, height_fraction): + return Extrema.resolve( + self.styles, + container, + viewport, + width_fraction, + height_fraction, + ) + + def _get_box_model( + self, + container, + viewport, + width_fraction, + height_fraction, + constrain_width=False, + greedy=True, + ) -> BoxModel: + model, self._extrema = BoxModel.resolve( + self, + container, + viewport, + width_fraction, + height_fraction, + constrain_width, + greedy, + ) + return model + + def get_content_width(self, container: Size, viewport: Size) -> int: + self._layout_width = container.width + return self._measure_content_width(container, viewport) + + def get_content_height(self, container: Size, viewport: Size, width: int) -> int: + self._layout_width = width + return self._measure_content_height(container, viewport, width) + + def _parse_visual_style(self, style: str): + return self._document_stylesheet.parse_style(style) + + def _render(self): + return self.content if self.content is not None else self._render_container() + + def render(self): + return self._render() + + @property + def region(self) -> Region: + return self._region + + @property + def content_region(self) -> Region: + return self.region.shrink(self.styles.gutter) + + def render_lines(self, crop: Region) -> list[Strip]: + content_size = self.content_region.size + visual = self._render() + if isinstance(visual, StripRenderable): + # Native Canvas already owns these complete keyline rows. No Rich + # console or scene widget is required to render them a second time. + lines = list(visual._strips) + else: + lines = visual.render_strips( + content_size.width, + content_size.height, + self.visual_style, + RenderOptions( + self._get_style, + self.styles, + self.text_selection, + self._document_selection_style, + ), + ) + lines = Visual.format_strips( + lines, + *content_size, + self.visual_style, + link_style=( + self.link_style + if self.auto_links and not self.is_container and not self.selecting + else None + ), + content_align=self.styles.content_align, + ) + blank = Strip.blank(content_size.width, self.visual_style.rich_style) + base_background, background = self.background_colors + strips = self._styles_cache.render( + self.styles, + self.region.size, + base_background, + background, + lambda y: lines[y] if y < len(lines) else blank, + self.presentation.filters, + None, + None, + content_size=content_size, + crop=crop, + opacity=self._resolved_paint_state().opacity, + ansi_theme=self.presentation.ansi_theme, + native_ansi=self.presentation.native_ansi, + ) + if not self.is_container: + identity = RichStyle.from_meta({"document_leaf": self.paint_leaf_index}) + strips = [strip.apply_style(identity) for strip in strips] + return strips + + +def _input_value(value): + """Freeze actual native value inputs, never scene epochs or CSS guesses.""" + from rich.terminal_theme import TerminalTheme + from textual.layout import Layout + + if isinstance(value, Layout): + return value.document_key() + if isinstance(value, TerminalTheme): + return (type(value), _input_value(vars(value))) + if isinstance(value, Palette): + return (type(value), _input_value(value._colors)) + if isinstance(value, dict): + return tuple((key, _input_value(item)) for key, item in sorted(value.items())) + if isinstance(value, (list, tuple)): + return tuple(_input_value(item) for item in value) + if isinstance(value, (set, frozenset)): + return frozenset(_input_value(item) for item in value) + return deepcopy(value) + + +@dataclass(frozen=True) +class StyleInput: + declaration: type[DOMNode] + type_names: frozenset[str] + type_name: str + selector_names: frozenset[str] + bases: tuple[type[DOMNode], ...] + python_bases: tuple[type, ...] + component_classes: frozenset[str] + component_scope: bool + name: str | None + id: str | None + classes: str + pseudo_classes: frozenset[str] + base_rules: dict + inline_rules: dict + components: tuple + key: tuple + + @classmethod + def acquire(cls, node: DOMNode, *, source_root: bool = False): + # Ancestors supply their actual effective rules. The constructed root's + # base/component rules belong to the acquired stylesheet, evaluated + # against its source children, rather than the preceding host tree. + base = {} if source_root else node.styles.base.acquire_document_rules() + inline = node.styles.inline.acquire_document_rules() + components = ( + () + if source_root + else tuple( + ( + name, + style.base.acquire_document_rules(), + style.inline.acquire_document_rules(), + ) + for name, style in sorted(node._component_styles.items()) + ) + ) + pseudo_classes = frozenset(node.get_pseudo_classes()) + if source_root: + pseudo_classes -= {"empty"} + values = ( + node.style_type, + node.css_type_names, + node.css_type_name, + node._css_types, + tuple(node._node_bases), + tuple(node.style_type.__mro__), + node._get_component_classes(), + node._component_style_scope, + node.name, + node.id, + " ".join(sorted(node.classes)), + pseudo_classes, + _input_value(base), + _input_value(inline), + _input_value(components), + ) + return cls(*values[:12], base, inline, components, values) + + def make(self): + node = StyleContext( + self.declaration, + name=self.name, + id=self.id, + classes=self.classes, + pseudo_classes=self.pseudo_classes, + ) + node.bind_declaration(self) + node._css_styles = Styles( + _rules=Styles(_rules=self.base_rules).acquire_document_rules() + ) + node._inline_styles = Styles( + _rules=Styles(_rules=self.inline_rules).acquire_document_rules() + ) + node.styles = RenderStyles(node, node._css_styles, node._inline_styles) + # Component styles retain their original attached virtual-node meaning. + from textual.css.stylesheet import _ComponentStyles + + for name, base, inline in self.components: + component = node._make_component_node(name) + component._attach(node) + component._css_styles = Styles( + _rules=Styles(_rules=base).acquire_document_rules() + ) + component._inline_styles = Styles( + _rules=Styles(_rules=inline).acquire_document_rules() + ) + component.styles = RenderStyles( + component, component._css_styles, component._inline_styles + ) + node._component_styles[name] = _ComponentStyles(component) + return node + + +@dataclass(frozen=True) +class DocumentPresentation: + """Exact acquired style and terminal inputs, independent of widget lifetime.""" + + ancestors: tuple[StyleInput, ...] + root: StyleInput + declarations: tuple[StyleInput, ...] + stylesheet: Stylesheet + viewport: Size + screen_size: Size + ansi_theme: TerminalTheme + native_ansi: bool + filters: tuple[LineFilter, ...] + key: tuple + admission: tuple + + @staticmethod + def current_admission(owner) -> tuple: + """Original invalidation facts for this actual publication participant. + + These facts qualify repeated queries only on the same admitted scene + lifetime. A replacement widget requires value comparison at publication; + an equal new counter never certifies retained document paint. + """ + app = owner.app + stylesheet = app.stylesheet + return ( + tuple( + ( + id(node), + type(node), + node._parent_revision, + node.styles._cache_key, + ( + None + if node.styles.layout is None + else node.styles.layout.document_key() + ), + node.id, + node.name, + node.classes, + frozenset(node.get_pseudo_classes()), + ) + for node in owner.css_path_nodes + ), + owner._subtree_style_revision, + id(stylesheet), + id(stylesheet._rules), + stylesheet._require_parse, + app.viewport_size, + app.size, + app.theme, + app.native_ansi_color, + _input_value(app.ansi_theme), + tuple( + (type(filter), type(filter).apply, _input_value(vars(filter))) + for filter in owner.get_line_filters() + ), + ) + + def current_for(self, owner) -> bool: + return self.admission == self.current_admission(owner) + + @classmethod + def acquire(cls, owner, declarations) -> DocumentPresentation: + css_path = owner.css_path_nodes + if css_path != list(reversed(owner.ancestors_with_self)): + raise TypeError( + "Custom CSS and paint ancestry require an acquired document presentation" + ) + ancestors = tuple(StyleInput.acquire(node) for node in css_path[:-1]) + root = StyleInput.acquire(owner, source_root=True) + declarations = tuple( + StyleInput.acquire(StyleContext(declaration)) + for declaration in sorted( + declarations, key=lambda item: (item.__module__, item.__qualname__) + ) + ) + stylesheet = owner.app.stylesheet.copy() + for declaration in declarations: + context = declaration.make() + for path, source, specificity, scope in context._get_default_css(): + stylesheet.add_source( + source, + read_from=path, + is_default_css=True, + tie_breaker=specificity, + scope=scope, + ) + filters = tuple( + filter.acquire_document() for filter in owner.get_line_filters() + ) + ansi_theme = deepcopy(owner.app.ansi_theme) + key = ( + tuple(item.key for item in ancestors), + root.key, + tuple(item.key for item in declarations), + tuple(stylesheet.source.items()), + _input_value(stylesheet._variables), + owner.app.viewport_size, + owner.app.size, + _input_value(ansi_theme), + owner.app.native_ansi_color, + tuple( + (type(filter), type(filter).apply, _input_value(vars(filter))) + for filter in filters + ), + ) + return cls( + ancestors, + root, + declarations, + stylesheet, + owner.app.viewport_size, + owner.app.size, + ansi_theme, + owner.app.native_ansi_color, + filters, + key, + cls.current_admission(owner), + ) + + def prepare( + self, + root: DocumentNode, + width: int, + *, + document, + roots, + headings, + root_selection=None, + selections=None, + selection_style=None, + selecting=False, + ) -> DocumentPaint: + # Whole-widget selection has no leaf coordinates before construction. + # A partial root range cannot be interpreted as each leaf's own offsets. + if root_selection is not None: + if root_selection != SELECT_ALL: + raise ValueError("A document root selection must cover the whole widget") + if selections is not None: + raise ValueError("Root-wide and leaf selections are distinct inputs") + selections = None if selections is None else dict(selections) + if (root_selection is not None or selections) and not isinstance( + selection_style, Style + ): + raise TypeError( + "Selected document paint requires the native Visual selection style" + ) + # Every preparation owns its mutable CSS/geometry workspace. The + # retained result owns only strips, literal provenance and source boxes. + stylesheet = self.stylesheet.copy() + stylesheet.parse() + ancestors = [item.make() for item in self.ancestors] + for parent, child in zip(ancestors, ancestors[1:]): + child._attach(parent) + if ancestors: + root._attach(ancestors[-1]) + declarations = {item.declaration: item for item in self.declarations} + nodes = list(root.walk_children(with_self=True)) + empty_inputs = {} + for node in nodes: + node.presentation = self + node._document_stylesheet = stylesheet + node.bind_declaration(declarations[node.declaration]) + if "empty" in stylesheet._get_candidate_rules(node._selector_names)[1]: + empty_inputs[node] = tuple( + (ancestor, ancestor.is_empty) + for ancestor in node.css_path_nodes + if isinstance(ancestor, DocumentNode) + ) + stylesheet.apply(node) + node._css_styles = Styles( + _rules=node.styles.base.acquire_document_rules() + ) + node._inline_styles = Styles( + _rules=node.styles.inline.acquire_document_rules() + ) + node.styles = RenderStyles(node, node._css_styles, node._inline_styles) + for node, inputs in empty_inputs.items(): + if any(ancestor.is_empty != was_empty for ancestor, was_empty in inputs): + raise TypeError( + "Detached CSS display changes an :empty selector input; " + "this dependency requires an explicit document producer" + ) + gutter = root.styles.gutter + content_width = max(0, width - gutter.width) + height = root.get_content_height( + Size(content_width, 0), self.viewport, content_width + ) + size = Size(width, height + gutter.height) + geometry = {} + leaves = [] + no_clip = RootSceneClip(size.region) + + def place( + node, + virtual_region, + region, + order, + layer_order, + clip, + visible, + dock_gutter, + inherited_layers, + source_index, + ): + if not node.display: + return + if (visibility := node.styles.get_rule("visibility")) is not None: + visible = visibility == "visible" + if node.source_index is not None: + source_index = node.source_index + node.paint_source_index = source_index + node._region = region + content_region = region.shrink(node.styles.gutter) + total_region = content_region.reset_offset + layers = inherited_layers + if layers is None: + if (names := node._get_document_layer_order()) is not None: + layers = {name: index for index, name in enumerate(names)} + if node.is_container: + if ( + node.styles.scrollbar_gutter == "stable" + and node.styles.scrollbar_size_vertical + ): + content_region, _ = content_region.split_vertical( + -node.styles.scrollbar_size_vertical + ) + result = node.arrange(content_region.size) + total_region = content_region.reset_offset.union(result.total_region) + sub_clip = clip.intersect(content_region) + placements = WidgetPlacement.process_offsets( + result.placements, + size.region, + content_region.offset - node.scroll_offset, + ) + for ( + child, + local, + placed, + rank, + ordinal, + child_clip, + ) in Compositor._place_children( + placements, + len(result.placements), + content_region, + node.scroll_offset, + order, + layer_order, + sub_clip, + no_clip, + layers or {"default": 0}, + ): + place( + child, + local, + placed, + rank, + ordinal, + child_clip, + visible, + result.scroll_spacing, + layers, + source_index, + ) + if visible: + geometry[node] = MapGeometry( + region, + order, + clip.region, + total_region.size, + content_region.size, + virtual_region, + dock_gutter, + ) + if not node.is_container: + node.paint_leaf_index = len(leaves) + node.selecting = selecting + node.text_selection = ( + root_selection + if selections is None + else selections.get(node.paint_leaf_index) + ) + node._document_selection_style = selection_style + leaves.append( + DocumentLeaf( + source_index, + node.declaration, + node.content, + region, + content_region, + clip.region, + node.selection, + node.link_style, + node.link_style_hover, + ) + ) + + inherited_layers = None + for node in reversed(ancestors): + if node._component_style_scope and node.styles.has_rule("layers"): + inherited_layers = { + name: index for index, name in enumerate(node.styles.layers) + } + place( + root, + size.region, + size.region, + ((0, 0, 0),), + 0, + no_clip, + True, + NULL_SPACING, + inherited_layers, + None, + ) + compositor = Compositor() + mapping = compositor._paint_regions( + compositor._ordered_geometry(geometry), size.region + ) + chops = compositor._render_chops( + size.region, + compositor._regions_to_spans((size.region,)), + widgets=mapping, + cuts=compositor._cuts_for_regions(size.region, mapping), + bounds=size.region, + ) + lines = tuple(Strip.join(line.values()) for line in chops) + placements = tuple( + DocumentBlockPlacement( + node.source_index, + node.declaration, + node.source_range, + entry.region, + entry.clip, + node.id, + ) + for node, entry in compositor._ordered_geometry(geometry) + if node.source_index is not None + ) + placements_by_id = { + placement.id: placement + for placement in placements + if placement.id is not None + } + placements_by_source = { + placement.source_index: placement for placement in placements + } + for member in roots: + member.placement = placements_by_source.get(member.source_index) + return DocumentPaint( + size, + lines, + placements, + tuple(leaves), + document, + self.key, + width, + gutter, + root.content_region.size, + tuple( + DocumentHeading(entry, placements_by_id.get(entry[2])) + for entry in headings + ), + root.is_empty, + root_selection, + None if selections is None else tuple(sorted(selections.items())), + selection_style, + selecting, + roots, + ) + + +@dataclass(frozen=True) +class DocumentBlockPlacement: + source_index: int + declaration: type[DOMNode] + source_range: tuple[int, int] + region: Region + clip: Region + id: str | None + + def source_text(self, document) -> str: + from textual.widgets._markdown import MarkdownBlock + + return MarkdownBlock.source_text(document.source, self.source_range) + + +@dataclass(frozen=True) +class DocumentHeading: + """One original heading entry and its actual optional native placement.""" + + entry: tuple[int, str, str | None] + placement: DocumentBlockPlacement | None + + +@dataclass(frozen=True) +class DocumentLeaf: + source_index: int | None + declaration: type[DOMNode] + content: Content + region: Region + content_region: Region + clip: Region + selection: Callable + link_style: RichStyle + link_style_hover: RichStyle + + def selected_text(self, selection): + """Use original leaf coordinates and its native copy delimiter.""" + return self.selection(self.content, selection) + + +@dataclass(frozen=True) +class DocumentPaint: + """Native paint/extents and source identity, with no retained scene nodes. + + Leaf logical offsets and block regions remain separate from document paint + rows. Interaction owners use these facts; rows aren't raw Markdown offsets. + """ + + size: Size + lines: tuple[Strip, ...] + blocks: tuple[DocumentBlockPlacement, ...] + leaves: tuple[DocumentLeaf, ...] + document: object + presentation_key: tuple + width: int + """Acquired outer width, including the original root gutter.""" + gutter: Spacing + """Actual worker-resolved native root padding and border.""" + content_size: Size + """Intrinsic inner extent; scene allocation does not replace this answer.""" + headings: tuple[DocumentHeading, ...] + root_empty: bool + """Native displayed-child membership of this acquired source/style cohort.""" + root_selection: Selection | None + """Whole-widget selection acquired before native leaves need to exist.""" + selections: tuple[tuple[int, Selection], ...] | None + """Actual partial leaf ranges, separate from root-wide selection.""" + selection_style: Style | None + selecting: bool + """Original active pointer gesture, which controls native link adornment.""" + roots: tuple[MarkdownSourceBlock, ...] + """Completed original grammar roots in source order, distinct from paint order. + + Each member retains its declaration, source index/range, original document, + original optional fence code and actual optional placement. Consumers + borrow this completed source; placements and composed leaves remain + separate native geometry answers. + """ + + @property + def table_of_contents(self): + return [heading.entry for heading in self.headings] + + def anchor_region(self, anchor: str) -> Region | None: + """Use the original duplicate-aware heading slug decision.""" + from textual.widgets._markdown import Markdown + + block_id = Markdown.anchor_id_for(self.table_of_contents, anchor) + for heading in self.headings: + if heading.entry[2] == block_id and heading.placement is not None: + return heading.placement.region + return None + + def matches_selection( + self, *, root_selection=None, selections=None, selection_style=None, + selecting=False, + ) -> bool: + return ( + root_selection == self.root_selection + and ( + None if selections is None else tuple(sorted(selections.items())) + ) == self.selections + and selection_style == self.selection_style + and selecting == self.selecting + ) + + def matches( + self, document, width: int, *, root_selection=None, selections=None, + selection_style=None, selecting=False, + ) -> bool: + return ( + width == self.width + and document.same_source(self.document) + and document.presentation.key == self.presentation_key + and self.matches_selection( + root_selection=root_selection, selections=selections, + selection_style=selection_style, selecting=selecting, + ) + ) + + def is_current(self, owner, width: int, *, selections=None) -> bool: + """Compare current declared presentation without reading any descendants. + + The source owner must still hold this exact acquired document. A source + replacement acquires a new MarkdownDocument; style refresh uses its + with_presentation(). Scene eviction cannot certify different source. + Selection and active pointer state come from their original Screen; + a selection update never admits rows prepared for the previous input. + """ + root_selection = owner.text_selection + return ( + width == self.width + and self.document.presentation.current_for(owner) + and self.matches_selection( + root_selection=root_selection, + selections=selections, + selection_style=( + Visual.selection_style(owner) + if root_selection is not None or selections else None + ), + selecting=owner.screen._selecting, + ) + ) + + def with_presentation( + self, document, *, root_selection=None, selections=None, + selection_style=None, selecting=False, + ) -> DocumentPaint: + """Admit retained paint after actual source/presentation value equality. + + Call at publication, including after eviction. Rows are reused only if + their source, width, selection and effective inputs agree. The resulting resource + owns the new participant's original invalidation witness. + """ + if not self.matches( + document, self.width, root_selection=root_selection, selections=selections, + selection_style=selection_style, selecting=selecting, + ): + raise ValueError( + "Retained document paint has different source, presentation or selection" + ) + return replace(self, document=document) + + def render_lines(self, crop: Region) -> list[Strip]: + """Borrow native rows; retain leaf logical-offset and link metadata.""" + return [ + ( + self.lines[y] if 0 <= y < self.size.height else Strip.blank(self.width) + ).crop(crop.x, crop.right) + for y in crop.line_range + ] + + def get_leaf_and_offset_at(self, x: int, y: int): + """Hit actual composed metadata; offsets belong to that original leaf.""" + if not self.size.region.contains(x, y): + return None, None + line = self.lines[y] + index = line.get_style_at(x).meta.get("document_leaf") + if index is None: + return None, None + return index, line.get_content_offset(x, scope=("document_leaf", index)) + + def prepare_selection( + self, selections=None, selection_style=None, *, root_selection=None, + selecting=False, + ): + """Worker-side native selection styling, retaining original leaf offsets. + + Use the same source/content/CSS owners, including native tab expansion + and wrapping. The interaction owner supplies leaf Selection values and + the Screen's original Visual selection style; paint rows aren't source + offsets. root_selection=SELECT_ALL applies to original leaves without + requiring any controls or fake leaf identities to exist beforehand. + """ + return self.document.prepare( + self.width, root_selection=root_selection, selections=selections, + selection_style=selection_style, selecting=selecting, + ) diff --git a/src/textual/dom.py b/src/textual/dom.py index 5a2cb03fa92..7da411a87a9 100644 --- a/src/textual/dom.py +++ b/src/textual/dom.py @@ -10,6 +10,7 @@ import threading from weakref import ref from functools import lru_cache, partial +from dataclasses import replace from inspect import getfile from typing import ( TYPE_CHECKING, @@ -48,11 +49,12 @@ from textual.css.parse import is_id_selector, parse_declarations, parse_selectors from textual.css.query import InvalidQueryFormat, NoMatches, TooManyMatches, WrongType from textual.css.styles import RenderStyles, Styles +from textual.geometry import Size from textual.css.tokenize import IDENTIFIER from textual.css.tokenizer import TokenError from textual.message_pump import MessagePump from textual.reactive import Reactive, ReactiveError, _Mutated, _watch -from textual.style import Style as VisualStyle +from textual.style import NULL_STYLE, Style as VisualStyle from textual.timer import Timer from textual.walk import walk_breadth_first, walk_breadth_search_id, walk_depth_first from textual.worker_manager import WorkerManager @@ -65,6 +67,7 @@ from textual.app import App from textual.css.query import DOMQuery, QueryType from textual.css.types import CSSLocation + from textual.css.styles import StylesBase from textual.message import Message from textual.reactive import _StoredReactiveAccess from textual.screen import Screen @@ -183,6 +186,12 @@ def _style_rules_updated(self, revision: int, geometry: bool) -> None: node._subtree_style_revision = revision geometry = node._style_geometry_updated(geometry) + _component_style_scope: ClassVar[bool] = False + + def _make_component_node(self, component: str) -> DOMNode: + return DOMNode(classes=component) + + DEFAULT_CSS: ClassVar[str] = "" """Default TCSS.""" @@ -234,6 +243,196 @@ def _style_rules_updated(self, revision: int, geometry: bool) -> None: _PSEUDO_CLASSES: ClassVar[dict[str, Callable[[App[Any]], bool]]] = {} """Pseudo class checks.""" + @property + def _has_relative_children_width(self) -> bool: + """Do any children (or progeny) have a relative width?""" + if not self.is_container: + return False + for child in self.children: + if child.styles.expand == "optimal": + continue + styles = child.styles + if not child.display: + continue + width = styles.width + if width is None: + continue + if styles.is_relative_width or ( + width.is_auto and child._has_relative_children_width + ): + return True + return False + + def _scan_relative_children_height(self, native: bool) -> tuple[bool, bool]: + """Read auto-child stretch through the original child measurement owner. + + Widgets retain their epoch-qualified answers. Detached document nodes + share the exact scan without borrowing those scene lifetime epochs. + """ + from textual.widget import Widget + from textual.css.scalar import Scalar + + if not self.is_container: + result = False + else: + result = False + for child in self.children: + styles = child.styles + native = native and ( + type(child).display is DOMNode.display + and type(styles) is RenderStyles + ) + if not child.display: + continue + height = styles.height + if height is None: + continue + native = native and type(height) is Scalar + if styles.is_relative_height: + result = True + break + if height.is_auto: + if type(child)._has_relative_children_height is Widget._has_relative_children_height: + relative, child_native = child._relative_children_height() + else: + relative, child_native = child._has_relative_children_height, False + native = native and child_native + if relative: + result = True + break + return result, native + + def _measure_content_width(self, container: Size, viewport: Size) -> int: + """Intrinsic native layout/visual width, independent of scene custody.""" + if self.is_container: + return self.layout.get_content_width(self, container, viewport) + width = self._render().get_optimal_width(self.styles, container.width) + if self.expand: + width = max(container.width, width) + if self.shrink: + width = min(width, container.width) + return width + + def _render_container(self): + """Native container paint, shared independently of scene custody.""" + from textual.renderables.blank import Blank + + if self.styles.layout and self.styles.keyline[0] != "none": + return self.layout.render_keyline(self) + return Blank(self.background_colors[1]) + + def _measure_content_height(self, container: Size, viewport: Size, width: int) -> int: + """Intrinsic native height; the caller owns reuse and publication.""" + if not width: + return 0 + if self.is_container: + return self.layout.get_content_height(self, container, viewport, width) + return self._render().get_height(self.styles, width) + + @property + def style_type(self) -> type[DOMNode]: + """The native declaration addressed by CSS and ordered selectors.""" + return type(self) + + @classmethod + def _require_document_methods(cls, methods) -> None: + """Different scene behavior must explicitly supply detached behavior.""" + for name, implementation in methods.items(): + if getattr(cls, name) is not implementation: + raise TypeError(f"{cls.__name__}.{name} requires a detached document producer") + + def _is_style_type(self, declaration: type[DOMNode]) -> bool: + return isinstance(self, declaration) + + @property + def css_type_names(self) -> frozenset[str]: + return self._css_type_names + + @property + def css_type_name(self) -> str: + return self._css_type_name + + @property + def first_of_type(self) -> bool: + """Is this the first widget of its type in its siblings?""" + parent = self.parent + if parent is None: + return True + # This pseudo classes only changes when the parent's nodes._updates changes + if parent._nodes._updates == self._first_of_type[0]: + return self._first_of_type[1] + widget_type = self.style_type + for node in parent._nodes.displayed: + if node._is_style_type(widget_type): + self._first_of_type = (parent._nodes._updates, node is self) + return self._first_of_type[1] + return False + + @property + def last_of_type(self) -> bool: + """Is this the last widget of its type in its siblings?""" + parent = self.parent + if parent is None: + return True + # This pseudo classes only changes when the parent's nodes._updates changes + if parent._nodes._updates == self._last_of_type[0]: + return self._last_of_type[1] + widget_type = self.style_type + for node in parent._nodes.displayed_reverse: + if node._is_style_type(widget_type): + self._last_of_type = (parent._nodes._updates, node is self) + return self._last_of_type[1] + return False + + @property + def first_child(self) -> bool: + """Is this the first widget in its siblings?""" + parent = self.parent + if parent is None: + return True + # This pseudo class only changes when the parent's nodes._updates changes + if parent._nodes._updates == self._first_child[0]: + return self._first_child[1] + for node in parent._nodes.displayed: + self._first_child = (parent._nodes._updates, node is self) + return self._first_child[1] + return False + + @property + def last_child(self) -> bool: + """Is this the last widget in its siblings?""" + parent = self.parent + if parent is None: + return True + # This pseudo class only changes when the parent's nodes._updates changes + if parent._nodes._updates == self._last_child[0]: + return self._last_child[1] + for node in parent._nodes.displayed_reverse: + self._last_child = (parent._nodes._updates, node is self) + return self._last_child[1] + return False + + @property + def is_odd(self) -> bool: + """Is this widget at an oddly numbered position within its siblings?""" + parent = self.parent + if parent is None: + return True + # This pseudo classes only changes when the parent's nodes._updates changes + if parent._nodes._updates == self._odd[0]: + return self._odd[1] + try: + is_odd = parent._nodes.displayed_and_visible.index(self) % 2 == 0 + self._odd = (parent._nodes._updates, is_odd) + return is_odd + except ValueError: + return False + + @property + def is_even(self) -> bool: + """Is this widget at an evenly numbered position within its siblings?""" + return not self.is_odd + def __init__( self, *, @@ -257,6 +456,14 @@ def __init__( self.styles: RenderStyles = RenderStyles( self, self._css_styles, self._inline_styles ) + self._visual_style: VisualStyle | None = None + self._visual_paint_state = EMPTY_PAINT + self._visual_style_cache: dict[tuple, VisualStyle] = {} + self._first_of_type = (-1, False) + self._last_of_type = (-1, False) + self._first_child = (-1, False) + self._last_child = (-1, False) + self._odd = (-1, False) self._auto_refresh: float | None = None self._auto_refresh_timer: Timer | None = None self._css_types = self._selector_type_names() @@ -1238,6 +1445,162 @@ def rich_style(self) -> Style: background.rich_color if background.a else None, ) + def get_visual_style( + self, *component_classes: str, partial: bool = False + ) -> VisualStyle: + """Get the visual style for the widget, including any component styles. + + Args: + component_classes: Optional component styles. + partial: Return a partial style (not combined with parent). + + Returns: + A Visual style instance. + + """ + cache_key = (self._pseudo_classes_cache_key, component_classes, partial) + if (visual_style := self._visual_style_cache.get(cache_key, None)) is None: + background = Color(0, 0, 0, 0) + color = Color(255, 255, 255, 0) + + style = Style() + opacity = 1.0 + + def iter_styles() -> Iterable[StylesBase]: + """Iterate over the styles from the DOM and additional components styles.""" + if partial: + node = self + else: + for node in reversed(self.ancestors_with_self): + yield node.styles + for name in component_classes: + yield node.get_component_styles(name) + + for styles in iter_styles(): + has_rule = styles.has_rule + opacity *= styles.opacity + if has_rule("background"): + text_background = background + styles.background.tint( + styles.background_tint + ) + if partial: + background_tint = styles.background.tint(styles.background_tint) + background = background.blend( + background_tint, 1 - background_tint.a + ).multiply_alpha(opacity) + else: + background += ( + styles.background.tint(styles.background_tint) + ).multiply_alpha(opacity) + else: + text_background = background + if has_rule("color"): + color = styles.color.multiply_alpha(styles.text_opacity) + style += styles.text_style + if has_rule("auto_color") and styles.auto_color: + color = text_background.get_contrast_text(color.a) + + visual_style = replace( + VisualStyle.from_rich_style(style), + background=background, + foreground=color, + ) + self._visual_style_cache[cache_key] = visual_style + + return visual_style + + def _get_style(self, style: VisualStyle | str) -> VisualStyle: + """A get_style method for use in Content. + + Args: + style: A style prefixed with a dot. + + Returns: + A visual style if one is fund, otherwise `None`. + """ + if isinstance(style, VisualStyle): + return style + + if style.startswith("."): + style_name = style[1:] + for node in self.walk_ancestors(with_self=True): + if not node._component_style_scope: + break + try: + return node.get_visual_style(style_name, partial=True) + except KeyError: + continue + else: + raise KeyError(f"No matching component class found for '{style}'") + return NULL_STYLE + try: + return self._parse_visual_style(style) + except Exception: + return NULL_STYLE + + def _parse_visual_style(self, style: str) -> VisualStyle: + """Parse inline styles through this node's presentation owner.""" + return VisualStyle.parse(style) + + @property + def link_style(self) -> Style: + """Style of links. + + Returns: + Rich style. + """ + styles = self.styles + _, background = self.background_colors + link_background = background + styles.link_background + link_color = link_background + ( + link_background.get_contrast_text(styles.link_color.a) + if styles.auto_link_color + else styles.link_color + ) + style = styles.link_style + Style.from_color( + link_color.rich_color, + link_background.rich_color if styles.link_background.a else None, + ) + return style + + @property + def link_style_hover(self) -> Style: + """Style of links underneath the mouse cursor. + + Returns: + Rich Style. + """ + styles = self.styles + _, background = self.background_colors + hover_background = background + styles.link_background_hover + hover_color = hover_background + ( + hover_background.get_contrast_text(styles.link_color_hover.a) + if styles.auto_link_color_hover + else styles.link_color_hover + ) + style = styles.link_style_hover + Style.from_color( + hover_color.rich_color, + hover_background.rich_color, + ) + return style + + @property + def visual_style(self) -> VisualStyle: + """The widget's current style.""" + resolved = self._resolved_paint_state() + if ( + self._visual_style is None + or not resolved.same_paint(self._visual_paint_state) + ): + background, color, style = resolved.background, resolved.foreground, resolved.text_style + self._visual_style = replace( + VisualStyle.from_rich_style(style), + background=background, + foreground=color, + ) + self._visual_paint_state = resolved + return self._visual_style + def check_consume_key(self, key: str, character: str | None) -> bool: """Check if the widget may consume the given key. diff --git a/src/textual/filter.py b/src/textual/filter.py index 31367fc344a..fea6a21bda8 100644 --- a/src/textual/filter.py +++ b/src/textual/filter.py @@ -14,6 +14,7 @@ from __future__ import annotations from abc import ABC, abstractmethod +from copy import deepcopy from functools import lru_cache from rich.color import Color as RichColor @@ -36,6 +37,15 @@ def __init__(self, enabled: bool = True) -> None: """ self.enabled = enabled + def acquire_document(self): + """Custom filters explicitly supply their detached, data-only state.""" + raise TypeError(f"{type(self).__name__} has no detached filter contract") + + def _acquire_native_document(self, apply): + if type(self).apply is not apply: + raise TypeError(f"{type(self).__name__} must supply acquire_document") + return deepcopy(self) + @abstractmethod def apply(self, segments: list[Segment], background: Color) -> list[Segment]: """Transform a list of segments. @@ -94,6 +104,9 @@ def apply(self, segments: list[Segment], background: Color) -> list[Segment]: for text, style, _ in segments ] + def acquire_document(self, *, _apply=apply): + return self._acquire_native_document(_apply) + class NoColor(LineFilter): """Remove all color information from segments.""" @@ -120,6 +133,9 @@ def apply(self, segments: list[Segment], background: Color) -> list[Segment]: for text, style, control in segments ] + def acquire_document(self, *, _apply=apply): + return self._acquire_native_document(_apply) + NO_DIM = Style(dim=False) """A Style to set dim to False.""" @@ -215,6 +231,9 @@ def apply(self, segments: list[Segment], background: Color) -> list[Segment]: for segment in segments ] + def acquire_document(self, *, _apply=apply): + return self._acquire_native_document(_apply) + class ANSIToTruecolor(LineFilter): """Convert ANSI colors to their truecolor equivalents.""" @@ -286,3 +305,6 @@ def apply(self, segments: list[Segment], background: Color) -> list[Segment]: ) for text, style, _ in segments ] + + def acquire_document(self, *, _apply=apply): + return self._acquire_native_document(_apply) diff --git a/src/textual/geometry.py b/src/textual/geometry.py index 8229383f6a6..313114a37a5 100644 --- a/src/textual/geometry.py +++ b/src/textual/geometry.py @@ -97,6 +97,10 @@ class Offset(NamedTuple): y: int = 0 """Offset in the y-axis (vertical)""" + @staticmethod + def _from_pickle(x: int, y: int) -> Offset: + return Offset(x, y) + @property def is_origin(self) -> bool: """Is the offset at (0, 0)?""" @@ -211,6 +215,10 @@ class Size(NamedTuple): height: int = 0 """The height in cells.""" + @staticmethod + def _from_pickle(width: int, height: int) -> Size: + return Size(width, height) + def __bool__(self) -> bool: """A Size is Falsy if it has area 0.""" return self.width * self.height != 0 @@ -362,6 +370,10 @@ class Region(NamedTuple): height: int = 0 """The height of the region.""" + @staticmethod + def _from_pickle(x: int, y: int, width: int, height: int) -> Region: + return Region(x, y, width, height) + @classmethod def from_union(cls, regions: Collection[Region]) -> Region: """Create a Region from the union of other regions. @@ -1159,6 +1171,10 @@ class Spacing(NamedTuple): left: int = 0 """Space from the left of a region.""" + @staticmethod + def _from_pickle(top: int, right: int, bottom: int, left: int) -> Spacing: + return Spacing(top, right, bottom, left) + def __bool__(self) -> bool: return self != (0, 0, 0, 0) @@ -1480,11 +1496,34 @@ def contains_point(self, offset: Offset) -> bool: return any(region.contains_point(offset) for region in self._regions) +def _reduce_geometry(value): + """Preserve the declared geometry identity and its constructor fields.""" + return value._from_pickle, tuple(value) + + +def _with_geometry_pickle(declaration: type, accelerated: type) -> type: + """Supply native value transport on the selected accelerated implementation. + + The extension's classes have no reduction and report a builtins module + which cannot resolve their constructors. The original geometry declaration + supplies an importable constructor; no class metadata or backend selection + is changed, and no consumer has to encode these values itself. + """ + accelerated._from_pickle = staticmethod(declaration._from_pickle) + accelerated.__reduce__ = _reduce_geometry + return accelerated + + if not TYPE_CHECKING and os.environ.get("TEXTUAL_SPEEDUPS", "1") == "1": try: - from textual_speedups import Offset, Region, Size, Spacing + import textual_speedups except ImportError: pass + else: + Offset = _with_geometry_pickle(Offset, textual_speedups.Offset) + Region = _with_geometry_pickle(Region, textual_speedups.Region) + Size = _with_geometry_pickle(Size, textual_speedups.Size) + Spacing = _with_geometry_pickle(Spacing, textual_speedups.Spacing) NULL_OFFSET: Final = Offset(0, 0) diff --git a/src/textual/layout.py b/src/textual/layout.py index 8e377809150..67bff5330d6 100644 --- a/src/textual/layout.py +++ b/src/textual/layout.py @@ -1,7 +1,8 @@ from __future__ import annotations -from abc import ABC, abstractmethod +from abc import ABC, ABCMeta, abstractmethod from dataclasses import dataclass +from inspect import getattr_static from typing import TYPE_CHECKING, ClassVar, Iterable, NamedTuple from textual._spatial_map import SpatialMap @@ -126,6 +127,12 @@ class WidgetPlacement(NamedTuple): overlay: bool = False absolute: bool = False + @classmethod + def process_offsets(cls, placements, bounds: Region, offset: Offset): + """Acquire each original constraint before descendant placement begins.""" + return [(ordinal, placement.process_offset(bounds, offset)) + for ordinal, placement in placements] + @property def reset_origin(self) -> WidgetPlacement: """Reset the origin in the placement (moves it to (0, 0)).""" @@ -231,13 +238,56 @@ def process_offset( return self -class Layout(ABC): +class _LayoutMeta(ABCMeta): + """Keep Layout's declaration supply current when its namespace changes.""" + + def __new__(mcls, name, bases, namespace, **kwargs): + declaration = super().__new__(mcls, name, bases, namespace, **kwargs) + if any(isinstance(base, _LayoutMeta) for base in bases): + Layout._publish_document_implementation.__func__(declaration) + return declaration + + def __setattr__(cls, name, value): + super().__setattr__(name, value) + _LayoutMeta._document_declaration_changed(cls, name) + + def __delattr__(cls, name): + super().__delattr__(name) + _LayoutMeta._document_declaration_changed(cls, name) + + def _document_declaration_changed(cls, name): + if name not in cls._document_method_names and name not in ( + "_document_methods", "__bases__", + ): + return + # Use Python's actual descendant declarations, not a second catalog. + pending = [cls] + visited = set() + while pending: + declaration = pending.pop() + if declaration in visited: + continue + visited.add(declaration) + Layout._publish_document_implementation.__func__(declaration) + pending.extend(type.__subclasses__(declaration)) + + +class Layout(ABC, metaclass=_LayoutMeta): """Base class of the object responsible for arranging Widgets within a container.""" name: ClassVar[str] = "" _content_width_dependency: ClassVar[HeightDependency] _content_height_dependency: ClassVar[HeightDependency] _arrangement_height_dependency: ClassVar[HeightDependency] + _document_method_names = ( + "arrange", "get_content_width", "get_content_height", "__init__", + "clear_cache", "render_keyline", "_document_inputs", + ) + _document_instance_method_names = frozenset( + name for name in _document_method_names if name != "__init__" + ) + _document_methods: ClassVar[tuple | None] = None + _document_implementation: ClassVar[tuple | None] = None def __init_subclass__(cls, **kwargs) -> None: from textual._measurement import CONTEXT_HEIGHT @@ -253,9 +303,57 @@ def __init_subclass__(cls, **kwargs) -> None: and cls._arrangement_height_dependency is CONTEXT_HEIGHT): cls._content_width_dependency = CONTEXT_HEIGHT + @classmethod + def _publish_document_implementation(cls): + """Admit actual C3 supply at declaration or namespace publication. + + Foreign mixins can mutate without entering this class owner. They need + an explicit detached contract; inherited native admission cannot + certify those independently changing declarations. + """ + expected = cls._document_methods + unmanaged = ( + type(cls).__setattr__ is not _LayoutMeta.__setattr__ + or type(cls).__delattr__ is not _LayoutMeta.__delattr__ + or type(cls).__getattribute__ is not type.__getattribute__ + or any( + base not in Layout.__mro__ and not issubclass(base, Layout) + for base in cls.__mro__[1:] + ) + ) + actual = ( + None if expected is None or unmanaged else + tuple(getattr_static(cls, name) for name in cls._document_method_names) + ) + supply = (cls, *actual) if actual is not None and actual == expected else None + type.__setattr__(cls, "_document_implementation", supply) + def clear_cache(self) -> None: """Release layout-owned derived state after structural child removal.""" + def document_key(self) -> tuple: + """Immutable inputs for detached use; custom layouts supply this contract.""" + implementation = type(self)._document_implementation + if implementation is None or implementation[0] is not type(self): + raise TypeError(f"{type(self).__name__} must declare its detached layout inputs") + # Instance dispatch changes binding even when the assigned object is + # the same unbound function. Constructor dispatch belongs to the type. + if not self.__dict__.keys().isdisjoint(type(self)._document_instance_method_names): + raise TypeError(f"{type(self).__name__} has instance-specific layout dispatch") + return (*implementation, tuple(self._document_inputs().items())) + + def _document_inputs(self) -> dict: + return {} + + def acquire_document(self) -> Layout: + """Acquire algorithm/configuration with independent derived geometry. + + Custom layouts own this construction contract. A scene layout's cached + placements and child custody are not document inputs. + """ + self.document_key() + return type(self)(**self._document_inputs()) + def __repr__(self) -> str: return f"<{self.name}>" diff --git a/src/textual/layouts/grid.py b/src/textual/layouts/grid.py index 764704e15a5..01b38743fff 100644 --- a/src/textual/layouts/grid.py +++ b/src/textual/layouts/grid.py @@ -19,20 +19,30 @@ class GridLayout(Layout): name = "grid" - def __init__(self) -> None: - self.min_column_width: int | None = None + def __init__( + self, + *, + min_column_width: int | None = None, + max_column_width: int | None = None, + stretch_height: bool = False, + regular: bool = False, + expand: bool = False, + shrink: bool = False, + auto_minimum: bool = False, + ) -> None: + self.min_column_width: int | None = min_column_width """Maintain a minimum column width, or `None` for no minimum.""" - self.max_column_width: int | None = None + self.max_column_width: int | None = max_column_width """Maintain a maximum column width, or `None` for no maximum.""" - self.stretch_height: bool = False + self.stretch_height: bool = stretch_height """Stretch the height of cells to be equal in each row.""" - self.regular: bool = False + self.regular: bool = regular """Grid should be regular (no remainder in last row).""" - self.expand: bool = False + self.expand: bool = expand """Expand the grid to fit the container if it is smaller.""" - self.shrink: bool = False + self.shrink: bool = shrink """Shrink the grid to fit the container if it is larger.""" - self.auto_minimum: bool = False + self.auto_minimum: bool = auto_minimum """If self.shrink is `True`, auto-detect and limit the width.""" self._grid_size: tuple[int, int] | None = None """Grid size after last arrange call.""" @@ -87,7 +97,7 @@ def arrange( table_size_rows = styles.grid_size_rows - viewport = parent.app.viewport_size + viewport = parent.layout_viewport keyline_style, _keyline_color = styles.keyline offset = (0, 0) gutter_spacing: Spacing | None @@ -376,3 +386,19 @@ def apply_height_limits(widget: Widget, height: int) -> int: ) return placements + + def _document_inputs(self) -> dict: + return { + "min_column_width": self.min_column_width, + "max_column_width": self.max_column_width, + "stretch_height": self.stretch_height, + "regular": self.regular, + "expand": self.expand, + "shrink": self.shrink, + "auto_minimum": self.auto_minimum, + } + + _document_methods = ( + arrange, Layout.get_content_width, Layout.get_content_height, + __init__, Layout.clear_cache, Layout.render_keyline, _document_inputs, + ) diff --git a/src/textual/layouts/horizontal.py b/src/textual/layouts/horizontal.py index 4b5e2475f9f..bff97008a89 100644 --- a/src/textual/layouts/horizontal.py +++ b/src/textual/layouts/horizontal.py @@ -27,7 +27,7 @@ def arrange( parent.pre_layout(self) placements: list[WidgetPlacement] = [] add_placement = placements.append - viewport = parent.app.viewport_size + viewport = parent.layout_viewport child_styles = [child.styles for child in children] box_margins: list[Spacing] = [ @@ -120,3 +120,9 @@ def arrange( x = next_x + margin return placements + + _document_methods = ( + arrange, Layout.get_content_width, Layout.get_content_height, + object.__init__, Layout.clear_cache, Layout.render_keyline, + Layout._document_inputs, + ) diff --git a/src/textual/layouts/stream.py b/src/textual/layouts/stream.py index e70b4a253ab..cb144151bb2 100644 --- a/src/textual/layouts/stream.py +++ b/src/textual/layouts/stream.py @@ -49,13 +49,13 @@ def arrange( if not children: self.clear_cache() return [] - viewport = parent.app.viewport_size + viewport = parent.layout_viewport if size.width != self._cached_width: self._cached_placements = None previous_results = self._cached_placements or [] - layout_widgets = parent.screen._layout_widgets.get(parent, []) + layout_widgets = parent.layout_invalidated_widgets _Region = Region _WidgetPlacement = WidgetPlacement @@ -158,3 +158,8 @@ def get_content_height( else: height = 0 return height + + _document_methods = ( + arrange, get_content_width, get_content_height, __init__, clear_cache, + Layout.render_keyline, Layout._document_inputs, + ) diff --git a/src/textual/layouts/vertical.py b/src/textual/layouts/vertical.py index 3dc72456b71..f7683ca4d9d 100644 --- a/src/textual/layouts/vertical.py +++ b/src/textual/layouts/vertical.py @@ -25,7 +25,7 @@ def arrange( parent.pre_layout(self) placements: list[WidgetPlacement] = [] add_placement = placements.append - viewport = parent.app.viewport_size + viewport = parent.layout_viewport child_styles = [child.styles for child in children] box_margins: list[Spacing] = [ @@ -56,7 +56,7 @@ def arrange( [styles.height for styles in child_styles], children, size, - parent.app.size, + parent.layout_screen_size, resolve_margin, resolve_dimension="height", greedy=greedy, @@ -124,3 +124,9 @@ def arrange( y = next_y + margin return placements + + _document_methods = ( + arrange, Layout.get_content_width, Layout.get_content_height, + object.__init__, Layout.clear_cache, Layout.render_keyline, + Layout._document_inputs, + ) diff --git a/src/textual/strip.py b/src/textual/strip.py index a7ae16b9a23..8594d673037 100644 --- a/src/textual/strip.py +++ b/src/textual/strip.py @@ -23,6 +23,7 @@ from textual.color import Color from textual.css.types import AlignHorizontal, AlignVertical from textual.filter import LineFilter +from textual.geometry import Offset SGR_STYLES = ["1", "2", "3", "4", "5", "6", "7", "8", "9", "21", "51", "52", "53"] @@ -103,19 +104,83 @@ def __init__( self._crop_cache: FIFOCache[tuple[int, int], Strip] | None = None self._style_cache: FIFOCache[Style, Strip] | None = None self._filter_cache: FIFOCache[tuple[LineFilter, Color], Strip] | None = None - self._line_length_cache: FIFOCache[ - tuple[int, Style | None], - Strip, - ] | None = None - self._crop_extend_cache: FIFOCache[ - tuple[int, int, Style | None], - Strip, - ] | None = None + self._line_length_cache: ( + FIFOCache[ + tuple[int, Style | None], + Strip, + ] + | None + ) = None + self._crop_extend_cache: ( + FIFOCache[ + tuple[int, int, Style | None], + Strip, + ] + | None + ) = None self._offsets_cache: FIFOCache[tuple[int, int], Strip] | None = None self._render_cache: str | None = None self._link_ids: set[str] | None = None self._cell_count: int | None = None + def get_style_at(self, x: int) -> Style: + """Original rendered cell style, including action/selection metadata.""" + end = 0 + for segment in self: + end += segment.cell_length + if x < end: + return segment.style or Style.null() + return Style.null() + + def get_content_offset( + self, x: int, *, scope: tuple[str, object] | None = None + ) -> Offset | None: + """Original character offset for a cell, optionally in one paint leaf. + + Compositor and detached documents share the Unicode cell/character + conversion. Scope separates different original leaves in a composed row. + """ + end = 0 + start = 0 + offset_y: int | None = None + offset_x = 0 + offset_x2 = 0 + + from rich.cells import get_character_cell_size + + offset: Offset | None = None + for segment in self: + end += segment.cell_length + style = segment.style + if style is not None and style._meta is not None: + meta = style.meta + if scope is not None and meta.get(scope[0]) != scope[1]: + start = end + continue + if "offset" in meta: + offset_x, offset_y = meta["offset"] + if offset_y is None: + continue + offset_x2 = offset_x + len(segment.text) + + if x < end and x >= start: + segment_cell_length = 0 + cell_cut = x - start + segment_offset = 0 + for character in segment.text: + if segment_cell_length >= cell_cut: + break + segment_cell_length += get_character_cell_size(character) + segment_offset += 1 + + offset = Offset(offset_x + segment_offset, offset_y) + break + start = end + + if offset is None and offset_y is not None: + offset = Offset(offset_x2, offset_y) + return offset + def __rich_repr__(self) -> rich.repr.Result: try: yield self._segments diff --git a/src/textual/visual.py b/src/textual/visual.py index cb5523efbd8..d766446abb2 100644 --- a/src/textual/visual.py +++ b/src/textual/visual.py @@ -127,6 +127,14 @@ class Visual(ABC): """ + @staticmethod + def selected_text(visual, selection): + """Extract native text coordinates from a scene or prepared visual.""" + from textual.content import Content + if isinstance(visual, (Text, Content)): + return selection.extract(str(visual)), "\n" + return None + @abstractmethod def render_strips( self, width: int, height: int | None, style: Style, options: RenderOptions @@ -187,6 +195,11 @@ def get_height(self, rules: RulesMap, width: int) -> int: A height in lines. """ + @classmethod + def selection_style(cls, widget: Widget) -> Style: + """Acquire the original Screen selection component for native paint.""" + return Style.from_styles(widget.screen.get_component_styles("screen--selection")) + @classmethod def to_strips( cls, @@ -218,29 +231,47 @@ def to_strips( selection = widget.text_selection if selection is not None: - selection_style = Style.from_styles( - widget.screen.get_component_styles("screen--selection") - ) + selection_style = cls.selection_style(widget) else: selection_style = None strips = visual.render_strips( - width, - height, - style, + width, height, style, RenderOptions( - widget._get_style, - widget.styles, - selection if apply_selection else None, - selection_style, + widget._get_style, widget.styles, + selection if apply_selection else None, selection_style, ), ) - if ( - widget.auto_links - and not widget.is_container - and not widget.screen._selecting - ): - link_style = widget.link_style + link_style = ( + widget.link_style + if widget.auto_links and not widget.is_container and not widget.screen._selecting + else None + ) + return cls.format_strips( + strips, width, height, style, + link_style=link_style, pad=pad, + content_align=widget.styles.content_align, + ) + + @classmethod + def format_strips( + cls, + strips: list[Strip], + width: int, + height: int | None, + style: Style, + *, + link_style: RichStyle | None = None, + pad: bool = False, + content_align: tuple[str, str] = ("left", "top"), + ) -> list[Strip]: + """Format rendered native content without borrowing a live widget. + + Both scene paint and detached document preparation use this alignment, + link styling and padding implementation. Interaction acquisition stays + with the scene owner; the worker receives its actual values. + """ + if link_style is not None: strips = [strip._apply_link_style(link_style) for strip in strips] if height is None: @@ -248,7 +279,6 @@ def to_strips( rich_style = (style + Style(reverse=False)).rich_style if pad: strips = [strip.extend_cell_length(width, rich_style) for strip in strips] - content_align = widget.styles.content_align if content_align != ("left", "top"): align_horizontal, align_vertical = content_align strips = list( diff --git a/src/textual/widget.py b/src/textual/widget.py index 2aa3a57eb01..6cdb4d36f15 100644 --- a/src/textual/widget.py +++ b/src/textual/widget.py @@ -293,6 +293,20 @@ class Widget(DOMNode): See also [static][textual.widgets._static.Static] for starting point for your own widgets. """ + _component_style_scope = True + + @property + def layout_viewport(self) -> Size: + return self.app.viewport_size + + @property + def layout_screen_size(self) -> Size: + return self.app.size + + @property + def layout_invalidated_widgets(self): + return self.screen._layout_widgets.get(self, []) + CACHE_SUBTREE_GEOMETRY: ClassVar[bool] = False """Reuse an unchanged contained scene during unrelated sibling reflows.""" @@ -505,11 +519,6 @@ def __init__( self._layout_cache: dict[str, object] = {} """A dict that is refreshed when the widget is resized / refreshed.""" - self._visual_style: VisualStyle | None = None - """Cached style of visual.""" - self._visual_paint_state = None - """Resolved paint backing the cached visual style.""" - self._render_cache = _RenderCache(_null_size, []) # Regions which need to be updated (in Widget) self._dirty_regions: set[Region] = set() @@ -529,7 +538,6 @@ def __init__( self._styles_cache = StylesCache() self._rich_style_cache: dict[tuple[str, ...], tuple[Style, Style]] = {} - self._visual_style_cache: dict[tuple[str, ...], VisualStyle] = {} self._tooltip: VisualType | None = None """The tooltip content.""" @@ -575,16 +583,6 @@ def __init__( self._cover_widget: Widget | None = None """Widget to render over this widget (used by loading indicator).""" - self._first_of_type: tuple[int, bool] = (-1, False) - """Used to cache :first-of-type pseudoclass state.""" - self._last_of_type: tuple[int, bool] = (-1, False) - """Used to cache :last-of-type pseudoclass state.""" - self._first_child: tuple[int, bool] = (-1, False) - """Used to cache :first-child pseudoclass state.""" - self._last_child: tuple[int, bool] = (-1, False) - """Used to cache :last-child pseudoclass state.""" - self._odd: tuple[int, bool] = (-1, False) - """Used to cache :odd pseudoclass state.""" self._last_scroll_time = monotonic() """Time of last scroll.""" self._extrema = Extrema() @@ -985,87 +983,6 @@ def has_focus_within(self) -> bool: node = node._parent return False - @property - def first_of_type(self) -> bool: - """Is this the first widget of its type in its siblings?""" - parent = self.parent - if parent is None: - return True - # This pseudo classes only changes when the parent's nodes._updates changes - if parent._nodes._updates == self._first_of_type[0]: - return self._first_of_type[1] - widget_type = type(self) - for node in parent._nodes.displayed: - if isinstance(node, widget_type): - self._first_of_type = (parent._nodes._updates, node is self) - return self._first_of_type[1] - return False - - @property - def last_of_type(self) -> bool: - """Is this the last widget of its type in its siblings?""" - parent = self.parent - if parent is None: - return True - # This pseudo classes only changes when the parent's nodes._updates changes - if parent._nodes._updates == self._last_of_type[0]: - return self._last_of_type[1] - widget_type = type(self) - for node in parent._nodes.displayed_reverse: - if isinstance(node, widget_type): - self._last_of_type = (parent._nodes._updates, node is self) - return self._last_of_type[1] - return False - - @property - def first_child(self) -> bool: - """Is this the first widget in its siblings?""" - parent = self.parent - if parent is None: - return True - # This pseudo class only changes when the parent's nodes._updates changes - if parent._nodes._updates == self._first_child[0]: - return self._first_child[1] - for node in parent._nodes.displayed: - self._first_child = (parent._nodes._updates, node is self) - return self._first_child[1] - return False - - @property - def last_child(self) -> bool: - """Is this the last widget in its siblings?""" - parent = self.parent - if parent is None: - return True - # This pseudo class only changes when the parent's nodes._updates changes - if parent._nodes._updates == self._last_child[0]: - return self._last_child[1] - for node in parent._nodes.displayed_reverse: - self._last_child = (parent._nodes._updates, node is self) - return self._last_child[1] - return False - - @property - def is_odd(self) -> bool: - """Is this widget at an oddly numbered position within its siblings?""" - parent = self.parent - if parent is None: - return True - # This pseudo classes only changes when the parent's nodes._updates changes - if parent._nodes._updates == self._odd[0]: - return self._odd[1] - try: - is_odd = parent._nodes.displayed_and_visible.index(self) % 2 == 0 - self._odd = (parent._nodes._updates, is_odd) - return is_odd - except ValueError: - return False - - @property - def is_even(self) -> bool: - """Is this widget at an evenly numbered position within its siblings?""" - return not self.is_odd - def __enter__(self) -> Self: """Use as context manager when composing.""" self.app._compose_stacks[-1].append(self) @@ -1271,99 +1188,6 @@ def get_component_rich_style( return partial_style if partial else style - def get_visual_style( - self, *component_classes: str, partial: bool = False - ) -> VisualStyle: - """Get the visual style for the widget, including any component styles. - - Args: - component_classes: Optional component styles. - partial: Return a partial style (not combined with parent). - - Returns: - A Visual style instance. - - """ - cache_key = (self._pseudo_classes_cache_key, component_classes, partial) - if (visual_style := self._visual_style_cache.get(cache_key, None)) is None: - background = Color(0, 0, 0, 0) - color = Color(255, 255, 255, 0) - - style = Style() - opacity = 1.0 - - def iter_styles() -> Iterable[StylesBase]: - """Iterate over the styles from the DOM and additional components styles.""" - if partial: - node = self - else: - for node in reversed(self.ancestors_with_self): - yield node.styles - for name in component_classes: - yield node.get_component_styles(name) - - for styles in iter_styles(): - has_rule = styles.has_rule - opacity *= styles.opacity - if has_rule("background"): - text_background = background + styles.background.tint( - styles.background_tint - ) - if partial: - background_tint = styles.background.tint(styles.background_tint) - background = background.blend( - background_tint, 1 - background_tint.a - ).multiply_alpha(opacity) - else: - background += ( - styles.background.tint(styles.background_tint) - ).multiply_alpha(opacity) - else: - text_background = background - if has_rule("color"): - color = styles.color.multiply_alpha(styles.text_opacity) - style += styles.text_style - if has_rule("auto_color") and styles.auto_color: - color = text_background.get_contrast_text(color.a) - - visual_style = replace( - VisualStyle.from_rich_style(style), - background=background, - foreground=color, - ) - self._visual_style_cache[cache_key] = visual_style - - return visual_style - - def _get_style(self, style: VisualStyle | str) -> VisualStyle: - """A get_style method for use in Content. - - Args: - style: A style prefixed with a dot. - - Returns: - A visual style if one is fund, otherwise `None`. - """ - if isinstance(style, VisualStyle): - return style - - if style.startswith("."): - style_name = style[1:] - for node in self.walk_ancestors(with_self=True): - if not isinstance(node, Widget): - break - try: - return node.get_visual_style(style_name, partial=True) - except KeyError: - continue - else: - raise KeyError(f"No matching component class found for '{style}'") - return NULL_STYLE - try: - return VisualStyle.parse(style) - except Exception: - return NULL_STYLE - @overload def render_str(self, text_content: str) -> Content: ... @@ -2000,113 +1824,9 @@ def _get_box_model( model, self._extrema = cached_measurement return model - styles = self.styles - is_border_box = styles.box_sizing == "border-box" - gutter = styles.gutter # Padding plus border - margin = styles.margin - - styles_width = styles.width - if not greedy and styles_width is not None and styles_width.is_fraction: - styles_width = Scalar.parse("auto") - is_auto_width = styles_width and styles_width.is_auto - is_auto_height = styles.height and styles.height.is_auto - - # Container minus padding and border - content_container = container - gutter.totals - - extrema = self._extrema = self._resolve_extrema( - container, viewport, width_fraction, height_fraction - ) - min_width, max_width, min_height, max_height = extrema - - if styles_width is None: - # No width specified, fill available space - content_width = Fraction(content_container.width - margin.width) - elif is_auto_width: - # When width is auto, we want enough space to always fit the content - content_width = Fraction( - self.get_content_width(content_container - margin.totals, viewport) - ) - if ( - styles.overflow_x == "auto" and styles.scrollbar_gutter == "stable" - ) or self.show_vertical_scrollbar: - content_width += styles.scrollbar_size_vertical - if ( - content_width < content_container.width - and self._has_relative_children_width - ): - content_width = Fraction(content_container.width) - else: - # An explicit width - content_width = styles_width.resolve( - container - margin.totals, viewport, width_fraction - ) - if is_border_box: - content_width -= gutter.width - - if min_width is not None: - # Restrict to minimum width, if set - content_width = max(content_width, min_width, Fraction(0)) - - if max_width is not None and not ( - container.width == 0 - and not styles.max_width.is_cells - and self._parent is not None - and self._parent.styles.is_auto_width - ): - # Restrict to maximum width, if set - content_width = min(content_width, max_width) - - content_width = max(Fraction(0), content_width) - - if constrain_width: - content_width = min(Fraction(container.width - gutter.width), content_width) - - if styles.height is None: - # No height specified, fill the available space - content_height = Fraction(content_container.height - margin.height) - elif is_auto_height: - # Calculate dimensions based on content - content_height = Fraction( - self.get_content_height( - content_container - margin.totals, - viewport, - int(content_width), - ) - ) - if ( - styles.overflow_y == "auto" and styles.scrollbar_gutter == "stable" - ) or self.show_horizontal_scrollbar: - content_height += styles.scrollbar_size_horizontal - if ( - content_height < content_container.height - and self._has_relative_children_height - ): - content_height = Fraction(content_container.height) - else: - styles_height = styles.height - # Explicit height set - content_height = styles_height.resolve( - container - margin.totals, viewport, height_fraction - ) - if is_border_box: - content_height -= gutter.height - - if min_height is not None: - # Restrict to minimum height, if set - content_height = max(content_height, min_height, Fraction(0)) - - if max_height is not None and not ( - container.height == 0 - and not styles.max_height.is_cells - and self._parent is not None - and self._parent.styles.is_auto_height - ): - content_height = min(content_height, max_height) - - content_height = max(Fraction(0), content_height) - model = BoxModel( - content_width + gutter.width, content_height + gutter.height, margin + model, extrema = BoxModel.resolve( + self, container, viewport, width_fraction, height_fraction, + constrain_width, greedy, ) self._box_model_cache[cache_key] = model, extrema return model @@ -2124,20 +1844,14 @@ def get_content_width(self, container: Size, viewport: Size) -> int: """ if self.is_container: - width = self.layout.get_content_width(self, container, viewport) + width = self._measure_content_width(container, viewport) return width cache_key = container.width if self._content_width_cache[0] == cache_key: return self._content_width_cache[1] - visual = self._render() - width = visual.get_optimal_width(self.styles, container.width) - - if self.expand: - width = max(container.width, width) - if self.shrink: - width = min(width, container.width) + width = self._measure_content_width(container, viewport) self._content_width_cache = (cache_key, width) @@ -2159,20 +1873,14 @@ def get_content_height(self, container: Size, viewport: Size, width: int) -> int return 0 if self.is_container: assert self.layout is not None - height = self.layout.get_content_height( - self, - container, - viewport, - width, - ) + height = self._measure_content_height(container, viewport, width) else: cache_key = width if self._content_height_cache[0] == cache_key: return self._content_height_cache[1] - visual = self._render() - height = visual.get_height(self.styles, width) + height = self._measure_content_height(container, viewport, width) self._content_height_cache = (cache_key, height) return height @@ -2642,26 +2350,6 @@ def _console(self) -> Console: """ return self.app.console - @property - def _has_relative_children_width(self) -> bool: - """Do any children (or progeny) have a relative width?""" - if not self.is_container: - return False - for child in self.children: - if child.styles.expand == "optimal": - continue - styles = child.styles - if not child.display: - continue - width = styles.width - if width is None: - continue - if styles.is_relative_width or ( - width.is_auto and child._has_relative_children_width - ): - return True - return False - @property def _has_relative_children_height(self) -> bool: """Do any children (or progeny) have a relative height?""" @@ -2683,34 +2371,7 @@ def _relative_children_height(self) -> tuple[bool, bool]: if native and "relative_children" in answers: return answers["relative_children"], True - if not self.is_container: - result = False - else: - result = False - for child in self.children: - styles = child.styles - native = native and ( - type(child).display is DOMNode.display - and type(styles) is RenderStyles - ) - if not child.display: - continue - height = styles.height - if height is None: - continue - native = native and type(height) is Scalar - if styles.is_relative_height: - result = True - break - if height.is_auto: - if type(child)._has_relative_children_height is Widget._has_relative_children_height: - relative, child_native = child._relative_children_height() - else: - relative, child_native = child._has_relative_children_height, False - native = native and child_native - if relative: - result = True - break + result, native = self._scan_relative_children_height(native) if native: answers["relative_children"] = result return result, native @@ -2746,43 +2407,9 @@ def _resolve_extrema( Extrema object. """ - min_width: Fraction | None = None - max_width: Fraction | None = None - min_height: Fraction | None = None - max_height: Fraction | None = None - - styles = self.styles - container -= styles.margin.totals - if styles.box_sizing == "border-box": - gutter_width, gutter_height = styles.gutter.totals - else: - gutter_width = gutter_height = 0 - - if styles.min_width is not None: - min_width = ( - styles.min_width.resolve(container, viewport, width_fraction) - - gutter_width - ) - - if styles.max_width is not None: - max_width = ( - styles.max_width.resolve(container, viewport, width_fraction) - - gutter_width - ) - if styles.min_height is not None: - min_height = ( - styles.min_height.resolve(container, viewport, height_fraction) - - gutter_height - ) - - if styles.max_height is not None: - max_height = ( - styles.max_height.resolve(container, viewport, height_fraction) - - gutter_height - ) - - extrema = Extrema(min_width, max_width, min_height, max_height) - return extrema + return Extrema.resolve( + self.styles, container, viewport, width_fraction, height_fraction, + ) def animate( self, @@ -2898,48 +2525,6 @@ def _get_layer_order(self, sources: Iterable[DOMNode]) -> tuple[str, ...] | None order = node.styles.layers return order - @property - def link_style(self) -> Style: - """Style of links. - - Returns: - Rich style. - """ - styles = self.styles - _, background = self.background_colors - link_background = background + styles.link_background - link_color = link_background + ( - link_background.get_contrast_text(styles.link_color.a) - if styles.auto_link_color - else styles.link_color - ) - style = styles.link_style + Style.from_color( - link_color.rich_color, - link_background.rich_color if styles.link_background.a else None, - ) - return style - - @property - def link_style_hover(self) -> Style: - """Style of links underneath the mouse cursor. - - Returns: - Rich Style. - """ - styles = self.styles - _, background = self.background_colors - hover_background = background + styles.link_background_hover - hover_color = hover_background + ( - hover_background.get_contrast_text(styles.link_color_hover.a) - if styles.auto_link_color_hover - else styles.link_color_hover - ) - style = styles.link_style_hover + Style.from_color( - hover_color.rich_color, - hover_background.rich_color, - ) - return style - @property def select_container(self) -> Widget: """The widget's container used when selecting text.. @@ -4489,23 +4074,6 @@ def _scroll_update(self, virtual_size: Size) -> None: self.scroll_x = self.validate_scroll_x(self.scroll_x) self.scroll_y = self.validate_scroll_y(self.scroll_y) - @property - def visual_style(self) -> VisualStyle: - """The widget's current style.""" - resolved = self._resolved_paint_state() - if ( - self._visual_style is None - or not resolved.same_paint(self._visual_paint_state) - ): - background, color, style = resolved.background, resolved.foreground, resolved.text_style - self._visual_style = replace( - VisualStyle.from_rich_style(style), - background=background, - foreground=color, - ) - self._visual_paint_state = resolved - return self._visual_style - def get_selection(self, selection: Selection) -> tuple[str, str] | None: """Get the text under the selection. @@ -4520,12 +4088,7 @@ def get_selection(self, selection: Selection) -> tuple[str, str] | None: Returns: Tuple of extracted text and ending (typically "\n" or " "), or `None` if no text could be extracted. """ - visual = self._render() - if isinstance(visual, (Text, Content)): - text = str(visual) - else: - return None - return selection.extract(text), "\n" + return Visual.selected_text(self._render(), selection) def selection_updated(self, selection: Selection | None) -> None: """Called when the selection is updated. @@ -4752,10 +4315,7 @@ def render(self) -> RenderResult: """ if self.is_container: - if self.styles.layout and self.styles.keyline[0] != "none": - return self.layout.render_keyline(self) - else: - return Blank(self.background_colors[1]) + return self._render_container() return self.css_identifier_styled def _render(self) -> Visual: diff --git a/src/textual/widgets/_markdown.py b/src/textual/widgets/_markdown.py index 6565c2cc15b..7673c5877db 100644 --- a/src/textual/widgets/_markdown.py +++ b/src/textual/widgets/_markdown.py @@ -8,7 +8,7 @@ from functools import partial from pathlib import Path, PurePath from types import MethodType -from typing import AsyncIterator, Callable, Iterable, Optional +from typing import TYPE_CHECKING, AsyncIterator, Callable, Iterable, Optional, Sequence from urllib.parse import unquote from markdown_it import MarkdownIt @@ -33,6 +33,10 @@ from textual.widgets import Static, Tree from textual.widgets._label import Label +if TYPE_CHECKING: + from textual.document._markdown import MarkdownDocument, MarkdownSourceBlock + from textual.document._paint import DocumentPaint + TableOfContentsType: TypeAlias = "list[tuple[int, str, str | None]]" """Information about the table of contents of a markdown document. @@ -129,8 +133,11 @@ class MarkdownLocation: anchor: str = "" def resolve(self, current: MarkdownLocation) -> MarkdownLocation: - path = (current.path if self.path == Path(".") and self.anchor - else current.path.parent / self.path) + path = ( + current.path + if self.path == Path(".") and self.anchor + else current.path.parent / self.path + ) return MarkdownLocation(path.absolute(), self.anchor) async def load(self, document: Markdown) -> None: @@ -238,6 +245,7 @@ def __init__( token: Token, source_range: tuple[int, int] | None = None, *args, + source_block: MarkdownSourceBlock | None = None, **kwargs, ) -> None: self._markdown_ref = weakref.ref(markdown) @@ -246,8 +254,12 @@ def __init__( self._token: Token = token self._blocks: list[MarkdownBlock] = [] self._inline_token: Token | None = None - self.source_range: tuple[int, int] = source_range or ( - (token.map[0], token.map[1]) if token.map is not None else (0, 0) + self._source_block = source_block + self.source_range: tuple[int, int] = ( + source_block.source_range if source_block is not None else + source_range or ( + (token.map[0], token.map[1]) if token.map is not None else (0, 0) + ) ) super().__init__( @@ -258,6 +270,57 @@ def __init__( **kwargs, ) + def _is_block_type(self, declaration: type[MarkdownBlock]) -> bool: + return isinstance(self, declaration) + + @property + def source_block(self) -> MarkdownSourceBlock | None: + """The actual acquired member, while this scene still owns its source. + + A normal update or append revokes the detached-source relation even + when text or line ranges happen to agree. No descendant or placement + projection can recreate it. + """ + source = self._source_block + return ( + source if source is not None + and self._markdown.is_current_document(source.document) else None + ) + + @classmethod + async def from_source( + cls, markdown: Markdown, source: MarkdownSourceBlock + ) -> MarkdownBlock: + """Construct genuine controls from this original acquired grammar member. + + Custom constructors receive their original grammar arguments and must + accept the source binding, or declare their own scene factory. Native + composition and callbacks still run on the real scene widgets. + """ + if source.declaration is not cls: + raise TypeError("Scene declaration differs from its acquired grammar member") + await asyncio.sleep(0) + block = cls( + markdown, source._token, *source._arguments, source_block=source + ) + if source._inline_token is not None: + block.build_from_token(source._inline_token) + block._blocks = [ + await child.declaration.from_source(markdown, child) + for child in source._blocks + ] + if source.id is not None: + block.id = source.id + return block + + def heading_id(self) -> str: + return self.make_heading_id(self._content, id(self)) + + @staticmethod + def make_heading_id(content: Content, identity: object) -> str: + """Original slug plus the actual source/scene lifetime identity.""" + return f"heading-{slug_for_tcss_id(content.plain)}-{identity}" + @property def _markdown(self) -> Markdown: """Resolve the weak ref to _markdown""" @@ -272,19 +335,49 @@ def select_container(self) -> Widget: @property def source(self) -> str | None: """The source of this block if known, otherwise `None`.""" + source = self.source_block + if source is not None: + return source.source_text() if self.source_range is None: return None - start, end = self.source_range - return "".join(self._markdown.source.splitlines(keepends=True)[start:end]) + return self.source_text(self._markdown.source, self.source_range) + + @staticmethod + def source_text(source: str, source_range: tuple[int, int]) -> str: + start, end = source_range + return "".join(source.splitlines(keepends=True)[start:end]) def _copy_context(self, block: MarkdownBlock) -> None: """Copy the context from another block.""" self._token = block._token + self._source_block = block._source_block def compose(self) -> ComposeResult: yield from self._blocks self._blocks.clear() + @classmethod + def document_node(cls, block, *, compose=compose): + """Use native content/composition; custom behavior supplies this producer. + + The acquired source node is a different lifetime from a scene widget. + Custom constructors, renderers, measurements and disclosure must declare + their own data-only preparation rather than run on a counterfeit widget. + """ + cls._require_native_document() + cls._require_document_methods({"compose": compose}) + return cls.native_document_node(block) + + @classmethod + def native_document_node(cls, block): + """Shared native composition for explicit prepared-content declarations.""" + return block.node(children=[child.prepare() for child in block._blocks]) + + @classmethod + def document_declarations(cls): + """Actual style declarations constructed by this document producer.""" + return (cls,) + def set_content(self, content: Content) -> None: self._content = content self.update(content) @@ -295,7 +388,7 @@ async def _update_from_block(self, block: MarkdownBlock) -> None: async def action_link(self, href: str) -> None: """Called on link click.""" - self.post_message(Markdown.LinkClicked(self._markdown, href)) + self._markdown.action_link(href) def build_from_token(self, token: Token) -> None: """Build inline block content from its source token. @@ -304,7 +397,13 @@ def build_from_token(self, token: Token) -> None: token: The token from which this block is built. """ self._inline_token = token - content = self._markdown._get_token_content(token, block=self) + source = self._source_block + if source is not None and token is not source._inline_token: + self._source_block = source = None + content = ( + self._markdown._get_token_content(token, block=self) + if source is None else source._content + ) self.set_content(content) @staticmethod @@ -392,12 +491,62 @@ def close_tag() -> None: content = Content("".join(tokens), spans=spans) return content + @classmethod + def _require_native_document( + cls, + *, + constructor=__init__, + render=Static.render, + width=Widget.get_content_width, + height=Widget.get_content_height, + box=Widget._get_box_model, + pre_layout=Widget.pre_layout, + process_layout=Widget.process_layout, + selection=Widget.get_selection, + build_from_token=build_from_token, + set_content=set_content, + content=_token_to_content, + visual=Static.visual, + container=Widget.is_container, + rendering=Widget._render, + empty=Widget.is_empty, + pseudo_classes=Widget.get_pseudo_classes, + ): + cls._require_document_methods( + { + "__init__": constructor, + "render": render, + "get_content_width": width, + "get_content_height": height, + "_get_box_model": box, + "pre_layout": pre_layout, + "process_layout": process_layout, + "get_selection": selection, + "build_from_token": build_from_token, + "set_content": set_content, + "_token_to_content": content.__func__, + "visual": visual, + "is_container": container, + "_render": rendering, + "is_empty": empty, + "get_pseudo_classes": pseudo_classes, + } + ) + class MarkdownHeader(MarkdownBlock): """Base class for a Markdown header.""" LEVEL = 0 + @classmethod + def make_heading_entry(cls, content: Content, block_id: str | None): + """Heading wording belongs to the block's actual acquired Content.""" + return cls.LEVEL, content.plain, block_id + + def table_of_contents_entry(self): + return self.make_heading_entry(self._content, self.id) + DEFAULT_CSS = """ MarkdownHeader { color: $text; @@ -561,6 +710,15 @@ class MarkdownList(MarkdownBlock): } """ + @staticmethod + def compose_rows(rows, make_bullet, make_column, make_row): + for symbol, blocks in rows: + yield make_row(make_bullet(symbol), make_column(blocks)) + + @classmethod + def document_declarations(cls): + return (cls, Horizontal, Vertical, MarkdownBullet) + class MarkdownBulletList(MarkdownList): """A Bullet list Markdown block.""" @@ -582,14 +740,27 @@ class MarkdownBulletList(MarkdownList): } """ + @staticmethod + def list_rows(blocks): + for block in blocks: + if block._is_block_type(MarkdownListItem): + yield block.bullet, block._blocks + def compose(self) -> ComposeResult: - for block in self._blocks: - if isinstance(block, MarkdownListItem): - bullet = MarkdownBullet() - bullet.symbol = block.bullet - yield Horizontal(bullet, Vertical(*block._blocks)) + yield from self.compose_rows( + self.list_rows(self._blocks), + MarkdownBullet.from_symbol, + lambda blocks: Vertical(*blocks), + Horizontal, + ) self._blocks.clear() + @classmethod + def document_node(cls, block, *, _compose=compose): + cls._require_native_document() + cls._require_document_methods({"compose": _compose}) + return block.list_node(cls.list_rows(block._blocks)) + class MarkdownOrderedList(MarkdownList): """An ordered list Markdown block.""" @@ -611,27 +782,39 @@ class MarkdownOrderedList(MarkdownList): } """ - def compose(self) -> ComposeResult: + @staticmethod + def list_rows(blocks): suffix = ". " start = 1 - if self._blocks and isinstance(self._blocks[0], MarkdownOrderedListItem): + if blocks and blocks[0]._is_block_type(MarkdownOrderedListItem): try: - start = int(self._blocks[0].bullet) + start = int(blocks[0].bullet) except ValueError: pass symbol_size = max( len(f"{number}{suffix}") - for number, block in enumerate(self._blocks, start) - if isinstance(block, MarkdownListItem) + for number, block in enumerate(blocks, start) + if block._is_block_type(MarkdownListItem) ) - for number, block in enumerate(self._blocks, start): - if isinstance(block, MarkdownListItem): - bullet = MarkdownBullet() - bullet.symbol = f"{number}{suffix}".rjust(symbol_size + 1) - yield Horizontal(bullet, Vertical(*block._blocks)) + for number, block in enumerate(blocks, start): + if block._is_block_type(MarkdownListItem): + yield f"{number}{suffix}".rjust(symbol_size + 1), block._blocks + def compose(self) -> ComposeResult: + yield from self.compose_rows( + self.list_rows(self._blocks), + MarkdownBullet.from_symbol, + lambda blocks: Vertical(*blocks), + Horizontal, + ) self._blocks.clear() + @classmethod + def document_node(cls, block, *, _compose=compose): + cls._require_native_document() + cls._require_document_methods({"compose": _compose}) + return block.list_node(cls.list_rows(block._blocks)) + class MarkdownTableCellContents(Static): """Widget for table cells. @@ -641,7 +824,7 @@ class MarkdownTableCellContents(Static): async def action_link(self, href: str) -> None: """Pass a link action on to the MarkdownTable parent.""" - self.post_message(Markdown.LinkClicked(self.query_ancestor(Markdown), href)) + self.query_ancestor(Markdown).action_link(href) class MarkdownTableContent(Widget): @@ -689,25 +872,38 @@ def __init__(self, headers: list[Content], rows: list[list[Content]]): self.last_row = 0 def pre_layout(self, layout: Layout) -> None: + self.prepare_grid( + layout, self.query_ancestor(MarkdownTable).styles.is_auto_width + ) + + @staticmethod + def cells(headers, rows): + for header in headers: + yield header, "header", None, header, 0 + for row_index, row in enumerate(rows, 1): + for cell_index, cell in enumerate(row, 1): + yield ( + cell, + f"row{row_index} cell", + f"cell{row_index}.{cell_index}", + cell.plain, + row_index, + ) + + @staticmethod + def prepare_grid(layout: Layout, auto_width: bool) -> None: assert isinstance(layout, GridLayout) layout.auto_minimum = True - layout.expand = not self.query_ancestor(MarkdownTable).styles.is_auto_width + layout.expand = not auto_width layout.shrink = True layout.stretch_height = True def compose(self) -> ComposeResult: - for header in self.headers: - yield MarkdownTableCellContents(header, classes="header").with_tooltip( - header - ) - for row_index, row in enumerate(self.rows, 1): - for cell_index, cell in enumerate(row, 1): - yield MarkdownTableCellContents( - cell, - classes=f"row{row_index} cell", - name=f"cell{row_index}.{cell_index}", - ).with_tooltip(cell.plain) - self.last_row = row_index + for content, classes, name, tooltip, row in self.cells(self.headers, self.rows): + yield MarkdownTableCellContents( + content, classes=classes, name=name + ).with_tooltip(tooltip) + self.last_row = row def _update_content(self, headers: list[Content], rows: list[list[Content]]): """Update cell contents.""" @@ -770,6 +966,10 @@ def compose(self) -> ComposeResult: yield MarkdownTableContent(headers, rows) def _get_headers_and_rows(self) -> tuple[list[Content], list[list[Content]]]: + return self.table_contents(self) + + @staticmethod + def table_contents(source) -> tuple[list[Content], list[list[Content]]]: """Get list of headers, and list of rows. Returns: @@ -784,17 +984,31 @@ def flatten(block: MarkdownBlock) -> Iterable[MarkdownBlock]: headers: list[Content] = [] rows: list[list[Content]] = [] - for block in flatten(self): - if isinstance(block, MarkdownTH): + for block in flatten(source): + if block._is_block_type(MarkdownTH): headers.append(block._content) - elif isinstance(block, MarkdownTR): + elif block._is_block_type(MarkdownTR): rows.append([]) - elif isinstance(block, MarkdownTD): + elif block._is_block_type(MarkdownTD): rows[-1].append(block._content) if rows and not rows[-1]: rows.pop() return headers, rows + @classmethod + def document_node( + cls, block, *, _compose=compose, _contents=table_contents, constructor=__init__ + ): + cls._require_native_document(constructor=constructor) + cls._require_document_methods( + {"compose": _compose, "table_contents": _contents.__func__} + ) + return block.table_node(*cls.table_contents(block)) + + @classmethod + def document_declarations(cls): + return (cls, MarkdownTableContent, MarkdownTableCellContents) + async def _update_from_block(self, block: MarkdownBlock) -> None: """Special case to update a Markdown table. @@ -853,8 +1067,18 @@ class MarkdownBullet(Widget): symbol = reactive("\u25cf") """The symbol for the bullet.""" + @classmethod + def from_symbol(cls, symbol: str): + bullet = cls() + bullet.symbol = symbol + return bullet + def get_selection(self, _selection) -> tuple[str, str] | None: - return self.symbol, " " + return self.selection_text(self.symbol, _selection) + + @staticmethod + def selection_text(symbol, selection): + return str(symbol), " " def render(self) -> Content: return Content(self.symbol) @@ -876,9 +1100,20 @@ class MarkdownListItem(MarkdownBlock): } """ - def __init__(self, markdown: Markdown, token: Token, bullet: str) -> None: + def __init__( + self, markdown: Markdown, token: Token, bullet: str, *, + source_block: MarkdownSourceBlock | None = None, + ) -> None: self.bullet = bullet - super().__init__(markdown, token) + super().__init__(markdown, token, source_block=source_block) + + @classmethod + def document_node( + cls, block, *, constructor=__init__, compose=MarkdownBlock.compose + ): + cls._require_native_document(constructor=constructor) + cls._require_document_methods({"compose": compose}) + return block.node(children=[child.prepare() for child in block._blocks]) class MarkdownOrderedListItem(MarkdownListItem): @@ -921,8 +1156,11 @@ class MarkdownFence(MarkdownBlock): } """ - def __init__(self, markdown: Markdown, token: Token, code: str) -> None: - super().__init__(markdown, token) + def __init__( + self, markdown: Markdown, token: Token, code: str, *, + source_block: MarkdownSourceBlock | None = None, + ) -> None: + super().__init__(markdown, token, source_block=source_block) self.code = code self.lexer = token.info self._highlighted_key: tuple[str, str, bool, bool] | None = None @@ -938,15 +1176,28 @@ def notify_style_update(self) -> None: return super().notify_style_update() def _refresh_highlight(self) -> None: - key = (self.code, self.lexer, self.app.native_ansi_color, self.app.current_theme.dark) + key = ( + self.code, + self.lexer, + self.app.native_ansi_color, + self.app.current_theme.dark, + ) highlighter = self.highlight - native = isinstance(highlighter, MethodType) and highlighter.__func__ is MarkdownFence.highlight.__func__ + native = ( + isinstance(highlighter, MethodType) + and highlighter.__func__ is MarkdownFence.highlight.__func__ + ) if native and self._highlighted_key == key: return - prepared = self._markdown._get_prepared_fence(*key) if native else None + source = self._source_block + prepared = ( + source.highlight(key[2], key[3]) if source is not None else + self._markdown._get_prepared_fence(*key) if native else None + ) self._highlighted_code = ( - prepared if prepared is not None else - self.highlight(self.code, self.lexer, ansi=key[2], dark=key[3]) + prepared + if prepared is not None + else self.highlight(self.code, self.lexer, ansi=key[2], dark=key[3]) ) self._highlighted_key = key @@ -989,11 +1240,34 @@ def set_content(self, content: Content) -> None: with suppress(NoMatches): label = self.query_one("#code-content", Label) if label.content is not content: - layout = not (isinstance(label.content, Content) and label.content.plain == content.plain) + layout = not ( + isinstance(label.content, Content) + and label.content.plain == content.plain + ) label.update(content, layout=layout) def compose(self) -> ComposeResult: - yield Label(self._highlighted_code, id="code-content", expand=True) + yield self.code_label(self._highlighted_code, Label) + + @staticmethod + def code_label(content, create): + return create(content, id="code-content", expand=True) + + @classmethod + def document_node( + cls, block, *, _compose=compose, constructor=__init__, content=set_content + ): + cls._require_native_document(constructor=constructor, set_content=content) + cls._require_document_methods({"compose": _compose}) + return cls.native_document_node(block) + + @classmethod + def native_document_node(cls, block): + return block.fence_node() + + @classmethod + def document_declarations(cls): + return (cls, Label) NUMERALS = " ⅠⅡⅢⅣⅤⅥ" @@ -1092,18 +1366,30 @@ def __init__( self._open_links = open_links self._last_parsed_line = 0 self._theme = "" + self._scene_document: MarkdownDocument | None = None @property def table_of_contents(self) -> TableOfContentsType: """The document's table of contents.""" if self._table_of_contents is None: - self._table_of_contents = [ - (header.LEVEL, header._content.plain, header.id) - for header in self.children - if isinstance(header, MarkdownHeader) - ] + self._table_of_contents = list(self.heading_entries(self.children)) return self._table_of_contents + @staticmethod + def heading_entries(blocks): + """Only original top-level headings participate in native navigation.""" + for block in blocks: + if block._is_block_type(MarkdownHeader): + yield block.table_of_contents_entry() + + @staticmethod + def anchor_id_for(table_of_contents, anchor: str) -> str | None: + unique = TrackedSlugs() + for _, title, header_id in table_of_contents: + if unique.slug(title) == anchor: + return header_id + return None + class TableOfContentsUpdated(Message): """The table of contents was updated.""" @@ -1253,7 +1539,9 @@ def on_markdown_link_clicked(self, event: LinkClicked) -> None: self.app.open_url(event.href) @staticmethod - def sanitize_location(location: str | PurePath | MarkdownLocation) -> MarkdownLocation: + def sanitize_location( + location: str | PurePath | MarkdownLocation, + ) -> MarkdownLocation: """Given a location, break out the path and any anchor. Args: @@ -1289,11 +1577,10 @@ def goto_anchor(self, anchor: str) -> bool: """ if not self._table_of_contents or not isinstance(self.parent, Widget): return False - unique = TrackedSlugs() - for _, title, header_id in self._table_of_contents: - if unique.slug(title) == anchor: - self.query_one(f"#{header_id}").scroll_visible(top=True) - return True + header_id = self.anchor_id_for(self._table_of_contents, anchor) + if header_id is not None: + self.query_one(f"#{header_id}").scroll_visible(top=True) + return True return False async def load(self, path: Path | MarkdownLocation) -> None: @@ -1319,6 +1606,88 @@ def _get_token_content(self, token: Token, *, block: MarkdownBlock) -> Content: """ return block._token_to_content(token) + def acquire_document_content(self): + """Acquire a data-only prepared inline supplier, not a live widget hook.""" + if type(self)._get_token_content is not Markdown._get_token_content: + raise TypeError( + f"{type(self).__name__} must supply acquire_document_content" + ) + return None + + def acquire_document_fences(self): + """Acquire a data-only prepared fence supplier, or native highlighting.""" + return None + + def acquire_document_unhandled(self): + """Data-only source extension for a custom parsed token grammar.""" + if type(self).unhandled_token is not Markdown.unhandled_token: + raise TypeError( + f"{type(self).__name__} must supply acquire_document_unhandled" + ) + return None + + def acquire_document_blocks(self): + """The original declaration catalog; dynamic factories supply their snapshot.""" + if type(self).get_block_class is not Markdown.get_block_class: + raise TypeError( + f"{type(self).__name__} must supply acquire_document_blocks" + ) + return self.BLOCKS.copy() + + def get_document_process_layout(self): + """Data-only layout processing; custom scene hooks must supply it.""" + if type(self).process_layout is not Widget.process_layout: + raise TypeError( + f"{type(self).__name__} must supply get_document_process_layout" + ) + return None + + @classmethod + def document_root( + cls, + document, + children, + *, + render=Widget.render, + width=Widget.get_content_width, + height=Widget.get_content_height, + pre_layout=Widget.pre_layout, + empty=Widget.is_empty, + pseudo_classes=Widget.get_pseudo_classes, + ): + """Native immutable root; custom scene semantics supply this producer.""" + cls._require_document_methods( + { + "render": render, + "get_content_width": width, + "get_content_height": height, + "pre_layout": pre_layout, + "is_empty": empty, + "get_pseudo_classes": pseudo_classes, + } + ) + return cls.native_document_root(document, children) + + @classmethod + def native_document_root(cls, document, children): + """Shared intrinsic block layout, independent of scene BodyMeasurement.""" + return document.root_node(children) + + def acquire_document(self, source: str, tokens: Sequence[Token]): + """Freeze source, declarations and effective presentation for workers. + + Tokens belong to the actual parser/link resolver. This method never + reparses the source or reads a current file to replace their wording. + The result prepares paint/extents without mounting any descendants. + """ + from textual.document._markdown import MarkdownDocument + + return MarkdownDocument.acquire(self, source, tokens) + + def action_link(self, href: str) -> None: + """Dispatch a link from mounted blocks or original detached placements.""" + self.post_message(Markdown.LinkClicked(self, href)) + def unhandled_token(self, token: Token) -> MarkdownBlock | None: """Process an unhandled token. @@ -1330,104 +1699,111 @@ def unhandled_token(self, token: Token) -> MarkdownBlock | None: """ return None - async def _parse_markdown(self, tokens: Iterable[Token]) -> AsyncIterator[MarkdownBlock]: - """Create a stream of MarkdownBlock widgets from markdown. - - Args: - tokens: List of tokens. + @staticmethod + def _build_blocks(tokens: Iterable[Token], create, unhandled, bullets): + """The one native token grammar for scene and detached resources. - Yields: - Widgets for mounting. + Factory and unknown-token behavior are acquired by their actual owner. + None marks token progress; scene construction retains cooperative turns. """ - stack: list[MarkdownBlock] = [] stack_append = stack.append - get_block_class = self.get_block_class - for token in tokens: - # One root may own a whole nested list or table. Resume other pumps - # between token-owned construction, not after a fixed root count. - await asyncio.sleep(0) + emitted = None token_type = token.type if token_type == "heading_open": - stack_append(get_block_class(token.tag)(self, token)) + stack_append(create(token.tag, token)) elif token_type == "hr": - yield get_block_class("hr")(self, token) + emitted = create("hr", token) elif token_type == "paragraph_open": - stack_append(get_block_class("paragraph_open")(self, token)) + stack_append(create("paragraph_open", token)) elif token_type == "blockquote_open": - stack_append(get_block_class("blockquote_open")(self, token)) + stack_append(create("blockquote_open", token)) elif token_type == "bullet_list_open": - stack_append(get_block_class("bullet_list_open")(self, token)) + stack_append(create("bullet_list_open", token)) elif token_type == "ordered_list_open": - stack_append(get_block_class("ordered_list_open")(self, token)) + stack_append(create("ordered_list_open", token)) elif token_type == "list_item_open": if token.info: - stack_append( - get_block_class("list_item_ordered_open")( - self, token, token.info - ) - ) + stack_append(create("list_item_ordered_open", token, token.info)) else: item_count = sum( 1 for block in stack - if isinstance(block, MarkdownUnorderedListItem) + if block._is_block_type(MarkdownUnorderedListItem) ) stack_append( - get_block_class("list_item_unordered_open")( - self, + create( + "list_item_unordered_open", token, - self.BULLETS[item_count % len(self.BULLETS)], + bullets[item_count % len(bullets)], ) ) elif token_type == "table_open": - stack_append(get_block_class("table_open")(self, token)) + stack_append(create("table_open", token)) elif token_type == "tbody_open": - stack_append(get_block_class("tbody_open")(self, token)) + stack_append(create("tbody_open", token)) elif token_type == "thead_open": - stack_append(get_block_class("thead_open")(self, token)) + stack_append(create("thead_open", token)) elif token_type == "tr_open": - stack_append(get_block_class("tr_open")(self, token)) + stack_append(create("tr_open", token)) elif token_type == "th_open": - stack_append(get_block_class("th_open")(self, token)) + stack_append(create("th_open", token)) elif token_type == "td_open": - stack_append(get_block_class("td_open")(self, token)) + stack_append(create("td_open", token)) elif token_type.endswith("_close"): block = stack.pop() if token.type == "heading_close": - block.id = ( - f"heading-{slug_for_tcss_id(block._content.plain)}-{id(block)}" - ) + block.id = block.heading_id() if stack: stack[-1]._blocks.append(block) else: - yield block + emitted = block elif token_type == "inline": stack[-1].build_from_token(token) elif token_type in ("fence", "code_block"): - fence_class = get_block_class(token_type) - assert issubclass(fence_class, MarkdownFence) - fence = fence_class(self, token, token.content.rstrip()) + fence = create(token_type, token, token.content.rstrip()) + assert fence._is_block_type(MarkdownFence) if stack: stack[-1]._blocks.append(fence) else: - yield fence + emitted = fence else: - external = self.unhandled_token(token) + external = unhandled(token) if external is not None: if stack: stack[-1]._blocks.append(external) else: - yield external + emitted = external + yield emitted + + async def _parse_markdown( + self, tokens: Iterable[Token] + ) -> AsyncIterator[MarkdownBlock]: + def create(name, token, *args): + return self.get_block_class(name)(self, token, *args) + + steps = iter( + self._build_blocks(tokens, create, self.unhandled_token, self.BULLETS) + ) + while True: + await asyncio.sleep(0) + try: + block = next(steps) + except StopIteration: + break + if block is not None: + yield block async def _parse_tokens( self, parser: MarkdownIt, markdown: str, *, use_thread: bool ) -> list[Token] | None: """Parse before constructing widgets; None discards a superseded request.""" if use_thread: - return await asyncio.get_running_loop().run_in_executor(None, parser.parse, markdown) + return await asyncio.get_running_loop().run_in_executor( + None, parser.parse, markdown + ) return parser.parse(markdown) def _get_prepared_fence( @@ -1436,6 +1812,100 @@ def _get_prepared_fence( """Optional data-only highlighting prepared by the document parser.""" return None + async def _replace_blocks(self, blocks: AsyncIterator[MarkdownBlock]) -> None: + """Commit original completed roots through the one native mount owner.""" + previous = self.query("MarkdownBlock") + removed = False + async for block in blocks: + if removed: + await self.mount(block) + else: + async with self.batch(): + await previous.remove() + await self.mount(block) + removed = True + if not removed: + await previous.remove() + + def _complete_update(self, tokens: Sequence[Token]) -> None: + # append() replaces the final root, not merely the final physical line. + self._table_of_contents = None + self._last_parsed_line = next( + ( + token.map[0] + for token in reversed(tokens) + if token.map is not None and token.level == 0 + ), + 0, + ) + self.post_message( + Markdown.TableOfContentsUpdated(self, self.table_of_contents).set_sender(self) + ) + + def materialize_document(self, paint: DocumentPaint) -> AwaitComplete: + """Rebuild interaction controls from the actual acquired grammar roots. + + Paint is the original completed grammar cohort, not a request to parse + equal source again. Its members and resolved suppliers bind directly + to the real controls; current scene CSS still owns their placement. + Ordinary update/append retain their parser, extension and streaming + contracts and invalidate this binding when they accept a new source. + """ + document = paint.document + if document.declaration is not type(self): + raise TypeError("Acquired Markdown belongs to a different scene declaration") + if any(not document.same_source(root.document) for root in paint.roots): + raise ValueError("Acquired roots belong to a different Markdown source") + previous = self._scene_document + self._scene_document = document + if not self.is_current_document(document): + self._scene_document = previous + raise ValueError("Acquired Markdown is no longer the owner's current source") + self._theme = self.app.theme + self._markdown = document.source + self._table_of_contents = None + + async def blocks(): + for source in paint.roots: + block = await source.declaration.from_source(self, source) + if not self.is_current_document(document): + return + yield block + + async def materialize(): + async with self.lock: + if not self.is_current_document(document): + return + await self._replace_blocks(blocks()) + if not self.is_current_document(document): + return + self._complete_update(document.tokens) + + return AwaitComplete(materialize()) + + def is_current_document(self, document: MarkdownDocument) -> bool: + """Admit source lineage through the Markdown owner's current resource. + + Native update/append revoke scene custody independently of a subclass + source publication. Both facts must agree; a current acquired document + cannot re-admit controls consumed by an older native source request. + """ + scene = self._scene_document + current = self.get_current_document() + return ( + scene is not None and scene.same_source(document) + and current is not None + and (current is scene or current.same_source(document)) + ) + + def get_current_document(self) -> MarkdownDocument | None: + """The current source resource, independent of style/width inputs. + + Acquired-source subclasses supply their original current document here + without duplicating native scene-admission or update/append decisions. + """ + return self._scene_document + def update(self, markdown: str) -> AwaitComplete: """Update the document with new Markdown. @@ -1452,8 +1922,8 @@ def update(self, markdown: str) -> AwaitComplete: else self._parser_factory() ) - markdown_block = self.query("MarkdownBlock") self._markdown = markdown + self._scene_document = None self._table_of_contents = None async def await_update() -> None: @@ -1465,33 +1935,9 @@ async def await_update() -> None: if tokens is None: return - # Replace existing blocks with the first completed root only. - removed: bool = False + await self._replace_blocks(self._parse_markdown(tokens)) - async for block in self._parse_markdown(tokens): - if removed: - await self.mount(block) - else: - async with self.batch(): - await markdown_block.remove() - await self.mount(block) - removed = True - if not removed: - await markdown_block.remove() - - # append() replaces the final block, not merely the final physical - # line. Keep its opening token so partial fences/paragraphs survive - # an initial update followed by streamed chunks. - self._last_parsed_line = next( - (token.map[0] for token in reversed(tokens) - if token.map is not None and token.level == 0), - 0, - ) - self.post_message( - Markdown.TableOfContentsUpdated( - self, self.table_of_contents - ).set_sender(self) - ) + self._complete_update(tokens) return AwaitComplete(await_update()) @@ -1511,6 +1957,7 @@ def append(self, markdown: str) -> AwaitComplete: ) self._markdown = self.source + markdown + self._scene_document = None updated_source = "".join( self._markdown.splitlines(keepends=True)[self._last_parsed_line :] ) @@ -1518,7 +1965,9 @@ def append(self, markdown: str) -> AwaitComplete: async def await_append() -> None: """Append new markdown widgets.""" async with self.lock: - tokens = await self._parse_tokens(parser, updated_source, use_thread=False) + tokens = await self._parse_tokens( + parser, updated_source, use_thread=False + ) if tokens is None: return existing_blocks = [ diff --git a/tests/css/test_stylesheet.py b/tests/css/test_stylesheet.py index 4925e221e91..40b03216ba4 100644 --- a/tests/css/test_stylesheet.py +++ b/tests/css/test_stylesheet.py @@ -18,6 +18,22 @@ from textual.widgets import Static +async def test_inline_style_uses_own_stylesheet_variables(): + class DifferentVariablesApp(App): + def get_css_variables(self): + return {**super().get_css_variables(), "document-color": "red"} + + stylesheet = Stylesheet(variables={"document-color": "blue"}) + async with DifferentVariablesApp().run_test(): + assert stylesheet.parse_style("$document-color").foreground == Color.parse( + "blue" + ) + stylesheet.set_variables({"document-color": "green"}) + assert stylesheet.parse_style("$document-color").foreground == Color.parse( + "green" + ) + + def test_target_rule_publication_acquires_only_declared_css_path(): class PathReads(DOMNode): path_reads = 0 diff --git a/tests/layouts/test_factory.py b/tests/layouts/test_factory.py index ab3a6667d70..ab18dd6c535 100644 --- a/tests/layouts/test_factory.py +++ b/tests/layouts/test_factory.py @@ -2,6 +2,8 @@ from textual.layouts.factory import MissingLayout, get_layout from textual.layouts.vertical import VerticalLayout +from textual.layouts.horizontal import HorizontalLayout +from textual.layouts.grid import GridLayout def test_get_layout_valid_layout(): @@ -12,3 +14,95 @@ def test_get_layout_valid_layout(): def test_get_layout_invalid_layout(): with pytest.raises(MissingLayout): get_layout("invalid") + + +def test_document_declaration_supply_tracks_c3_namespace_changes(): + class Left(VerticalLayout): + pass + + class Right(VerticalLayout): + pass + + class Diamond(Left, Right): + pass + + layout = Diamond() + original = layout.document_key() + assert original[0] is Diamond + assert type(layout.acquire_document()) is Diamond + Right.arrange = HorizontalLayout.arrange + with pytest.raises(TypeError, match="detached layout inputs"): + layout.document_key() + # Left masks Right only when it actually owns the declaration. + Left.arrange = VerticalLayout.arrange + assert layout.document_key() == original + del Left.arrange + with pytest.raises(TypeError, match="detached layout inputs"): + layout.document_key() + del Right.arrange + assert layout.document_key() == original + + +def test_document_supply_tracks_actual_base_change_and_instance_binding(): + class Reparented(HorizontalLayout): + pass + + layout = Reparented() + previous = layout.document_key() + Reparented.__bases__ = (VerticalLayout,) + current = layout.document_key() + assert current != previous + assert current[0] is Reparented + # An instance function is unbound, even if it is the original class object. + layout.arrange = VerticalLayout.arrange + with pytest.raises(TypeError, match="instance-specific"): + layout.document_key() + del layout.arrange + assert layout.document_key() == current + + +def test_document_supply_does_not_require_cooperative_subclass_hook(): + class StopsSubclassHook(VerticalLayout): + def __init_subclass__(cls, **kwargs): + pass + + class Leaf(StopsSubclassHook): + pass + + assert Leaf().document_key()[0] is Leaf + + +def test_foreign_mixin_requires_its_own_detached_contract(): + class Foreign: + pass + + class UnknownMutationOwner(Foreign, VerticalLayout): + pass + + with pytest.raises(TypeError, match="detached layout inputs"): + UnknownMutationOwner().document_key() + + +def test_unobserved_metaclass_mutation_cannot_inherit_native_admission(): + class DirectSetter(type(VerticalLayout)): + def __setattr__(cls, name, value): + type.__setattr__(cls, name, value) + + class Unobserved(VerticalLayout, metaclass=DirectSetter): + pass + + with pytest.raises(TypeError, match="detached layout inputs"): + Unobserved().document_key() + assert VerticalLayout().document_key()[0] is VerticalLayout + + +def test_document_key_reads_current_grid_configuration_without_republishing_supply(): + layout = GridLayout() + implementation = type(layout)._document_implementation + original = layout.document_key() + layout.min_column_width = 8 + assert layout.document_key() != original + assert type(layout)._document_implementation is implementation + acquired = layout.acquire_document() + assert acquired.min_column_width == 8 + assert acquired.document_key() == layout.document_key() diff --git a/tests/layouts/test_grid.py b/tests/layouts/test_grid.py index e781249ab7e..c8ce0fef4d7 100644 --- a/tests/layouts/test_grid.py +++ b/tests/layouts/test_grid.py @@ -32,3 +32,32 @@ def compose(self) -> ComposeResult: grid_layout = app.query_one(containers.Grid).layout assert isinstance(grid_layout, GridLayout) assert grid_layout.grid_size == (3, 3) + + +async def test_acquire_configured_grid_keeps_inputs_and_owns_new_geometry(): + class ConfiguredGridApp(App): + def compose(self) -> ComposeResult: + with containers.Grid(id="grid"): + yield widgets.Label("First") + yield widgets.Label("Second") + + app = ConfiguredGridApp() + async with app.run_test() as pilot: + await pilot.pause() + original = app.query_one("#grid").layout + assert isinstance(original, GridLayout) + assert original.grid_size is not None + original.min_column_width = 3 + original.max_column_width = 7 + original.stretch_height = True + original.regular = True + original.expand = True + original.shrink = True + original.auto_minimum = True + last_grid_size = original.grid_size + acquired = original.acquire_document() + assert acquired is not original + assert type(acquired) is type(original) + assert acquired.document_key() == original.document_key() + assert acquired.grid_size is None + assert original.grid_size == last_grid_size diff --git a/tests/test_geometry.py b/tests/test_geometry.py index 8f1a31768fe..e298869dcac 100644 --- a/tests/test_geometry.py +++ b/tests/test_geometry.py @@ -1,3 +1,5 @@ +from copy import deepcopy +import pickle from typing import Literal import pytest @@ -5,6 +7,16 @@ from textual.geometry import Offset, Region, Shape, Size, Spacing, clamp +@pytest.mark.parametrize( + "value", + (Offset(-3, 7), Size(31, 19), Region(-5, 11, 31, 19), Spacing(1, 2, 3, 4)), +) +def test_geometry_transport_preserves_selected_native_type(value): + for restored in (pickle.loads(pickle.dumps(value, protocol=5)), deepcopy(value)): + assert type(restored) is type(value) + assert tuple(restored) == tuple(value) + + def test_dimensions_region(): assert Size(30, 40).region == Region(0, 0, 30, 40) diff --git a/tests/test_markdown_document.py b/tests/test_markdown_document.py new file mode 100644 index 00000000000..0349453001c --- /dev/null +++ b/tests/test_markdown_document.py @@ -0,0 +1,662 @@ +"""Detached native preparation against the actual mounted Markdown owner.""" + +import asyncio +from concurrent.futures import ProcessPoolExecutor +import multiprocessing +import os +import pickle + +import pytest + +from textual.app import App, ComposeResult +from textual.containers import VerticalScroll +from textual.geometry import Offset, Size, Spacing +from textual.layouts.stream import StreamLayout +from textual.selection import SELECT_ALL, Selection +from textual.visual import Visual +from textual.widgets import Label, Markdown +from textual.widgets._markdown import MarkdownBlock, MarkdownFence + +SOURCE = """# Native document + +This **strong** paragraph has [a native link](https://example.com), `code`, and 界. + +> A quote with enough text to wrap when the acquired width changes. + +- outer item + - nested item +- second item + +3. third +4. fourth + +| left | right | +| --- | --- | +| [cell](https://example.org) | content | + +```python +print('native fence') +``` + +--- + +Last paragraph. + +## Repeated heading + +First section. + +## Repeated heading + +Second section. + +> ## Nested heading +> +> Nested headings do not appear in the top-level contents. +""" + + +class AcquiredMarkdown(Markdown): + BLOCKS = Markdown.BLOCKS.copy() + + async def _parse_tokens(self, *args, **kwargs): + tokens = await super()._parse_tokens(*args, **kwargs) + self.acquired_tokens = tokens + return tokens + + +class DocumentCodeLabel(Label): + DEFAULT_CSS = "DocumentCodeLabel { color: magenta; padding: 1 2; }" + + +class DocumentFence(MarkdownFence): + def compose(self) -> ComposeResult: + yield self.code_label(self._highlighted_code, DocumentCodeLabel) + + @classmethod + def document_node(cls, block): + return block.fence_node(label_type=DocumentCodeLabel) + + @classmethod + def document_declarations(cls): + return cls, DocumentCodeLabel + + +AcquiredMarkdown.BLOCKS.update(fence=DocumentFence, code_block=DocumentFence) + + +def painted_characters(lines): + """Compare actual paint and native metadata independent of segmentation.""" + return tuple( + tuple( + ( + character, + str(segment.style), + ( + { + key: value + for key, value in segment.style.meta.items() + if key != "document_leaf" + } + if segment.style is not None + else {} + ), + ) + for segment in line + for character in segment.text + ) + for line in lines + ) + + +class DocumentApp(App): + CSS = """ + VerticalScroll { scrollbar-size-vertical: 0; } + Markdown { margin: 0; } + """ + + def compose(self) -> ComposeResult: + with VerticalScroll(): + yield AcquiredMarkdown(SOURCE) + + +class BoundSourceMarkdown(AcquiredMarkdown): + """Observe actual scene suppliers without replacing native conversion.""" + + live_inline_calls = 0 + live_fence_calls = 0 + + def _get_token_content(self, token, *, block): + self.live_inline_calls += 1 + return super()._get_token_content(token, block=block) + + def acquire_document_content(self): + return MarkdownBlock._token_to_content + + def _get_prepared_fence(self, *args): + self.live_fence_calls += 1 + return super()._get_prepared_fence(*args) + + +class BoundSourceApp(DocumentApp): + def compose(self): + with VerticalScroll(): + yield BoundSourceMarkdown(SOURCE) + + +@pytest.mark.asyncio +async def test_scene_controls_bind_process_returned_grammar_roots(monkeypatch): + monkeypatch.delenv("NO_COLOR", raising=False) + app = BoundSourceApp() + with ProcessPoolExecutor( + max_workers=1, mp_context=multiprocessing.get_context("spawn") + ) as executor: + async with app.run_test(size=(54, 100)) as pilot: + await pilot.pause() + markdown = app.query_one(BoundSourceMarkdown) + document = markdown.acquire_document(SOURCE, markdown.acquired_tokens) + delivered = await asyncio.wrap_future( + executor.submit(document.prepare, markdown.region.width) + ) + worker_pids = tuple(executor._processes) + + calls = markdown.live_inline_calls, markdown.live_fence_calls + await markdown.materialize_document(delivered) + await pilot.pause() + assert markdown.table_of_contents == delivered.table_of_contents + original_members = {} + + def members(source): + original_members[source.source_index] = source + for child in source._blocks: + members(child) + + for scene, source in zip(markdown.children, delivered.roots, strict=True): + assert scene.source_block is source + assert scene.source == source.source_text() + assert scene.id == source.id + members(source) + for scene in markdown.query(MarkdownBlock): + source = scene.source_block + assert source is original_members[source.source_index] + if source._inline_token is not None: + assert scene._content is source._content + if isinstance(scene, MarkdownFence): + assert scene.code == source.code + assert scene._highlighted_code.is_same( + source.highlight(app.native_ansi_color, app.current_theme.dark) + ) + assert (markdown.live_inline_calls, markdown.live_fence_calls) == calls + + # Theme/style refresh is a new paint input, not a new source lineage. + app.theme = "textual-light" + markdown.disabled = True + await pilot.pause() + changed = delivered.document.with_presentation(markdown) + assert changed.dark != document.dark + assert changed.child_pseudo_classes != document.child_pseudo_classes + assert changed.presentation.key != document.presentation.key + assert changed.same_source(document) + assert not delivered.is_current(markdown, delivered.width) + assert all(scene.source_block is source for scene, source in + zip(markdown.children, delivered.roots, strict=True)) + + # Eviction reconstructs the exact members, never a token-range match. + before = tuple(markdown.children) + await markdown.remove_children() + await markdown.materialize_document(delivered) + assert all(scene is not old and scene.source_block is source + for scene, old, source in + zip(markdown.children, before, delivered.roots, strict=True)) + assert (markdown.live_inline_calls, markdown.live_fence_calls) == calls + retained = tuple(markdown.query(MarkdownBlock)) + await markdown.append("\n\nA native streamed successor.") + assert markdown.source.endswith("A native streamed successor.") + assert all(scene.source_block is None for scene in retained) + assert all(scene.source_block is None for scene in markdown.query(MarkdownBlock)) + await markdown.update(SOURCE) + assert all(scene.source_block is None for scene in markdown.query(MarkdownBlock)) + replacement = markdown.acquire_document(SOURCE, markdown.acquired_tokens) + assert not replacement.same_source(document) + + assert all(not os.path.exists(f"/proc/{pid}") for pid in worker_pids) + + +class GrammarExtension(MarkdownBlock): + def __init__(self, markdown, token, marker, *, source_block=None): + self.marker = marker + super().__init__(markdown, token, source_block=source_block) + + @classmethod + def document_node(cls, block): + cls._require_native_document(constructor=cls.__init__) + return cls.native_document_node(block) + + +def acquired_grammar_extension(token, create): + if token.type == "html_block": + return create("html_block", token, "original-constructor-argument") + return None + + +class ExtendedSourceMarkdown(AcquiredMarkdown): + BLOCKS = {**AcquiredMarkdown.BLOCKS, "html_block": GrammarExtension} + + def unhandled_token(self, token): + if token.type == "html_block": + return GrammarExtension(self, token, "original-constructor-argument") + return None + + def acquire_document_unhandled(self): + return acquired_grammar_extension + + +@pytest.mark.asyncio +async def test_scene_factory_retains_custom_grammar_and_constructor(): + class GrammarApp(App): + def compose(self): + yield ExtendedSourceMarkdown("\noriginal\n\n") + + app = GrammarApp() + async with app.run_test() as pilot: + await pilot.pause() + markdown = app.query_one(ExtendedSourceMarkdown) + document = markdown.acquire_document(markdown.source, markdown.acquired_tokens) + paint = await asyncio.to_thread(document.prepare, markdown.region.width) + assert len(paint.roots) == 1 + assert paint.roots[0].declaration is GrammarExtension + await markdown.materialize_document(paint) + scene = markdown.query_one(GrammarExtension) + assert scene.source_block is paint.roots[0] + assert scene.marker == "original-constructor-argument" + await markdown.append("\n\nnext\n\n") + assert all(block.marker == "original-constructor-argument" + and block.source_block is None + for block in markdown.query(GrammarExtension)) + + +@pytest.mark.asyncio +async def test_source_binding_refuses_a_new_resolved_supplier(): + app = BoundSourceApp() + async with app.run_test() as pilot: + await pilot.pause() + markdown = app.query_one(BoundSourceMarkdown) + document = markdown.acquire_document(SOURCE, markdown.acquired_tokens) + # An independent supplier declaration is not a style-only refresh. + markdown.acquire_document_content = lambda: lambda token: MarkdownBlock._token_to_content(token) + changed = document.with_presentation(markdown) + assert not document.same_source(changed) + + +class IndependentlyAcquiredMarkdown(AcquiredMarkdown): + document = None + + def get_current_document(self): + return self.document + + +@pytest.mark.asyncio +async def test_independent_source_owner_revokes_scene_without_native_update(): + class IndependentApp(App): + def compose(self): + yield IndependentlyAcquiredMarkdown("Original acquired source.") + + app = IndependentApp() + async with app.run_test() as pilot: + await pilot.pause() + markdown = app.query_one(IndependentlyAcquiredMarkdown) + document = markdown.acquire_document(markdown.source, markdown.acquired_tokens) + markdown.document = document + paint = await asyncio.to_thread(document.prepare, markdown.region.width) + await markdown.materialize_document(paint) + root = markdown.children[0] + assert root.source_block is paint.roots[0] + markdown.styles.color = "red" + markdown.document = document.with_presentation(markdown) + assert root.source_block is paint.roots[0] + # A requested source string is not the acquired source of old controls. + markdown._markdown = "A pending independent source." + assert root.source == paint.roots[0].source_text() + markdown._markdown = document.source + # The owner's fresh acquisition supersedes old controls despite equal + # source text, without calling either native update or append. + markdown.document = markdown.acquire_document(markdown.source, markdown.acquired_tokens) + assert root.source_block is None + before = tuple(markdown.children) + with pytest.raises(ValueError, match="current source"): + markdown.materialize_document(paint) + assert tuple(markdown.children) == before + assert markdown.source == "Original acquired source." + # Native source requests revoke scene custody even if the independently + # acquired owner still retains the preceding document. + markdown.document = document + await markdown.materialize_document(paint) + root = markdown.children[0] + assert root.source_block is paint.roots[0] + publication = markdown.append(" A native successor.") + assert root.source_block is None + await publication + assert root.source_block is None + + +class BeforeMountMarkdown(Markdown): + async def _parse_tokens(self, parser, markdown, *, use_thread): + tokens = await super()._parse_tokens(parser, markdown, use_thread=use_thread) + assert not self.children + self.host_pseudos = frozenset(self.get_pseudo_classes()) + self.document = self.acquire_document(markdown, tokens) + self.document_paint = await asyncio.to_thread( + self.document.prepare, self.region.width, + root_selection=self.text_selection, + selection_style=(Visual.selection_style(self) if self.text_selection is not None else None), + selecting=self.screen._selecting, + ) + return tokens + + +class BeforeMountApp(App): + CSS = """ + VerticalScroll { scrollbar-size-vertical: 0; } + BeforeMountMarkdown { margin: 0; background: cyan; padding: 0 2; } + BeforeMountMarkdown:empty { background: red; padding: 1 3; } + BeforeMountMarkdown:last-child { border-left: solid yellow; } + """ + + def compose(self) -> ComposeResult: + with VerticalScroll(): + yield Label("Actual host sibling") + yield BeforeMountMarkdown() + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "source, expected_empty", + ( + pytest.param("A direct native document.", False, id="paragraph"), + pytest.param("", True, id="empty-source"), + pytest.param("", True, id="unhandled-comment"), + ), +) +async def test_source_empty_membership_before_native_mount( + source, expected_empty, monkeypatch +): + monkeypatch.delenv("NO_COLOR", raising=False) + app = BeforeMountApp() + async with app.run_test(size=(54, 30)) as pilot: + await pilot.pause() + markdown = app.query_one(BeforeMountMarkdown) + await markdown.update(source) + # Membership publication changes the parent's :empty answer. Mount's + # sibling-order callback does not own that parent style publication. + markdown.update_node_styles() + await pilot.pause() + paint = markdown.document_paint + assert "empty" in markdown.host_pseudos + assert "empty" not in markdown.document.presentation.root.pseudo_classes + assert "last-child" in markdown.document.presentation.root.pseudo_classes + assert paint.root_empty == markdown.is_empty + assert paint.root_empty is expected_empty + size, mounted = app.screen._compositor.render_subtree_strips( + markdown, app.screen._compositor.find_widget(markdown) + ) + assert paint.size == size + assert paint.gutter == markdown.styles.gutter + assert painted_characters(paint.lines) == painted_characters(mounted) + refreshed = markdown.document.with_presentation(markdown) + assert ( + refreshed.presentation.root.key == markdown.document.presentation.root.key + ) + + +class EmptyDisplayApp(BeforeMountApp): + CSS = ( + BeforeMountApp.CSS + "BeforeMountMarkdown MarkdownParagraph { display: none; }" + ) + + +class StreamBeforeMountApp(BeforeMountApp): + CSS = BeforeMountApp.CSS + "VerticalScroll { layout: stream; }" + + +@pytest.mark.asyncio +async def test_root_selection_before_leaves_matches_native_scene_and_readiness(monkeypatch): + monkeypatch.delenv("NO_COLOR", raising=False) + app = BeforeMountApp() + async with app.run_test(size=(54, 100)) as pilot: + await pilot.pause() + markdown = app.query_one(BeforeMountMarkdown) + assert not markdown.children + app.screen._select_all_in_widget(markdown) + await markdown.update(SOURCE) + markdown.update_node_styles() + await pilot.pause() + document = markdown.document.with_presentation(markdown) + style = Visual.selection_style(markdown) + assert markdown.document_paint.root_selection == SELECT_ALL + assert markdown.document_paint.selection_style == style + assert markdown.document_paint.document.same_source(document) + # Mount can add further native default CSS sources. Their actual + # ordered supply is a new acquired presentation, not counter equality. + paint = await asyncio.to_thread( + document.prepare, markdown.region.width, + root_selection=SELECT_ALL, selection_style=style, + ) + assert paint.leaves + assert paint.root_selection == SELECT_ALL + assert paint.selections is None + assert paint.is_current(markdown, paint.width) + assert not paint.matches(document, paint.width) + + # The native mounted oracle acquires its own actual descendants. The + # detached preparation above had only the root selection at ingress. + app.screen._select_all_in_widget(markdown) + await pilot.pause() + _, mounted = app.screen._compositor.render_subtree_strips( + markdown, app.screen._compositor.find_widget(markdown) + ) + assert painted_characters(markdown.document_paint.lines) == painted_characters(mounted) + assert painted_characters(paint.lines) == painted_characters(mounted) + unselected = await asyncio.to_thread(document.prepare, paint.width) + assert not unselected.is_current(markdown, paint.width) + assert any(a != b for a, b in zip(painted_characters(paint.lines), painted_characters(unselected.lines))) + app.screen.clear_selection() + await pilot.pause() + assert not paint.is_current(markdown, paint.width) + assert unselected.is_current(markdown, paint.width) + with pytest.raises(ValueError, match="whole widget"): + await asyncio.to_thread( + document.prepare, paint.width, + root_selection=Selection(Offset(0, 0), Offset(3, 0)), + selection_style=style, + ) + + +@pytest.mark.asyncio +async def test_partial_leaf_selection_uses_original_offsets_and_native_style(monkeypatch): + monkeypatch.delenv("NO_COLOR", raising=False) + app = DocumentApp() + async with app.run_test(size=(54, 100)) as pilot: + await pilot.pause() + markdown = app.query_one(AcquiredMarkdown) + document = markdown.acquire_document(SOURCE, markdown.acquired_tokens) + paint = await asyncio.to_thread(document.prepare, markdown.region.width) + index = next(i for i, leaf in enumerate(paint.leaves) if leaf.declaration is DocumentCodeLabel) + selection = Selection.from_offsets(Offset(1, 0), Offset(6, 0)) + selections = {index: selection} + app.screen.selections = {markdown.query_one(DocumentCodeLabel): selection} + await pilot.pause() + selected = await asyncio.to_thread( + paint.prepare_selection, selections, Visual.selection_style(markdown) + ) + assert selected.root_selection is None + assert selected.is_current(markdown, paint.width, selections=selections) + assert not selected.is_current(markdown, paint.width) + selections.clear() + assert selected.selections == ((index, selection),) + _, mounted = app.screen._compositor.render_subtree_strips( + markdown, app.screen._compositor.find_widget(markdown) + ) + assert painted_characters(selected.lines) == painted_characters(mounted) + + +@pytest.mark.asyncio +async def test_complete_document_request_and_paint_cross_spawn_process(monkeypatch): + monkeypatch.delenv("NO_COLOR", raising=False) + app = StreamBeforeMountApp() + async with app.run_test(size=(54, 30)) as pilot: + await pilot.pause() + markdown = app.query_one(BeforeMountMarkdown) + scene_layout = markdown.parent.layout + assert isinstance(scene_layout, StreamLayout) + assert scene_layout._cached_placements + scene_placements = scene_layout._cached_placements + await markdown.update(SOURCE) + markdown.update_node_styles() + await pilot.pause() + document = markdown.document.with_presentation(markdown) + selection_style = Visual.selection_style(markdown) + local = await asyncio.to_thread( + document.prepare, markdown.region.width, + root_selection=SELECT_ALL, selection_style=selection_style, + ) + # Semantic root membership is supplied by the same native grammar, + # not reconstructed from descendant placement or paint order. + assert [(root.declaration, root.source_range) for root in local.roots] == [ + (type(root), root.source_range) for root in markdown.children + ] + assert [root.placement.region for root in local.roots] == [ + root.region.translate(-markdown.region.offset) for root in markdown.children + ] + acquired_layout = document.presentation.ancestors[-1].base_rules["layout"] + assert isinstance(acquired_layout, StreamLayout) + assert acquired_layout is not scene_layout + assert acquired_layout._cached_placements is None + assert all(placement.widget.parent is markdown.parent + for placement in scene_placements) + + # The request no longer borrows a running App or widget. Both complete + # request and complete result traverse the original spawn/pickle boundary. + with ProcessPoolExecutor( + max_workers=1, mp_context=multiprocessing.get_context("spawn") + ) as executor: + future = executor.submit( + document.prepare, local.width, + root_selection=SELECT_ALL, selection_style=selection_style, + ) + remote = await asyncio.wrap_future(future) + worker_pids = tuple(executor._processes) + + assert all(not os.path.exists(f"/proc/{pid}") for pid in worker_pids) + assert remote.matches( + document, local.width, + root_selection=SELECT_ALL, selection_style=selection_style, + ) + assert not remote.matches(document, local.width) + assert type(remote.gutter) is Spacing + assert type(remote.size) is Size + assert remote.gutter == local.gutter + assert remote.size == local.size + assert remote.content_size == local.content_size + assert remote.root_empty == local.root_empty + assert remote.blocks == local.blocks + assert remote.table_of_contents == local.table_of_contents + assert all(root.document is remote.document for root in remote.roots) + assert [ + (root.declaration, root.source_index, root.source_range, root.code, root.source_text(), root.placement) + for root in remote.roots + ] == [ + (root.declaration, root.source_index, root.source_range, root.code, root.source_text(), root.placement) + for root in local.roots + ] + assert [root.code for root in remote.roots if root.code is not None] == ["print('native fence')"] + assert painted_characters(remote.lines) == painted_characters(local.lines) + + +@pytest.mark.asyncio +async def test_display_changing_empty_selector_requires_explicit_source(monkeypatch): + monkeypatch.delenv("NO_COLOR", raising=False) + app = EmptyDisplayApp() + async with app.run_test(size=(54, 30)) as pilot: + await pilot.pause() + with pytest.raises(TypeError, match="display changes an :empty selector input"): + await app.query_one(BeforeMountMarkdown).update("A hidden source child.") + + +@pytest.mark.asyncio +async def test_native_document_rows_currentness_and_interactions(monkeypatch): + monkeypatch.delenv("NO_COLOR", raising=False) + app = DocumentApp() + async with app.run_test(size=(54, 100)) as pilot: + await pilot.pause() + markdown = app.query_one(AcquiredMarkdown) + document = markdown.acquire_document(SOURCE, markdown.acquired_tokens) + paint = await asyncio.to_thread(document.prepare, markdown.region.width) + size, mounted = app.screen._compositor.render_subtree_strips( + markdown, + app.screen._compositor.find_widget(markdown), + ) + assert paint.size == size + assert paint.gutter == markdown.styles.gutter + assert paint.content_size == size.region.shrink(paint.gutter).size + assert [(level, title) for level, title, _ in paint.table_of_contents] == [ + (level, title) for level, title, _ in markdown.table_of_contents + ] + assert all(heading.placement is not None for heading in paint.headings) + assert paint.anchor_region("repeated-heading") is not None + assert paint.anchor_region("repeated-heading-1") is not None + assert paint.anchor_region("repeated-heading") != paint.anchor_region( + "repeated-heading-1" + ) + assert paint.anchor_region("nested-heading") is None + assert tuple(line.text for line in paint.lines) == tuple( + line.text for line in mounted + ) + assert painted_characters(paint.lines) == painted_characters(mounted) + assert paint.is_current(markdown, size.width) + # Returned worker data retain this original acquisition identity. + delivered = pickle.loads(pickle.dumps(paint)) + assert delivered.matches(document, size.width) + assert delivered.table_of_contents == paint.table_of_contents + assert not delivered.matches( + markdown.acquire_document(SOURCE, markdown.acquired_tokens), size.width + ) + assert any( + "@click" in segment.style.meta + for line in paint.lines + for segment in line + if segment.style is not None + ) + assert any(leaf.selected_text(Selection(None, None)) for leaf in paint.leaves) + assert any(leaf.declaration is DocumentCodeLabel for leaf in paint.leaves) + link = next( + (x, y) + for y, line in enumerate(paint.lines) + for x in range(line.cell_length) + if "@click" in line.get_style_at(x).meta + ) + leaf_index, offset = paint.get_leaf_and_offset_at(*link) + assert leaf_index is not None and offset is not None + assert paint.leaves[leaf_index].selected_text(Selection(None, None)) + assert any( + block.source_text(document).startswith("# Native") for block in paint.blocks + ) + markdown.styles.color = "red" + assert not paint.is_current(markdown, size.width) + refreshed = document.with_presentation(markdown) + updated = await asyncio.to_thread(refreshed.prepare, size.width) + assert updated.is_current(markdown, size.width) + await pilot.resize_terminal(38, 100) + await pilot.pause() + current = document.with_presentation(markdown) + assert not paint.matches(current, markdown.region.width) + resized = await asyncio.to_thread(current.prepare, markdown.region.width) + size, mounted = app.screen._compositor.render_subtree_strips( + markdown, + app.screen._compositor.find_widget(markdown), + ) + assert resized.size == size + assert tuple(line.text for line in resized.lines) == tuple( + line.text for line in mounted + ) + assert painted_characters(resized.lines) == painted_characters(mounted) + assert resized.table_of_contents == paint.table_of_contents