Merge branch 'css' of github.com:Textualize/textual into style-error-improvements

This commit is contained in:
Darren Burns
2022-04-27 12:13:14 +01:00
11 changed files with 173 additions and 31 deletions

View File

@@ -391,7 +391,9 @@ class Compositor:
self._cuts = [sorted(set(line_cuts)) for line_cuts in cuts] self._cuts = [sorted(set(line_cuts)) for line_cuts in cuts]
return self._cuts return self._cuts
def _get_renders(self) -> Iterable[tuple[Region, Region, Lines]]: def _get_renders(
self, crop: Region | None = None
) -> Iterable[tuple[Region, Region, Lines]]:
"""Get rendered widgets (lists of segments) in the composition. """Get rendered widgets (lists of segments) in the composition.
Returns: Returns:
@@ -402,15 +404,21 @@ class Compositor:
_rich_traceback_guard = True _rich_traceback_guard = True
if self.map: if self.map:
widget_regions = sorted( if crop:
[ overlaps = crop.overlaps
mapped_regions = [
(widget, region, order, clip)
for widget, (region, order, clip, _, _) in self.map.items()
if widget.visible and not widget.is_transparent and overlaps(crop)
]
else:
mapped_regions = [
(widget, region, order, clip) (widget, region, order, clip)
for widget, (region, order, clip, _, _) in self.map.items() for widget, (region, order, clip, _, _) in self.map.items()
if widget.visible and not widget.is_transparent if widget.visible and not widget.is_transparent
], ]
key=itemgetter(2),
reverse=True, widget_regions = sorted(mapped_regions, key=itemgetter(2), reverse=True)
)
else: else:
widget_regions = [] widget_regions = []
@@ -480,23 +488,20 @@ class Compositor:
] ]
# Go through all the renders in reverse order and fill buckets with no render # Go through all the renders in reverse order and fill buckets with no render
renders = self._get_renders() renders = self._get_renders(crop)
intersection = Region.intersection intersection = Region.intersection
for region, clip, lines in renders: for region, clip, lines in renders:
render_region = intersection(region, clip) render_region = intersection(region, clip)
for y, line in zip(render_region.y_range, lines): for y, line in zip(render_region.y_range, lines):
first_cut, last_cut = render_region.x_extents first_cut, last_cut = render_region.x_extents
final_cuts = [cut for cut in cuts[y] if (last_cut >= cut >= first_cut)] final_cuts = [cut for cut in cuts[y] if (last_cut >= cut >= first_cut)]
# TODO: Suspect this may break for region not on cut boundaries
if len(final_cuts) == 2: if len(final_cuts) == 2:
# Two cuts, which means the entire line # Two cuts, which means the entire line
cut_segments = [line] cut_segments = [line]
else: else:
# More than one cut, which means we need to divide the line
# if not final_cuts:
# continue
render_x = render_region.x render_x = render_region.x
relative_cuts = [cut - render_x for cut in final_cuts] relative_cuts = [cut - render_x for cut in final_cuts]
_, *cut_segments = divide(line, relative_cuts) _, *cut_segments = divide(line, relative_cuts)

View File

@@ -47,7 +47,7 @@ class _ReadUntil(Awaitable):
self.max_bytes = max_bytes self.max_bytes = max_bytes
class PeekBuffer(Awaitable): class _PeekBuffer(Awaitable):
__slots__: list[str] = [] __slots__: list[str] = []
@@ -61,7 +61,7 @@ class Parser(Generic[T]):
read = _Read read = _Read
read1 = _Read1 read1 = _Read1
read_until = _ReadUntil read_until = _ReadUntil
peek_buffer = PeekBuffer peek_buffer = _PeekBuffer
def __init__(self) -> None: def __init__(self) -> None:
self._buffer = io.StringIO() self._buffer = io.StringIO()
@@ -103,14 +103,14 @@ class Parser(Generic[T]):
while tokens: while tokens:
yield popleft() yield popleft()
while pos < data_size or isinstance(self._awaiting, PeekBuffer): while pos < data_size or isinstance(self._awaiting, _PeekBuffer):
_awaiting = self._awaiting _awaiting = self._awaiting
if isinstance(_awaiting, _Read1): if isinstance(_awaiting, _Read1):
self._awaiting = self._gen.send(data[pos : pos + 1]) self._awaiting = self._gen.send(data[pos : pos + 1])
pos += 1 pos += 1
elif isinstance(_awaiting, PeekBuffer): elif isinstance(_awaiting, _PeekBuffer):
self._awaiting = self._gen.send(data[pos:]) self._awaiting = self._gen.send(data[pos:])
elif isinstance(_awaiting, _Read): elif isinstance(_awaiting, _Read):

View File

@@ -3,7 +3,7 @@ from __future__ import annotations
import os import os
import re import re
from typing import Any, Callable, Generator from typing import Any, Callable, Generator, Iterable
from . import log from . import log
from . import events from . import events
@@ -34,6 +34,11 @@ class XTermParser(Parser[events.Event]):
def debug_log(self, *args: Any) -> None: def debug_log(self, *args: Any) -> None:
if self._debug_log_file is not None: if self._debug_log_file is not None:
self._debug_log_file.write(" ".join(args) + "\n") self._debug_log_file.write(" ".join(args) + "\n")
self._debug_log_file.flush()
def feed(self, data: str) -> Iterable[events.Event]:
self.debug_log(f"FEED {data!r}")
return super().feed(data)
def parse_mouse_code(self, code: str, sender: MessageTarget) -> events.Event | None: def parse_mouse_code(self, code: str, sender: MessageTarget) -> events.Event | None:
sgr_match = self._re_sgr_mouse.match(code) sgr_match = self._re_sgr_mouse.match(code)
@@ -83,10 +88,23 @@ class XTermParser(Parser[events.Event]):
while not self.is_eof: while not self.is_eof:
character = yield read1() character = yield read1()
self.debug_log(f"character={character!r}") self.debug_log(f"character={character!r}")
# The more_data is to allow the parse to distinguish between an escape sequence if character == ESC:
# and the escape key pressed # Could be the escape key was pressed OR the start of an escape sequence
if character == ESC and ((yield self.peek_buffer()) or more_data()):
sequence: str = character sequence: str = character
peek_buffer = yield self.peek_buffer()
if not peek_buffer:
# An escape arrived without any following characters
on_token(events.Key(self.sender, key=ESC))
continue
if peek_buffer and peek_buffer[0] == ESC:
# There is an escape in the buffer, so ESC ESC has arrived
yield read1()
on_token(events.Key(self.sender, key=ESC))
# If there is no further data, it is not part of a sequence,
# So we don't need to go in to the loop
if len(peek_buffer) == 1 and not more_data():
continue
while True: while True:
sequence += yield read1() sequence += yield read1()
self.debug_log(f"sequence={sequence!r}") self.debug_log(f"sequence={sequence!r}")

View File

@@ -253,7 +253,7 @@ class App(DOMNode):
key_values = " ".join( key_values = " ".join(
f"{key}={value}" for key, value in kwargs.items() f"{key}={value}" for key, value in kwargs.items()
) )
output = " ".join((output, key_values)) output = f"{output} {key_values}" if output else key_values
if self._log_console is not None: if self._log_console is not None:
self._log_console.print(output, soft_wrap=True) self._log_console.print(output, soft_wrap=True)
if self.devtools.is_connected: if self.devtools.is_connected:

View File

@@ -131,12 +131,12 @@ class ScalarProperty:
): ):
raise StyleValueError("'auto' not allowed here") raise StyleValueError("'auto' not allowed here")
if new_value.unit != Unit.AUTO: if new_value is not None and new_value.unit != Unit.AUTO:
if new_value is not None and new_value.unit not in self.units: if new_value.unit not in self.units:
raise StyleValueError( raise StyleValueError(
f"{self.name} units must be one of {friendly_list(get_symbols(self.units))}" f"{self.name} units must be one of {friendly_list(get_symbols(self.units))}"
) )
if new_value is not None and new_value.is_percent: if new_value.is_percent:
new_value = Scalar( new_value = Scalar(
float(new_value.value), self.percent_unit, Unit.WIDTH float(new_value.value), self.percent_unit, Unit.WIDTH
) )
@@ -715,7 +715,7 @@ class NameListProperty:
def __get__( def __get__(
self, obj: StylesBase, objtype: type[StylesBase] | None = None self, obj: StylesBase, objtype: type[StylesBase] | None = None
) -> tuple[str, ...]: ) -> tuple[str, ...]:
return cast(tuple[str, ...], obj.get_rule(self.name, ())) return cast("tuple[str, ...]", obj.get_rule(self.name, ()))
def __set__(self, obj: StylesBase, names: str | tuple[str] | None = None): def __set__(self, obj: StylesBase, names: str | tuple[str] | None = None):

View File

@@ -190,18 +190,21 @@ class LinuxDriver(Driver):
return False return False
parser = XTermParser(self._target, more_data) parser = XTermParser(self._target, more_data)
feed = parser.feed
utf8_decoder = getincrementaldecoder("utf-8")().decode utf8_decoder = getincrementaldecoder("utf-8")().decode
decode = utf8_decoder decode = utf8_decoder
read = os.read read = os.read
EVENT_READ = selectors.EVENT_READ
try: try:
while not self.exit_event.is_set(): while not self.exit_event.is_set():
selector_events = selector.select(0.1) selector_events = selector.select(0.1)
for _selector_key, mask in selector_events: for _selector_key, mask in selector_events:
if mask | selectors.EVENT_READ: if mask | EVENT_READ:
unicode_data = decode(read(fileno, 1024)) unicode_data = decode(read(fileno, 1024))
for event in parser.feed(unicode_data): for event in feed(unicode_data):
self.process_event(event) self.process_event(event)
except Exception as error: except Exception as error:
log(error) log(error)

View File

@@ -285,5 +285,6 @@ class EventMonitor(threading.Thread):
def on_size_change(self, width: int, height: int) -> None: def on_size_change(self, width: int, height: int) -> None:
"""Called when terminal size changes.""" """Called when terminal size changes."""
event = Resize(self.target, Size(width, height)) size = Size(width, height)
event = Resize(self.target, size, size)
run_coroutine_threadsafe(self.target.post_message(event), loop=self.loop) run_coroutine_threadsafe(self.target.post_message(event), loop=self.loop)

View File

@@ -524,6 +524,7 @@ class Widget(DOMNode):
self._container_size = container_size self._container_size = container_size
if self.is_container: if self.is_container:
self._refresh_scrollbars()
width, height = self.container_size width, height = self.container_size
if self.show_vertical_scrollbar: if self.show_vertical_scrollbar:
self.vertical_scrollbar.window_virtual_size = virtual_size.height self.vertical_scrollbar.window_virtual_size = virtual_size.height
@@ -534,7 +535,6 @@ class Widget(DOMNode):
self.refresh(layout=True) self.refresh(layout=True)
self.call_later(self.scroll_to, self.scroll_x, self.scroll_y) self.call_later(self.scroll_to, self.scroll_x, self.scroll_y)
self._refresh_scrollbars()
else: else:
self.refresh() self.refresh()

View File

@@ -1,11 +1,15 @@
from decimal import Decimal
import pytest import pytest
from rich.style import Style from rich.style import Style
from textual.color import Color from textual.color import Color
from textual.css.errors import StyleValueError from textual.css.errors import StyleValueError
from textual.css.scalar import Scalar, Unit
from textual.css.styles import Styles, RenderStyles from textual.css.styles import Styles, RenderStyles
from textual.dom import DOMNode from textual.dom import DOMNode
from textual.widget import Widget
def test_styles_reset(): def test_styles_reset():
@@ -131,3 +135,53 @@ def test_opacity_set_invalid_type_error():
styles = RenderStyles(DOMNode(), Styles(), Styles()) styles = RenderStyles(DOMNode(), Styles(), Styles())
with pytest.raises(StyleValueError): with pytest.raises(StyleValueError):
styles.opacity = "invalid value" styles.opacity = "invalid value"
@pytest.mark.parametrize(
"size_dimension_input,size_dimension_expected_output",
[
# fmt: off
[None, None],
[1, Scalar(1, Unit.CELLS, Unit.WIDTH)],
[1.0, Scalar(1.0, Unit.CELLS, Unit.WIDTH)],
[1.2, Scalar(1.2, Unit.CELLS, Unit.WIDTH)],
[1.2e3, Scalar(1200.0, Unit.CELLS, Unit.WIDTH)],
["20", Scalar(20, Unit.CELLS, Unit.WIDTH)],
["1.4", Scalar(1.4, Unit.CELLS, Unit.WIDTH)],
[Scalar(100, Unit.CELLS, Unit.WIDTH), Scalar(100, Unit.CELLS, Unit.WIDTH)],
[Scalar(10.3, Unit.CELLS, Unit.WIDTH), Scalar(10.3, Unit.CELLS, Unit.WIDTH)],
[Scalar(10.4, Unit.CELLS, Unit.HEIGHT), Scalar(10.4, Unit.CELLS, Unit.HEIGHT)],
[Scalar(10.5, Unit.PERCENT, Unit.WIDTH), Scalar(10.5, Unit.WIDTH, Unit.WIDTH)],
[Scalar(10.6, Unit.PERCENT, Unit.PERCENT), Scalar(10.6, Unit.WIDTH, Unit.WIDTH)],
[Scalar(10.7, Unit.HEIGHT, Unit.PERCENT), Scalar(10.7, Unit.HEIGHT, Unit.PERCENT)],
# percentage values are normalised to floats and get the WIDTH "percent_unit":
[Scalar(11, Unit.PERCENT, Unit.HEIGHT), Scalar(11.0, Unit.WIDTH, Unit.WIDTH)],
# fmt: on
],
)
def test_widget_style_size_can_accept_various_data_types_and_normalize_them(
size_dimension_input, size_dimension_expected_output
):
widget = Widget()
widget.styles.width = size_dimension_input
assert widget.styles.width == size_dimension_expected_output
@pytest.mark.parametrize(
"size_dimension_input",
[
"a",
"1.4e3",
3.14j,
Decimal("3.14"),
list(),
tuple(),
dict(),
],
)
def test_widget_style_size_fails_if_data_type_is_not_supported(size_dimension_input):
widget = Widget()
with pytest.raises(StyleValueError):
widget.styles.width = size_dimension_input

55
tests/test_parser.py Normal file
View File

@@ -0,0 +1,55 @@
from textual._parser import Parser
def test_read1():
class TestParser(Parser[str]):
"""A simple parser that reads a byte at a time from a stream."""
def parse(self, on_token):
while True:
data = yield self.read1()
if not data:
break
on_token(data)
test_parser = TestParser()
test_data = "Where there is a Will there is a way!"
for size in range(1, len(test_data) + 1):
# Feed the parser in pieces, first 1 character at a time, then 2, etc
test_parser = TestParser()
data = []
for offset in range(0, len(test_data), size):
for chunk in test_parser.feed(test_data[offset : offset + size]):
data.append(chunk)
# Check we have received all the data in characters, no matter the fee dsize
assert len(data) == len(test_data)
assert "".join(data) == test_data
def test_read():
class TestParser(Parser[str]):
"""A parser that reads chunks of a given size from the stream."""
def __init__(self, size):
self.size = size
super().__init__()
def parse(self, on_token):
while True:
data = yield self.read1()
if not data:
break
on_token(data)
test_data = "Where there is a Will there is a way!"
for read_size in range(1, len(test_data) + 1):
for size in range(1, len(test_data) + 1):
test_parser = TestParser(read_size)
data = []
for offset in range(0, len(test_data), size):
for chunk in test_parser.feed(test_data[offset : offset + size]):
data.append(chunk)
assert "".join(data) == test_data

View File

@@ -1,16 +1,22 @@
from contextlib import nullcontext as does_not_raise
from decimal import Decimal
import pytest import pytest
from textual.css.errors import StyleValueError from textual.css.errors import StyleValueError
from textual.css.scalar import Scalar, Unit
from textual.widget import Widget from textual.widget import Widget
@pytest.mark.parametrize( @pytest.mark.parametrize(
"set_val, get_val, style_str", [ "set_val, get_val, style_str",
[
[True, True, "visible"], [True, True, "visible"],
[False, False, "hidden"], [False, False, "hidden"],
["hidden", False, "hidden"], ["hidden", False, "hidden"],
["visible", True, "visible"], ["visible", True, "visible"],
]) ],
)
def test_widget_set_visible_true(set_val, get_val, style_str): def test_widget_set_visible_true(set_val, get_val, style_str):
widget = Widget() widget = Widget()
widget.visible = set_val widget.visible = set_val