Skip to content

Commit

Permalink
Visual protocol
Browse files Browse the repository at this point in the history
  • Loading branch information
willmcgugan committed Jan 25, 2025
1 parent 04dbf88 commit aa159dd
Show file tree
Hide file tree
Showing 9 changed files with 129 additions and 60 deletions.
3 changes: 0 additions & 3 deletions src/textual/_border.py
Original file line number Diff line number Diff line change
Expand Up @@ -271,9 +271,6 @@ def get_box(
Returns:
A tuple of 3 Segment triplets.
"""
assert isinstance(inner_style, Style)
assert isinstance(outer_style, Style)
assert isinstance(style, Style)
_Segment = Segment
(
(top1, top2, top3),
Expand Down
55 changes: 23 additions & 32 deletions src/textual/content.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
from rich.cells import set_cell_size
from rich.console import OverflowMethod
from rich.segment import Segment, Segments
from rich.style import Style as RichStyle
from rich.terminal_theme import TerminalTheme
from rich.text import Text
from typing_extensions import Final, TypeAlias
Expand All @@ -27,14 +28,15 @@
from textual._context import active_app
from textual._loop import loop_last
from textual.color import Color
from textual.css.styles import RulesMap
from textual.css.types import TextAlign
from textual.selection import Selection
from textual.strip import Strip
from textual.style import Style
from textual.visual import Visual

if TYPE_CHECKING:
from textual.widget import Widget
pass


ContentType: TypeAlias = Union["Content", str]
Expand Down Expand Up @@ -275,7 +277,11 @@ def __lt__(self, other: object) -> bool:
return self.plain < other.plain
return NotImplemented

def get_optimal_width(self, widget: Widget, container_width: int) -> int:
def get_optimal_width(
self,
rules: RulesMap,
container_width: int,
) -> int:
"""Get optimal width of the visual to display its content. Part of the Textual Visual protocol.
Args:
Expand All @@ -289,7 +295,7 @@ def get_optimal_width(self, widget: Widget, container_width: int) -> int:
lines = self.without_spans.split("\n")
return max(line.cell_length for line in lines)

def get_height(self, widget: Widget, width: int) -> int:
def get_height(self, rules: RulesMap, width: int) -> int:
"""Get the height of the visual if rendered with the given width. Part of the Textual Visual protocol.
Args:
Expand Down Expand Up @@ -365,37 +371,30 @@ def get_span(y: int) -> tuple[int, int] | None:

def render_strips(
self,
widget: Widget,
rules: RulesMap,
width: int,
height: int | None,
style: Style,
selection: Selection | None = None,
selection_style: Style | None = None,
) -> list[Strip]:
if not width:
return []

selection = widget.selection
if selection is not None:
selection_style = Style.from_rich_style(
widget.screen.get_component_rich_style("screen--selection")
)

else:
selection_style = None

lines = self._wrap_and_format(
width,
align=widget.styles.text_align,
align=rules.get("text_align", "left"),
overflow="fold",
no_wrap=False,
tab_size=8,
selection=widget.selection,
selection=selection,
selection_style=selection_style,
)

if height is not None:
lines = lines[:height]

strip_lines = [line.to_strip(widget, style) for line in lines]
strip_lines = [Strip(*line.to_strip(style)) for line in lines]
return strip_lines

def __len__(self) -> int:
Expand Down Expand Up @@ -1128,7 +1127,7 @@ def __init__(
def plain(self) -> str:
return self.content.plain

def to_strip(self, widget: Widget, style: Style) -> Strip:
def to_strip(self, style: Style) -> tuple[list[Segment], int]:
_Segment = Segment
align = self.align
width = self.width
Expand All @@ -1137,8 +1136,6 @@ def to_strip(self, widget: Widget, style: Style) -> Strip:
x = self.x
y = self.y

parse_style = widget.app.stylesheet.parse_style

if align in ("start", "left") or (align == "justify" and self.line_end):
pass

Expand Down Expand Up @@ -1166,9 +1163,7 @@ def to_strip(self, widget: Widget, style: Style) -> Strip:
add_segment = segments.append
x = self.x
for index, word in enumerate(words):
for text, text_style in word.render(
style, end="", parse_style=parse_style
):
for text, text_style in word.render(style, end=""):
add_segment(
_Segment(
text, (style + text_style).rich_style_with_offset(x, y)
Expand All @@ -1178,16 +1173,15 @@ def to_strip(self, widget: Widget, style: Style) -> Strip:
if index < len(spaces) and (pad := spaces[index]):
add_segment(_Segment(" " * pad, (style + text_style).rich_style))

strip = Strip(self._apply_link_style(widget, segments), width)
return strip
return segments, width

segments = (
[Segment(" " * pad_left, style.background_style.rich_style)]
if pad_left
else []
)
add_segment = segments.append
for text, text_style in content.render(style, end="", parse_style=parse_style):
for text, text_style in content.render(style, end=""):
add_segment(
_Segment(text, (style + text_style).rich_style_with_offset(x, y))
)
Expand All @@ -1197,16 +1191,13 @@ def to_strip(self, widget: Widget, style: Style) -> Strip:
segments.append(
_Segment(" " * pad_right, style.background_style.rich_style)
)
strip = Strip(
self._apply_link_style(widget, segments),
content.cell_length + pad_left + pad_right,
)
return strip

return (segments, content.cell_length + pad_left + pad_right)

def _apply_link_style(
self, widget: Widget, segments: list[Segment]
self, link_style: RichStyle, segments: list[Segment]
) -> list[Segment]:
link_style = widget.link_style

_Segment = Segment
segments = [
_Segment(
Expand Down
34 changes: 32 additions & 2 deletions src/textual/css/styles.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
from __future__ import annotations

from collections import abc
from dataclasses import dataclass, field
from functools import partial
from operator import attrgetter
from typing import TYPE_CHECKING, Any, Callable, Iterable, Literal, cast
from typing import TYPE_CHECKING, Any, Callable, Iterable, Iterator, Literal, cast

import rich.repr
from rich.style import Style
Expand Down Expand Up @@ -1245,7 +1246,7 @@ def css(self) -> str:


@rich.repr.auto
class RenderStyles(StylesBase):
class RenderStyles(StylesBase, abc.Mapping):
"""Presents a combined view of two Styles object: a base Styles and inline Styles."""

def __init__(self, node: DOMNode, base: Styles, inline_styles: Styles) -> None:
Expand All @@ -1265,6 +1266,35 @@ def __eq__(self, other: object) -> bool:
)
return NotImplemented

def __getitem__(self, key: str) -> object:
if key not in RULE_NAMES_SET:
raise KeyError(key)
return getattr(self, key)

def get(self, key: str, default: object | None = None) -> object:
return getattr(self, key) if key in RULE_NAMES_SET else default

def __len__(self) -> int:
return len(RULE_NAMES)

def __iter__(self) -> Iterator[str]:
return iter(RULE_NAMES)

def __contains__(self, key: object) -> bool:
return key in RULE_NAMES_SET

def keys(self) -> Iterable[str]:
return RULE_NAMES

def values(self) -> Iterable[object]:
for key in RULE_NAMES:
yield getattr(self, key)

def items(self) -> Iterable[tuple[str, object]]:
get_rule = self.get_rule
for key in RULE_NAMES:
yield (key, getattr(self, key))

@property
def _cache_key(self) -> int:
"""A cache key, that changes when any style is changed.
Expand Down
2 changes: 2 additions & 0 deletions src/textual/css/tokenizer.py
Original file line number Diff line number Diff line change
Expand Up @@ -130,10 +130,12 @@ def __init__(self, description: str, **tokens: str) -> None:
self._expect_semicolon = True

def expect_eof(self, eof: bool) -> Expect:
"""Expect an end of file."""
self._expect_eof = eof
return self

def expect_semicolon(self, semicolon: bool) -> Expect:
"""Tokenizer expects text to be terminated with a semi-colon."""
self._expect_semicolon = semicolon
return self

Expand Down
21 changes: 21 additions & 0 deletions src/textual/strip.py
Original file line number Diff line number Diff line change
Expand Up @@ -599,6 +599,27 @@ def apply_style(self, style: Style) -> Strip:
self._style_cache[style] = styled_strip
return styled_strip

def _apply_link_style(self, link_style: Style) -> Strip:
segments = self._segments
_Segment = Segment
segments = [
(
_Segment(
text,
(
style
if style._meta is None
else (style + link_style if "@click" in style.meta else style)
),
control,
)
if style
else _Segment(text)
)
for text, style, control in segments
]
return Strip(segments, self._cell_length)

def render(self, console: Console) -> str:
"""Render the strip into terminal sequences.
Expand Down
Loading

0 comments on commit aa159dd

Please sign in to comment.