From 352c8b789dde4594e70b9662861ea48b98e43152 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Tue, 12 Apr 2022 16:18:10 +0100 Subject: [PATCH 01/11] tweak to log --- sandbox/basic.css | 6 +-- src/textual/app.py | 71 +++++++++++++++++++----------- src/textual/css/_styles_builder.py | 36 ++++++++++++++- src/textual/css/stylesheet.py | 2 +- src/textual/events.py | 4 +- src/textual/screen.py | 2 +- 6 files changed, 86 insertions(+), 35 deletions(-) diff --git a/sandbox/basic.css b/sandbox/basic.css index 3a4c2619e..8bcb7ee79 100644 --- a/sandbox/basic.css +++ b/sandbox/basic.css @@ -58,7 +58,7 @@ App > Screen { #header { color: $text-primary-darken-1; background: $primary-darken-1; - height: 3; + height: 3 } @@ -74,9 +74,9 @@ App > Screen { Tweet { height: 22; max-width: 80; - margin: 1 3; + margin: 1 3; background: $panel; - color: $text-panel + color: $text-panel; layout: vertical; /* border: outer $primary; */ padding: 1; diff --git a/src/textual/app.py b/src/textual/app.py index 02040bae4..c710ac5ee 100644 --- a/src/textual/app.py +++ b/src/textual/app.py @@ -4,6 +4,7 @@ import asyncio import inspect import os import platform +from time import perf_counter import warnings from asyncio import AbstractEventLoop from pathlib import Path @@ -29,7 +30,7 @@ from ._callback import invoke from ._context import active_app from ._event_broker import extract_handler_actions, NoHandler from .binding import Bindings, NoBinding -from .css.stylesheet import Stylesheet, StylesheetParseError, StylesheetError +from .css.stylesheet import Stylesheet from .devtools.client import DevtoolsClient, DevtoolsConnectionError from .design import ColorSystem from .dom import DOMNode @@ -102,6 +103,7 @@ class App(DOMNode): """ self.console = Console(markup=False, highlight=False) self.error_console = Console(markup=False, stderr=True) + self._screen = screen self.driver_class = driver_class or self.get_driver_class() self._title = title @@ -121,7 +123,16 @@ class App(DOMNode): self.bindings = Bindings() self._title = title - self.log_file = open(log, "wt") if log else None + self._log_console: Console | None = None + if log: + self.log_file = open(log, "wt") + self._log_console = Console( + file=self.log_file, markup=False, emoji=False, highlight=False + ) + else: + self._log_console = None + self._log_file = None + self.log_verbosity = log_verbosity self.bindings.bind("ctrl+c", "quit", show=False, allow_forward=False) @@ -219,30 +230,36 @@ class App(DOMNode): _textual_calling_frame (inspect.FrameInfo | None): The frame info to include in the log message sent to the devtools server. """ - output = "" + if verbosity > self.log_verbosity: + return + try: - output = f" ".join(str(arg) for arg in objects) - if kwargs: - key_values = " ".join(f"{key}={value}" for key, value in kwargs.items()) - output = " ".join((output, key_values)) - - if not _textual_calling_frame: - _textual_calling_frame = inspect.stack()[1] - - calling_path = _textual_calling_frame.filename - calling_lineno = _textual_calling_frame.lineno - - if self.devtools.is_connected and verbosity <= self.log_verbosity: - if len(objects) > 1 or len(kwargs) >= 1 and output: - self.devtools.log(output, path=calling_path, lineno=calling_lineno) - else: + if len(objects) == 1: + if self._log_console is not None: + self._log_console.print(objects[0]) + if self.devtools.is_connected: + if not _textual_calling_frame: + _textual_calling_frame = inspect.stack()[1] + calling_path = _textual_calling_frame.filename + calling_lineno = _textual_calling_frame.lineno self.devtools.log( - *objects, path=calling_path, lineno=calling_lineno + objects[0], path=calling_path, lineno=calling_lineno ) - - if self.log_file and verbosity <= self.log_verbosity: - self.log_file.write(output + "\n") - self.log_file.flush() + else: + output = f" ".join(str(arg) for arg in objects) + if kwargs: + key_values = " ".join( + f"{key}={value}" for key, value in kwargs.items() + ) + output = " ".join((output, key_values)) + if self._log_console is not None: + self._log_console.print(output, soft_wrap=True) + if self.devtools.is_connected: + if not _textual_calling_frame: + _textual_calling_frame = inspect.stack()[1] + calling_path = _textual_calling_frame.filename + calling_lineno = _textual_calling_frame.lineno + self.devtools.log(output, path=calling_path, lineno=calling_lineno) except Exception: pass @@ -310,11 +327,13 @@ class App(DOMNode): if self.css_file is not None: stylesheet = Stylesheet(variables=self.get_css_variables()) try: - self.log("loading", self.css_file) + time = perf_counter() stylesheet.read(self.css_file) - except StylesheetError as error: - self.log(error) + elapsed = (perf_counter() - time) * 1000 + self.log(f"loaded {self.css_file} in {elapsed:.0f}ms") + except Exception as error: self.console.bell() + self.log(error) else: self.reset_styles() self.stylesheet = stylesheet diff --git a/src/textual/css/_styles_builder.py b/src/textual/css/_styles_builder.py index 9e85c106f..63627025d 100644 --- a/src/textual/css/_styles_builder.py +++ b/src/textual/css/_styles_builder.py @@ -275,8 +275,8 @@ class StylesBuilder: space: list[int] = [] append = space.append for token in tokens: - token_name, value, _, _, location, _ = token - if token_name in ("number", "scalar"): + token_name, value, _, _, _, _ = token + if token_name == "number": try: append(int(value)) except ValueError: @@ -289,12 +289,44 @@ class StylesBuilder: ) self.styles._rules[name] = Spacing.unpack(cast(SpacingDimensions, tuple(space))) + def _process_space_partial(self, name: str, tokens: list[Token]) -> None: + if len(tokens) != 1: + self.error(name, tokens[0], "expected a single token here") + + _EDGE_SPACING_MAP = {"top": 0, "right": 1, "bottom": 2, "left": 3} + token = tokens[0] + token_name, value, _, _, _, _ = token + if token_name == "number": + space = int(value) + else: + self.error(name, token, f"expected a number here; found {value!r}") + style_name, _, edge = name.replace("-", "_").partition("_") + + current_spacing = cast( + tuple[int, int, int, int], self.styles._rules.get(style_name, (0, 0, 0, 0)) + ) + + spacing_list = list(current_spacing) + spacing_list[_EDGE_SPACING_MAP[edge]] = space + + self.styles._rules[style_name] = Spacing(*spacing_list) + def process_padding(self, name: str, tokens: list[Token]) -> None: self._process_space(name, tokens) def process_margin(self, name: str, tokens: list[Token]) -> None: self._process_space(name, tokens) + process_margin_top = _process_space_partial + process_margin_right = _process_space_partial + process_margin_bottom = _process_space_partial + process_margin_left = _process_space_partial + + process_padding_top = _process_space_partial + process_padding_right = _process_space_partial + process_padding_bottom = _process_space_partial + process_padding_left = _process_space_partial + def _parse_border(self, name: str, tokens: list[Token]) -> tuple[str, Color]: border_type = "solid" border_color = Color(0, 255, 0) diff --git a/src/textual/css/stylesheet.py b/src/textual/css/stylesheet.py index 6703c71c8..8ff1382e9 100644 --- a/src/textual/css/stylesheet.py +++ b/src/textual/css/stylesheet.py @@ -28,7 +28,7 @@ from ..dom import DOMNode from .. import log -class StylesheetParseError(Exception): +class StylesheetParseError(StylesheetError): def __init__(self, errors: StylesheetErrors) -> None: self.errors = errors diff --git a/src/textual/events.py b/src/textual/events.py index 6f9ef9fa9..5525a123a 100644 --- a/src/textual/events.py +++ b/src/textual/events.py @@ -202,7 +202,7 @@ class Key(InputEvent): @rich.repr.auto -class MouseEvent(InputEvent, bubble=True): +class MouseEvent(InputEvent, bubble=True, verbosity=2): """Sent in response to a mouse event""" __slots__ = [ @@ -344,7 +344,7 @@ class MouseScrollDown(InputEvent, verbosity=3, bubble=True): self.y = y -class MouseScrollUp(MouseScrollDown, bubble=True): +class MouseScrollUp(MouseScrollDown, verbosity=3, bubble=True): pass diff --git a/src/textual/screen.py b/src/textual/screen.py index 96b0825d0..bdc2002ef 100644 --- a/src/textual/screen.py +++ b/src/textual/screen.py @@ -91,7 +91,7 @@ class Screen(Widget): def on_idle(self, event: events.Idle) -> None: # Check for any widgets marked as 'dirty' (needs a repaint) if self._dirty_widgets: - self.log(dirty=len(self._dirty_widgets)) + # self.log(dirty=len(self._dirty_widgets)) for widget in self._dirty_widgets: # Repaint widgets # TODO: Combine these in to a single update. From e238bee274cbbabb46630dc3b1ff7b645f1c2775 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Tue, 12 Apr 2022 16:37:32 +0100 Subject: [PATCH 02/11] ws --- sandbox/basic.css | 2 +- src/textual/app.py | 2 +- tests/css/test_parse.py | 17 +++++++++++++++++ 3 files changed, 19 insertions(+), 2 deletions(-) diff --git a/sandbox/basic.css b/sandbox/basic.css index 8bcb7ee79..4d3e64d65 100644 --- a/sandbox/basic.css +++ b/sandbox/basic.css @@ -74,7 +74,7 @@ App > Screen { Tweet { height: 22; max-width: 80; - margin: 1 3; + margin: 1 3; background: $panel; color: $text-panel; layout: vertical; diff --git a/src/textual/app.py b/src/textual/app.py index c710ac5ee..873d4c5e7 100644 --- a/src/textual/app.py +++ b/src/textual/app.py @@ -234,7 +234,7 @@ class App(DOMNode): return try: - if len(objects) == 1: + if len(objects) == 1 and not kwargs: if self._log_console is not None: self._log_console.print(objects[0]) if self.devtools.is_connected: diff --git a/tests/css/test_parse.py b/tests/css/test_parse.py index 34f79034c..defb15a5e 100644 --- a/tests/css/test_parse.py +++ b/tests/css/test_parse.py @@ -9,6 +9,7 @@ from textual.css.stylesheet import Stylesheet, StylesheetParseError from textual.css.tokenize import tokenize from textual.css.tokenizer import Token, ReferencedBy from textual.css.transition import Transition +from textual.geometry import Spacing from textual.layouts.dock import DockLayout @@ -1065,3 +1066,19 @@ class TestParseOpacity: with pytest.raises(StylesheetParseError): stylesheet.parse(css) assert stylesheet.rules[0].errors + + +class TestParseMargin: + def test_margin_partial(self): + css = "#foo {margin: 1; margin-top: 2; margin-right: 3; margin-bottom: -1;}" + stylesheet = Stylesheet() + stylesheet.parse(css) + assert stylesheet.rules[0].styles.margin == Spacing(2, 3, -1, 1) + + +class TestParsePadding: + def test_padding_partial(self): + css = "#foo {padding: 1; padding-top: 2; padding-right: 3; padding-bottom: -1;}" + stylesheet = Stylesheet() + stylesheet.parse(css) + assert stylesheet.rules[0].styles.padding == Spacing(2, 3, -1, 1) From 2a7bc92b971d901ef08f4f710d21c4aa2eb6b077 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Tue, 12 Apr 2022 16:40:01 +0100 Subject: [PATCH 03/11] simplify --- src/textual/css/_styles_builder.py | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/src/textual/css/_styles_builder.py b/src/textual/css/_styles_builder.py index 63627025d..4a3776aa4 100644 --- a/src/textual/css/_styles_builder.py +++ b/src/textual/css/_styles_builder.py @@ -290,6 +290,7 @@ class StylesBuilder: self.styles._rules[name] = Spacing.unpack(cast(SpacingDimensions, tuple(space))) def _process_space_partial(self, name: str, tokens: list[Token]) -> None: + """Process granular margin / padding declarations.""" if len(tokens) != 1: self.error(name, tokens[0], "expected a single token here") @@ -311,11 +312,8 @@ class StylesBuilder: self.styles._rules[style_name] = Spacing(*spacing_list) - def process_padding(self, name: str, tokens: list[Token]) -> None: - self._process_space(name, tokens) - - def process_margin(self, name: str, tokens: list[Token]) -> None: - self._process_space(name, tokens) + process_padding = _process_space + process_margin = _process_space process_margin_top = _process_space_partial process_margin_right = _process_space_partial From cb691e125c0756c3aa420e55c007998809b67016 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Tue, 12 Apr 2022 16:40:33 +0100 Subject: [PATCH 04/11] Remove log --- src/textual/screen.py | 1 - 1 file changed, 1 deletion(-) diff --git a/src/textual/screen.py b/src/textual/screen.py index bdc2002ef..0b987352e 100644 --- a/src/textual/screen.py +++ b/src/textual/screen.py @@ -91,7 +91,6 @@ class Screen(Widget): def on_idle(self, event: events.Idle) -> None: # Check for any widgets marked as 'dirty' (needs a repaint) if self._dirty_widgets: - # self.log(dirty=len(self._dirty_widgets)) for widget in self._dirty_widgets: # Repaint widgets # TODO: Combine these in to a single update. From 98f4a16164a2a799e1f122364eb3ccbc25e00dce Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Tue, 12 Apr 2022 16:42:40 +0100 Subject: [PATCH 05/11] comment --- src/textual/app.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/textual/app.py b/src/textual/app.py index 873d4c5e7..587f3beb0 100644 --- a/src/textual/app.py +++ b/src/textual/app.py @@ -332,6 +332,7 @@ class App(DOMNode): elapsed = (perf_counter() - time) * 1000 self.log(f"loaded {self.css_file} in {elapsed:.0f}ms") except Exception as error: + # TODO: catch specific exceptions self.console.bell() self.log(error) else: From 13e6e944e5ca6f4838d66fa486f511148cbdb585 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Tue, 12 Apr 2022 16:46:33 +0100 Subject: [PATCH 06/11] comment --- src/textual/app.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/textual/app.py b/src/textual/app.py index 587f3beb0..2b2f78784 100644 --- a/src/textual/app.py +++ b/src/textual/app.py @@ -323,7 +323,7 @@ class App(DOMNode): event_loop.close() async def _on_css_change(self) -> None: - + """Called when the CSS changes (if watch_css is True).""" if self.css_file is not None: stylesheet = Stylesheet(variables=self.get_css_variables()) try: From cbd258d93a1cba33e304ae237622842376f4b883 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Tue, 12 Apr 2022 16:49:36 +0100 Subject: [PATCH 07/11] fix tests --- tests/css/test_tokenize.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/css/test_tokenize.py b/tests/css/test_tokenize.py index 96fa0cfa0..ff6565dc0 100644 --- a/tests/css/test_tokenize.py +++ b/tests/css/test_tokenize.py @@ -1,3 +1,5 @@ +from __future__ import annotations + import pytest from textual.css.tokenize import tokenize From ba92ef0c3cdd64e3a5363a75b4054e04ad70cb48 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Tue, 12 Apr 2022 17:10:15 +0100 Subject: [PATCH 08/11] test fix --- tests/css/test_parse.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/css/test_parse.py b/tests/css/test_parse.py index defb15a5e..b5b3179fd 100644 --- a/tests/css/test_parse.py +++ b/tests/css/test_parse.py @@ -1,3 +1,5 @@ +from __future__ import annotations + import pytest From 7b752336535e18e049f5f3a5d62648278ab20d12 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Tue, 12 Apr 2022 17:14:56 +0100 Subject: [PATCH 09/11] fix cast --- src/textual/css/_styles_builder.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/textual/css/_styles_builder.py b/src/textual/css/_styles_builder.py index 4a3776aa4..2903724b0 100644 --- a/src/textual/css/_styles_builder.py +++ b/src/textual/css/_styles_builder.py @@ -304,7 +304,8 @@ class StylesBuilder: style_name, _, edge = name.replace("-", "_").partition("_") current_spacing = cast( - tuple[int, int, int, int], self.styles._rules.get(style_name, (0, 0, 0, 0)) + "tuple[int, int, int, int]", + self.styles._rules.get(style_name, (0, 0, 0, 0)), ) spacing_list = list(current_spacing) From 06f196ebc8ede6505845f429aaa933ec8d68701b Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Tue, 12 Apr 2022 17:27:46 +0100 Subject: [PATCH 10/11] fix height --- src/textual/app.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/textual/app.py b/src/textual/app.py index 2b2f78784..9015cf082 100644 --- a/src/textual/app.py +++ b/src/textual/app.py @@ -101,7 +101,7 @@ class App(DOMNode): driver_class (Type[Driver], optional): Driver class, or None to use default. Defaults to None. title (str, optional): Title of the application. Defaults to "Textual Application". """ - self.console = Console(markup=False, highlight=False) + self.console = Console(markup=False, highlight=False, emoji=False) self.error_console = Console(markup=False, stderr=True) self._screen = screen @@ -127,7 +127,11 @@ class App(DOMNode): if log: self.log_file = open(log, "wt") self._log_console = Console( - file=self.log_file, markup=False, emoji=False, highlight=False + file=self.log_file, + markup=False, + emoji=False, + highlight=False, + width=100, ) else: self._log_console = None From 0249da9784d576fbcf01e3c401ed8b96b723718e Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Wed, 13 Apr 2022 11:17:31 +0100 Subject: [PATCH 11/11] Remove superfluous f string --- src/textual/app.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/textual/app.py b/src/textual/app.py index 9015cf082..d2c325cb5 100644 --- a/src/textual/app.py +++ b/src/textual/app.py @@ -250,7 +250,7 @@ class App(DOMNode): objects[0], path=calling_path, lineno=calling_lineno ) else: - output = f" ".join(str(arg) for arg in objects) + output = " ".join(str(arg) for arg in objects) if kwargs: key_values = " ".join( f"{key}={value}" for key, value in kwargs.items()