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