Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 28 additions & 19 deletions src/textual/_compositor.py
Original file line number Diff line number Diff line change
Expand Up @@ -244,29 +244,38 @@ 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."""
if (indexed := self.geometry.get(node)) is not 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]:
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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)
Expand All @@ -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
Expand Down
34 changes: 18 additions & 16 deletions src/textual/style.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -38,6 +38,7 @@
"link",
"auto_color",
"_meta",
"overline",
)


Expand All @@ -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",
Expand Down Expand Up @@ -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
Expand All @@ -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:
Expand All @@ -141,6 +131,7 @@ def _is_null(self) -> bool:
None,
None,
None,
None,
)

@cached_property
Expand Down Expand Up @@ -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}'")
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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:
Expand Down Expand Up @@ -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(
Expand All @@ -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:
Expand Down
26 changes: 9 additions & 17 deletions src/textual/widget.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand Down
24 changes: 17 additions & 7 deletions src/textual/widgets/_markdown.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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
Expand Down
57 changes: 57 additions & 0 deletions tests/test_markdown.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

from __future__ import annotations

import asyncio
from pathlib import Path
from typing import Iterator

Expand Down Expand Up @@ -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:
Expand Down
Loading
Loading