diff --git a/src/textual/_compositor.py b/src/textual/_compositor.py index 7ec4c98192..0c9ab27530 100644 --- a/src/textual/_compositor.py +++ b/src/textual/_compositor.py @@ -244,19 +244,28 @@ def capture(cls, key, geometry, widgets, invisible_widgets, clips, clip, screen_ }), widgets, invisible_widgets) @staticmethod - def _contains_geometry(geometry, entries, node: Widget) -> bool: - if node is None: - return False - return node in geometry or any( - isinstance(entry, SubtreeGeometryPlacement) and entry.source.contains(node) - for entry in entries - ) + def _route_geometry(geometry) -> Mapping[Widget, tuple[Widget, ...]]: + """Index original geometry membership by its ordered borrowed edges. + + Logical and invisible membership do not imply a geometry placement. + A widget may belong to multiple child sources; every edge remains in + original order so projection keeps its last-assignment semantics. + """ + routes: dict[Widget, list[Widget]] = {node: [] for node in geometry} + for child, entry in geometry.items(): + if isinstance(entry, SubtreeGeometryPlacement): + for node in entry.source._geometry_routes: + routes.setdefault(node, []).append(child) + return MappingProxyType({node: tuple(children) for node, children in routes.items()}) + + @cached_property + def _geometry_routes(self) -> Mapping[Widget, tuple[Widget, ...]]: + """Geometry routing belongs to this immutable source, not the DOM.""" + return self._route_geometry({node: entry for node, (_, entry) in self.geometry.items()}) def contains(self, node: Widget) -> bool: """Membership in this complete source, including borrowed children.""" - return self._contains_geometry( - self.geometry, (entry for _, entry in self.geometry.values()), node - ) + return node in self._geometry_routes def captured_parent(self, node: Widget) -> Widget | None: """The original parent edge, independent of the live child tree.""" @@ -264,9 +273,9 @@ def captured_parent(self, node: Widget) -> Widget | None: entry = indexed[1] return (entry.parent if isinstance(entry, (SubtreeMapGeometry, SubtreeGeometryPlacement)) else None) - for _, entry in self.geometry.values(): - if isinstance(entry, SubtreeGeometryPlacement) and entry.source.contains(node): - return entry.source.captured_parent(node) + if children := self._geometry_routes.get(node): + entry = self.geometry[children[0]][1] + return entry.source.captured_parent(node) raise errors.NoWidget("Widget is not in captured subtree") def members(self, *, invisible: bool = False) -> Iterator[Widget]: @@ -350,11 +359,10 @@ def project_into(self, geometry: CompositorMap, key: SubtreeGeometryKey, # their own demands, never the unrelated global path set. # More than one edge may contain an original widget; keep # source order and its original last-assignment semantics. - for child, (ordinal, entry) in self.geometry.items(): - if (isinstance(entry, SubtreeGeometryPlacement) - and entry.source.contains(node)): - candidates[ordinal] = child - child_demands.setdefault(child, set()).add(node) + for child in self._geometry_routes.get(node, ()): + ordinal = self.geometry[child][0] + candidates[ordinal] = child + child_demands.setdefault(child, set()).add(node) entries = ((node, self.geometry[node][1]) for _, node in sorted(candidates.items())) else: @@ -437,6 +445,7 @@ def capture(cls, key, geometry, widgets, invisible_widgets, clips, clip, screen_ key, geometry, widgets, invisible_widgets, clips, clip, screen_coordinates) intrinsic = {} origin = key.region.offset + geometry_routes = cls._route_geometry(geometry) for node, entry in geometry.items(): child = isinstance(entry, SubtreeGeometryPlacement) scope, bounds = (entry.clip if child else clips[node]).relative_bounds(clip, origin) @@ -450,7 +459,7 @@ def capture(cls, key, geometry, widgets, invisible_widgets, clips, clip, screen_ key, geometry, widgets, invisible_widgets, clips, clip, screen_coordinates) parent = node.parent parent = parent._render_widget if isinstance(parent, Widget) else None - parent = (parent if parent is not node and cls._contains_geometry(geometry, geometry.values(), parent) + parent = (parent if parent is not node and parent in geometry_routes else None) intrinsic[node] = ( replace(entry, parent=parent, clip_bounds=bounds) if child diff --git a/src/textual/style.py b/src/textual/style.py index e14384d69b..637058bb83 100644 --- a/src/textual/style.py +++ b/src/textual/style.py @@ -7,7 +7,7 @@ from __future__ import annotations -from dataclasses import dataclass +from dataclasses import dataclass, replace from functools import cached_property, lru_cache from operator import attrgetter from pickle import dumps, loads @@ -38,6 +38,7 @@ "link", "auto_color", "_meta", + "overline", ) @@ -54,22 +55,9 @@ "blink", "link", "_meta", + "overline", ) -_get_simple_attributes_sans_color = attrgetter( - "bold", - "dim", - "italic", - "underline", - "underline2", - "reverse", - "strike", - "blink", - "link", - "_meta", -) - - _get_attributes = attrgetter( "background", "foreground", @@ -109,6 +97,7 @@ class Style: link: str | None = None _meta: bytes | None = None auto_color: bool = False + overline: bool | None = None def __rich_repr__(self) -> rich.repr.Result: yield "background", self.background, None @@ -121,6 +110,7 @@ def __rich_repr__(self) -> rich.repr.Result: yield "reverse", self.reverse, None yield "strike", self.strike, None yield "blink", self.blink, None + yield "overline", self.overline, None yield "link", self.link, None if self._meta is not None: @@ -141,6 +131,7 @@ def _is_null(self) -> bool: None, None, None, + None, ) @cached_property @@ -185,6 +176,8 @@ def style_definition(self) -> str: output_append("strike" if self.strike else "not strike") if self.blink is not None: output_append("blink" if self.blink else "not blink") + if self.overline is not None: + output_append("overline" if self.overline else "not overline") if self.link is not None: if "'" not in self.link: output_append(f"link='{self.link}'") @@ -227,6 +220,8 @@ def markup_tag(self) -> str: output_append("strike" if self.strike else "not strike") if self.blink is not None: output_append("blink" if self.blink else "not blink") + if self.overline is not None: + output_append("overline" if self.overline else "not overline") if self.link is not None: output_append("link") if self._meta is not None: @@ -300,6 +295,7 @@ def __add__(self, other: object | None) -> Style: if _meta is not None and other__meta is not None else (_meta if other__meta is None else other__meta) ), + overline=self.overline if other.overline is None else other.overline, ) return new_style elif other is None: @@ -384,6 +380,7 @@ def from_rich_style( blink=rich_style.blink, link=rich_style.link, _meta=rich_style._meta, + overline=rich_style.overline, ) @classmethod @@ -411,6 +408,7 @@ def from_styles(cls, styles: StylesBase) -> Style: strike=text_style.strike, blink=text_style.blink, auto_color=styles.auto_color, + overline=text_style.overline, ) @classmethod @@ -446,6 +444,7 @@ def rich_style(self) -> RichStyle: blink, link, _meta, + overline, ) = _get_simple_attributes(self) color = None if foreground is None else background + foreground @@ -463,6 +462,7 @@ def rich_style(self) -> RichStyle: blink=blink, link=link, meta=None if _meta is None else self.meta, + overline=overline, ) def rich_style_with_offset(self, x: int, y: int) -> RichStyle: @@ -490,6 +490,7 @@ def rich_style_with_offset(self, x: int, y: int) -> RichStyle: blink, link, _meta, + overline, ) = _get_simple_attributes(self) color = None if foreground is None else background + foreground return RichStyle( @@ -505,12 +506,13 @@ def rich_style_with_offset(self, x: int, y: int) -> RichStyle: blink=blink, link=link, meta={**self.meta, "offset": (x, y)}, + overline=overline, ) @cached_property def without_color(self) -> Style: """The style without any colors.""" - return Style(None, None, *_get_simple_attributes_sans_color(self)) + return replace(self, background=None, foreground=None, auto_color=False) @cached_property def background_style(self) -> Style: diff --git a/src/textual/widget.py b/src/textual/widget.py index dcdbb95e11..222a0d82c4 100644 --- a/src/textual/widget.py +++ b/src/textual/widget.py @@ -8,6 +8,7 @@ from asyncio import Task, create_task, gather, shield from collections import Counter from contextlib import asynccontextmanager +from dataclasses import replace from fractions import Fraction from time import monotonic from types import TracebackType @@ -1325,14 +1326,10 @@ def iter_styles() -> Iterable[StylesBase]: if has_rule("auto_color") and styles.auto_color: color = text_background.get_contrast_text(color.a) - visual_style = VisualStyle( - background, - color, - bold=style.bold, - dim=style.dim, - italic=style.italic, - underline=style.underline, - strike=style.strike, + visual_style = replace( + VisualStyle.from_rich_style(style), + background=background, + foreground=color, ) self._visual_style_cache[cache_key] = visual_style @@ -4501,15 +4498,10 @@ def visual_style(self) -> VisualStyle: or not resolved.same_paint(self._visual_paint_state) ): background, color, style = resolved.background, resolved.foreground, resolved.text_style - self._visual_style = VisualStyle( - background, - color, - bold=style.bold, - dim=style.dim, - italic=style.italic, - reverse=style.reverse, - underline=style.underline, - strike=style.strike, + self._visual_style = replace( + VisualStyle.from_rich_style(style), + background=background, + foreground=color, ) self._visual_paint_state = resolved return self._visual_style diff --git a/src/textual/widgets/_markdown.py b/src/textual/widgets/_markdown.py index 885a863bc3..a9d38b668b 100644 --- a/src/textual/widgets/_markdown.py +++ b/src/textual/widgets/_markdown.py @@ -71,11 +71,19 @@ def start(self) -> None: async def stop(self) -> None: """Stop the stream and await its finish.""" - if self._task is not None: - self._task.cancel() - await self._task - self._task = None - self._stopped = True + self._stopped = True + self._new_markup.set() + task = self._task + if task is not None: + try: + await asyncio.shield(task) + except asyncio.CancelledError: + # Caller cancellation must not abandon the stream's append drain. + await asyncio.shield(task) + raise + finally: + if task.done() and self._task is task: + self._task = None async def write(self, markdown_fragment: str) -> None: """Append or enqueue a markdown fragment. @@ -97,11 +105,13 @@ async def write(self, markdown_fragment: str) -> None: async def _run(self) -> None: """Run a task to append markdown fragments when available.""" try: - while await self._new_markup.wait(): + while not self._stopped or self._pending: + await self._new_markup.wait() new_markdown = "".join(self._pending) self._pending.clear() self._new_markup.clear() - await asyncio.shield(self.markdown_widget.append(new_markdown)) + if new_markdown: + await asyncio.shield(self.markdown_widget.append(new_markdown)) except asyncio.CancelledError: # Task has been cancelled, add any outstanding markdown pass diff --git a/tests/test_markdown.py b/tests/test_markdown.py index 502f3d2098..5d28aac93b 100644 --- a/tests/test_markdown.py +++ b/tests/test_markdown.py @@ -2,6 +2,7 @@ from __future__ import annotations +import asyncio from pathlib import Path from typing import Iterator @@ -42,6 +43,62 @@ def compose(self) -> ComposeResult: yield FussyMarkdown(self._markdown) +@pytest.mark.parametrize("parked", [False, True]) +async def test_stream_empty_stop_joins_without_publication(parked): + appends = [] + + class ObservedMarkdown(Markdown): + async def append(self, fragment): + appends.append(fragment) + await super().append(fragment) + + document = ObservedMarkdown() + async with App().run_test() as pilot: + await pilot.app.mount(document) + stream = document.get_stream(document) + task = stream._task + if parked: + await asyncio.sleep(0) + await stream.stop() + assert appends == [] + assert task.done() and not task.cancelled() + assert stream._task is None and stream._stopped + await stream.stop() + with pytest.raises(RuntimeError): + await stream.write("late") + + +async def test_stream_stop_drains_real_append_before_propagating_caller_cancellation(): + entered = asyncio.Event() + release = asyncio.Event() + + class HeldMarkdown(Markdown): + async def append(self, fragment): + entered.set() + await release.wait() + await super().append(fragment) + + document = HeldMarkdown() + async with App().run_test() as pilot: + await pilot.app.mount(document) + stream = document.get_stream(document) + await stream.write("first\n\n") + await entered.wait() + await stream.write("second") + worker = stream._task + stopping = asyncio.create_task(stream.stop()) + await asyncio.sleep(0) + stopping.cancel() + await asyncio.sleep(0) + assert not stopping.done() + release.set() + with pytest.raises(asyncio.CancelledError): + await stopping + assert worker.done() and not worker.cancelled() + assert stream._task is None and stream._stopped + assert document._markdown == "first\n\nsecond" + + async def test_document_supplies_inline_content_for_heading_paragraph_and_table(): class CustomParagraph(MD.MarkdownParagraph): def _token_to_content(self, token: Token) -> Content: diff --git a/tests/test_style_inheritance.py b/tests/test_style_inheritance.py index 72b631a94a..1bb9a2ba22 100644 --- a/tests/test_style_inheritance.py +++ b/tests/test_style_inheritance.py @@ -2,6 +2,52 @@ from textual.widgets import Button, Static +def test_visual_style_preserves_declared_flags(): + from rich.style import Style as RichStyle + from textual.style import Style + + original = RichStyle(reverse=True, underline2=True, blink=True, overline=True) + converted = Style.from_rich_style(original) + assert converted.rich_style == original + assert converted.without_color.rich_style == original + assert converted.rich_style_with_offset(2, 3).overline is True + assert (converted + Style(overline=False, reverse=False)).rich_style.overline is False + assert (converted + Style(overline=False, reverse=False)).rich_style.reverse is False + assert Style(overline=True) != Style() + assert not Style(overline=True)._is_null + assert Style(overline=True).style_definition == "overline" + + +async def test_component_and_widget_render_keep_declared_text_flags(): + from textual.widgets import OptionList + + class StyleApp(App): + CSS = """ + OptionList > .option-list--option-highlighted { + text-style: reverse blink overline; + } + Static { text-style: reverse blink overline; } + """ + + def compose(self): + yield OptionList("selected") + yield Static("sidebar", id="row") + + async with StyleApp().run_test(size=(40, 12)) as pilot: + await pilot.pause() + options = pilot.app.query_one(OptionList) + row = pilot.app.query_one("#row", Static) + component = options.get_visual_style("option-list--option-highlighted") + for style in (component.rich_style, row.visual_style.rich_style): + assert style.reverse and style.blink and style.overline + for widget, word in ((options, "selected"), (row, "sidebar")): + segments = [segment for strip in widget.render_lines(widget.region.reset_offset) + for segment in strip if word in segment.text] + assert segments + assert all(segment.style.reverse and segment.style.blink + and segment.style.overline for segment in segments) + + async def test_text_style_inheritance(): """Check that changes to text style are inherited in children.""" @@ -21,17 +67,23 @@ def compose(self) -> ComposeResult: class InheritanceApp(App): def compose(self) -> ComposeResult: - yield Button("button1") + yield Button("button1", id="initial-focus") yield FocusableThing() yield Button("button2") app = InheritanceApp() async with app.run_test() as pilot: + # Headless startup does not promise application-focus admission. + # Establish the starting widget before testing the Tab transition. + initial_focus = app.query_one("#initial-focus", Button) + initial_focus.focus() await pilot.pause() + assert app.focused is initial_focus child = app.query_one("#child-of-focusable-thing") assert child.rich_style.bold assert not child.rich_style.reverse await pilot.press("tab") await pilot.pause() + assert app.focused is app.query_one(FocusableThing) assert child.rich_style.bold assert child.rich_style.reverse diff --git a/tests/test_viewport_geometry_targets.py b/tests/test_viewport_geometry_targets.py index 94d6339222..e53b24dc78 100644 --- a/tests/test_viewport_geometry_targets.py +++ b/tests/test_viewport_geometry_targets.py @@ -8,6 +8,58 @@ from textual.widgets import Static +def test_borrowed_geometry_routes_preserve_membership_ancestry_and_assignment_order(): + from types import MappingProxyType + + from textual._compositor import ( + IntrinsicSubtreeGeometry, PlacedSubtreeGeometry, RootSceneClip, + SubtreeGeometryKey, SubtreeGeometryPlacement, SubtreeMapGeometry, + ) + from textual.geometry import Offset, Region, Size, Spacing + from textual.map_geometry import MapGeometry + + root, first, second, shared, logical, invisible, unrelated = ( + Static(name) for name in ("root", "first", "second", "shared", "logical", "invisible", "unrelated") + ) + bounds = Region(0, 0, 20, 10) + clip = RootSceneClip(bounds) + key = SubtreeGeometryKey(0, 0, bounds, bounds, (), 0, bounds, True, + Spacing(), bounds.size, False, Offset(), ()) + + def placed(y): + region = Region(0, y, 5, 1) + return MapGeometry(region, (), bounds, region.size, region.size, region, Spacing()) + + def source(owner, y): + return IntrinsicSubtreeGeometry( + key, MappingProxyType({ + owner: (0, SubtreeMapGeometry(placed(y), (), None)), + shared: (1, SubtreeMapGeometry(placed(y + 1), (), owner)), + }), frozenset((owner, shared, logical)), frozenset((invisible,)), + ) + + first_source, second_source = source(first, 0), source(second, 5) + resource = PlacedSubtreeGeometry( + key, MappingProxyType({ + first: (0, SubtreeGeometryPlacement(first_source, key, clip, root)), + second: (1, SubtreeGeometryPlacement(second_source, key, clip, root)), + root: (2, placed(9)), + }), frozenset((root,)), frozenset(), + ) + assert resource.contains(shared) + assert logical in set(resource.members()) and not resource.contains(logical) + assert invisible in set(resource.members(invisible=True)) and not resource.contains(invisible) + assert not resource.contains(unrelated) + assert resource._geometry_routes[shared] == (first, second) + assert resource.captured_parent(shared) is first + assert resource.captured_parent(first) is root + published, clips = {}, {} + resource.project_into(published, key, clip, clips, root, visible_only=True, + retained={shared, unrelated}, bounds=Region(30, 30, 1, 1)) + assert published[shared] == placed(6) + assert unrelated not in published + + async def test_complete_markdown_capture_keeps_offscreen_table_keylines(): from textual.widgets import Markdown from textual.widgets._markdown import MarkdownTableContent